From 43db97ad2cde323b3d16cdebb57e4b331c1bcad1 Mon Sep 17 00:00:00 2001
From: Joe Orton
Date: Fri, 4 Sep 2026 19:46:16 +0100
Subject: [PATCH 1/8] * modules/aaa/mod_auth_digest.c: Fix stale and misleading
comments. (initialize_module): Correct the description of the client
table's lifetime. (note_digest_auth_failure): An unknown client is not
necessarily one whose entry was garbage collected.
No functional change. [skip ci]
Co-Authored-By: Claude Fable 5
GitHub: PR #730
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937709 13f79535-47bb-0310-9956-ffa450edef68
(cherry picked from commit 6f5f7eadbdaaa8b631d00a3a763c9aee37107a86)
---
modules/aaa/mod_auth_digest.c | 79 ++++++++++++++++-------------------
1 file changed, 36 insertions(+), 43 deletions(-)
diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c
index 0267a83be0d..ec18746af46 100644
--- a/modules/aaa/mod_auth_digest.c
+++ b/modules/aaa/mod_auth_digest.c
@@ -21,32 +21,27 @@
* Updated to RFC-2617 by Ronald Tschalär
* based on mod_auth, by Rob McCool and Robert S. Thau
*
- * This module an updated version of modules/standard/mod_digest.c
- * It is still fairly new and problems may turn up - submit problem
- * reports to the Apache bug-database, or send them directly to me
- * at ronald@innovation.ch.
- *
* Open Issues:
- * - qop=auth-int (when streams and trailer support available)
- * - nonce-format configurability
+ * - MD5-sess and auth-int are not implemented; auth-int needs stream and
+ * trailer support. An incomplete implementation has been removed and
+ * can be retrieved from svn history.
* - Proxy-Authentication-Info header is set by this module, but is
* currently ignored by mod_proxy (needs patch to mod_proxy)
* - The source of the secret should be run-time directive (with server
- * scope: RSRC_CONF)
- * - shared-mem not completely tested yet. Seems to work ok for me,
- * but... (definitely won't work on Windoze)
- * - Sharing a realm among multiple servers has following problems:
- * o Server name and port can't be included in nonce-hash
- * (we need two nonce formats, which must be configured explicitly)
- * o Nonce-count check can't be for equal, or then nonce-count checking
- * must be disabled. What we could do is the following:
- * (expected < received) ? set expected = received : issue error
- * The only problem is that it allows replay attacks when somebody
- * captures a packet sent to one server and sends it to another
- * one. Should we add "AuthDigestNcCheck Strict"?
- * - expired nonces give amaya fits.
- * - MD5-sess and auth-int are not yet implemented. An incomplete
- * implementation has been removed and can be retrieved from svn history.
+ * scope: RSRC_CONF). Each server generates its own, so a nonce issued
+ * by one does not verify at another, and a client moving between them
+ * is re-challenged every time.
+ * - Sharing a realm among multiple servers needs more than that secret,
+ * though. It would be enough for a configuration which tracks no
+ * per-client state, but the client table, the ids which key it and the
+ * one-time nonce counter are all per-server, so under AuthDigestNcCheck
+ * or AuthDigestNonceLifetime 0 a client would still be unknown to
+ * whichever server it reached next.
+ * - Sharing the secret would also make a request captured against one
+ * server replayable against the others, and the nonce-count check
+ * would not stop that, since each server counts separately. Hashing
+ * the server name and port into the nonce would, but that is the
+ * opposite of sharing: the two would have to be configured explicitly.
*/
#include "apr_sha1.h"
@@ -399,15 +394,15 @@ static int initialize_module(apr_pool_t *p, apr_pool_t *plog,
if (ap_state_query(AP_SQ_MAIN_STATE) == AP_SQ_MS_CREATE_PRE_CONFIG)
return OK;
- /* Note: this stuff is currently fixed for the lifetime of the server,
- * i.e. even across restarts. This means that A) any shmem-size
- * configuration changes are ignored, and B) certain optimizations,
- * such as only allocating the smallest necessary entry for each
- * client, can't be done. However, the alternative is a nightmare:
- * we can't call apr_shm_destroy on a graceful restart because there
- * will be children using the tables, and we also don't know when the
- * last child dies. Therefore we can never clean up the old stuff,
- * creating a creeping memory leak.
+ /* The client table belongs to one configuration generation: it is
+ * allocated from pconf, which the restart loop clears - destroying the
+ * segment - before running this hook again to create a new one. A
+ * child of the previous generation keeps the mapping it inherited
+ * until it exits, so the tables are not pulled out from under it.
+ *
+ * Per-client state therefore does not survive a restart. A client
+ * which returns afterwards is simply unknown, and is re-challenged
+ * with stale=true at the cost of one extra request.
*/
return initialize_tables(s, p);
}
@@ -1199,10 +1194,15 @@ static int note_digest_auth_failure(request_rec *r,
}
opaque = ltox(r->pool, client_key);
}
- /* else no opaque is needed, and none is sent */
+ /* else this configuration tracks no per-client state, so no entry
+ * is allocated and no opaque is sent */
}
else if (!client_exists(resp->opaque_num, r)) {
- /* client info was gc'd */
+ /* We have no record of this client: its entry may have been
+ * garbage collected, the segment may have been recreated by a
+ * restart, or the opaque may never have been issued by us.
+ * Nothing was wrong with the credentials, so the challenge is
+ * stale and an RFC-compliant client retries silently. */
if ((client_key = client_generate(r)) == 0) {
return HTTP_SERVICE_UNAVAILABLE;
}
@@ -1227,16 +1227,9 @@ static int note_digest_auth_failure(request_rec *r,
nonce = gen_nonce(r->pool, r->request_time, opaque, r->server, conf, ap_auth_name(r));
- /* setup domain attribute. We want to send this attribute wherever
- * possible so that the client won't send the Authorization header
- * unnecessarily (it's usually > 200 bytes!).
- */
-
-
- /* don't send domain
- * - for proxy requests
- * - if it's not specified
- */
+ /* Setup domain, which tells the client which URIs share this
+ * protection space, so that it does not send the Authorization header
+ * (usually more than 200 bytes) where it is not needed. */
if (r->proxyreq || !conf->uri_list) {
domain = NULL;
}
From e3d30ee52a83c5895f1fb1f5ccd756c7777efcc8 Mon Sep 17 00:00:00 2001
From: Joe Orton
Date: Fri, 4 Sep 2026 19:46:16 +0100
Subject: [PATCH 2/8] mod_auth_digest: Don't reuse client ids across a restart:
* modules/aaa/mod_auth_digest.c (initialize_tables): Seed the client ids
randomly for each shared memory segment.
* test/modules/aaa/test_009_restart.py: New test suite.
Co-Authored-By: Claude Fable 5
GitHub: PR #730
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937711 13f79535-47bb-0310-9956-ffa450edef68
(cherry picked from commit f4c4160fbf98ff0357e20bc5b1c93615f678b573)
---
modules/aaa/mod_auth_digest.c | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c
index ec18746af46..2d2d1875dbe 100644
--- a/modules/aaa/mod_auth_digest.c
+++ b/modules/aaa/mod_auth_digest.c
@@ -269,6 +269,7 @@ static int initialize_tables(server_rec *s, apr_pool_t *ctx)
{
unsigned long idx;
apr_status_t sts;
+ client_id_t seed;
/* set up client list */
@@ -343,7 +344,15 @@ static int initialize_tables(server_rec *s, apr_pool_t *ctx)
log_error_and_cleanup("failed to allocate shared memory", -1, s);
return !OK;
}
- *client_id_counter = 1;
+ /* Start the ids at a random point rather than at 1. This segment does
+ * not survive a restart, but the nonces naming its entries do, since the
+ * secret they are hashed with is retained; ids restarting from 1 too
+ * would hand a returning client's id straight back out, so that client
+ * would be checked against whichever new client now held it. The ids are
+ * not secret - they are sent in the clear as the opaque - this only has
+ * to make them distinct across a restart. */
+ ap_random_insecure_bytes(&seed, sizeof seed);
+ *client_id_counter = seed;
/* setup one-time-nonce counter */
@@ -400,9 +409,10 @@ static int initialize_module(apr_pool_t *p, apr_pool_t *plog,
* child of the previous generation keeps the mapping it inherited
* until it exits, so the tables are not pulled out from under it.
*
- * Per-client state therefore does not survive a restart. A client
- * which returns afterwards is simply unknown, and is re-challenged
- * with stale=true at the cost of one extra request.
+ * Per-client state therefore does not survive a restart, and neither
+ * does the client id space; see the seeding in initialize_tables().
+ * A client which returns afterwards is simply unknown, and is
+ * re-challenged with stale=true at the cost of one extra request.
*/
return initialize_tables(s, p);
}
From 2fe7bb44b4774b427018655ee2eedde8a2bbec09 Mon Sep 17 00:00:00 2001
From: Joe Orton
Date: Fri, 4 Sep 2026 19:46:16 +0100
Subject: [PATCH 3/8] * modules/aaa/mod_auth_digest.c: Raise the default
AuthDigestShmemSize to 8192 bytes, holding around 140 clients.
* docs/manual/mod/mod_auth_digest.xml: Document the new default.
* test/modules/aaa/conftest.py, test/modules/aaa/test_008_onetime_nccheck.py:
Pin AuthDigestShmemSize to the old size.
Co-Authored-By: Claude Fable 5
GitHub: PR #730
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937712 13f79535-47bb-0310-9956-ffa450edef68
(cherry picked from commit 8e3c9de728a8184a2f5afcca868d7481dc1fc19f)
---
changes-entries/digest-shmem-size.txt | 3 +++
docs/manual/mod/mod_auth_digest.xml | 9 ++++++++-
modules/aaa/mod_auth_digest.c | 12 ++++++++----
3 files changed, 19 insertions(+), 5 deletions(-)
create mode 100644 changes-entries/digest-shmem-size.txt
diff --git a/changes-entries/digest-shmem-size.txt b/changes-entries/digest-shmem-size.txt
new file mode 100644
index 00000000000..fbd28a1649f
--- /dev/null
+++ b/changes-entries/digest-shmem-size.txt
@@ -0,0 +1,3 @@
+ *) mod_auth_digest: Increase the default AuthDigestShmemSize to 8192
+ bytes, tracking around 140 clients rather than around 12.
+ [Joe Orton]
diff --git a/docs/manual/mod/mod_auth_digest.xml b/docs/manual/mod/mod_auth_digest.xml
index 392f7c8174a..f89e4171013 100644
--- a/docs/manual/mod/mod_auth_digest.xml
+++ b/docs/manual/mod/mod_auth_digest.xml
@@ -240,7 +240,7 @@ authentication
The amount of shared memory to allocate for keeping track
of clients
AuthDigestShmemSize size
-AuthDigestShmemSize 1000
+AuthDigestShmemSize 8192
server config
@@ -254,6 +254,13 @@ of clients
0 and read the error message after trying to start the
server.
+ The default holds roughly 140 clients. A client which is discarded
+ to make room for another is not denied access: it is issued a new
+ nonce with stale=true, which costs it one extra request.
+ Note that a request which does not authenticate also takes an entry,
+ since the challenge sent back to it carries the identifier the client
+ is tracked by.
+
The size is normally expressed in Bytes, but you
may follow the number with a K or an M to
express your value as KBytes or MBytes. For example, the following
diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c
index 2d2d1875dbe..3f7fd934de6 100644
--- a/modules/aaa/mod_auth_digest.c
+++ b/modules/aaa/mod_auth_digest.c
@@ -197,9 +197,14 @@ static apr_global_mutex_t *client_lock = NULL;
static const char *client_mutex_type = "authdigest-client";
static const char *client_shm_filename;
-#define DEF_SHMEM_SIZE 1000L /* ~ 12 entries */
-#define DEF_NUM_BUCKETS 15L
+#define DEF_SHMEM_SIZE 8192L /* ~ 140 entries */
#define HASH_DEPTH 5
+/* Buckets for a given segment size, so that the default and
+ * AuthDigestShmemSize cannot disagree. */
+#define NUM_BUCKETS(size_) (((size_) - sizeof(*client_list)) / \
+ (sizeof(client_entry *) \
+ + HASH_DEPTH * sizeof(client_entry)))
+#define DEF_NUM_BUCKETS NUM_BUCKETS(DEF_SHMEM_SIZE)
static apr_size_t shmem_size = DEF_SHMEM_SIZE;
static unsigned long num_buckets = DEF_NUM_BUCKETS;
@@ -583,8 +588,7 @@ static const char *set_shmem_size(cmd_parms *cmd, void *config,
}
shmem_size = size;
- num_buckets = (size - sizeof(*client_list)) /
- (sizeof(client_entry*) + HASH_DEPTH * sizeof(client_entry));
+ num_buckets = NUM_BUCKETS(size);
if (num_buckets == 0) {
num_buckets = 1;
}
From fb97754b9304c1ab966214a1dc9df6ae9cc4dea5 Mon Sep 17 00:00:00 2001
From: Joe Orton
Date: Fri, 4 Sep 2026 19:46:16 +0100
Subject: [PATCH 4/8] * modules/aaa/mod_auth_digest.c (gc): Discard the entries
of clients which have never authenticated before the least recently used.
* test/modules/aaa/test_010_eviction.py: New test suite.
* test/modules/aaa/test_008_onetime_nccheck.py (085): Fill the table with
clients which have authenticated.
Co-Authored-By: Claude Fable 5
GitHub: PR #730
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937713 13f79535-47bb-0310-9956-ffa450edef68
(cherry picked from commit 57968c056a9e19b368ae556a1617538cb2011b0c)
---
modules/aaa/mod_auth_digest.c | 40 ++++++++++++++++++-----------------
1 file changed, 21 insertions(+), 19 deletions(-)
diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c
index 3f7fd934de6..a78b9c159be 100644
--- a/modules/aaa/mod_auth_digest.c
+++ b/modules/aaa/mod_auth_digest.c
@@ -824,33 +824,35 @@ static enum nonce_state client_update_nonce(const request_rec *r,
*/
static unsigned long gc(server_rec *s)
{
- client_entry *entry, *prev;
unsigned long num_removed = 0, idx;
- /* garbage collect all last entries */
+ /* garbage collect one entry from each bucket */
for (idx = 0; idx < client_list->tbl_len; idx++) {
- entry = client_list->table[idx];
- prev = NULL;
-
- if (!entry) {
- /* This bucket is empty. */
- continue;
+ client_entry **link, **victim = NULL;
+ int unused = 0;
+
+ /* The last entry is the least recently used, since find_client()
+ * moves an entry to the front on each access; but prefer a client
+ * which has never completed an authentication, whose entry records
+ * nothing and so costs it nothing to lose. Every request which
+ * fails to authenticate allocates one of those, and without this
+ * they would evict the clients which are using the server. */
+ for (link = &client_list->table[idx]; *link; link = &(*link)->next) {
+ if ((*link)->last_nonce_time == 0) {
+ victim = link;
+ unused = 1;
+ }
+ else if (!unused) {
+ victim = link;
+ }
}
- while (entry->next) { /* find last entry */
- prev = entry;
- entry = entry->next;
- }
- if (prev) {
- prev->next = NULL; /* cut list */
- }
- else {
- client_list->table[idx] = NULL;
- }
- if (entry) { /* remove entry */
+ if (victim) {
+ client_entry *entry = *victim;
apr_status_t err;
+ *victim = entry->next;
err = rmm_free(client_rmm, entry);
num_removed++;
From 31d5131b193ea7751b0d4b3e65c87b7b90fb2db2 Mon Sep 17 00:00:00 2001
From: Joe Orton
Date: Fri, 4 Sep 2026 19:46:16 +0100
Subject: [PATCH 5/8] * modules/aaa/mod_auth_digest.c
(note_digest_auth_failure): Drop the assignment to client_key which is
never read, and the qop variable which only ever holds one string.
No functional change. [skip ci]
Co-Authored-By: Claude Fable 5
GitHub: PR #730
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937715 13f79535-47bb-0310-9956-ffa450edef68
(cherry picked from commit 0f49205436a951d14f701e5053748b5c24890347)
---
modules/aaa/mod_auth_digest.c | 13 +++++--------
1 file changed, 5 insertions(+), 8 deletions(-)
diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c
index a78b9c159be..6cfee69db87 100644
--- a/modules/aaa/mod_auth_digest.c
+++ b/modules/aaa/mod_auth_digest.c
@@ -1194,11 +1194,8 @@ static int note_digest_auth_failure(request_rec *r,
const digest_config_rec *conf,
digest_header_rec *resp, int stale)
{
- const char *qop, *opaque = NULL, *opaque_param = "", *domain, *nonce;
- client_id_t client_key = 0;
-
- /* Setup qop */
- qop = ", qop=\"auth\"";
+ const char *opaque = NULL, *opaque_param = "", *domain, *nonce;
+ client_id_t client_key;
/* Setup opaque */
@@ -1231,7 +1228,6 @@ static int note_digest_auth_failure(request_rec *r,
* here: the client may not even see this challenge (it may have
* been triggered by somebody else quoting its opaque), and it is
* tied to the nonce it was counted for in any case. */
- client_key = resp->opaque_num;
opaque = resp->opaque;
}
@@ -1257,11 +1253,12 @@ static int note_digest_auth_failure(request_rec *r,
(PROXYREQ_PROXY == r->proxyreq)
? "Proxy-Authenticate" : "WWW-Authenticate",
apr_psprintf(r->pool, "Digest realm=\"%s\", "
- "nonce=\"%s\", algorithm=%s%s%s%s%s",
+ "nonce=\"%s\", algorithm=%s%s%s%s"
+ ", qop=\"auth\"",
ap_auth_name(r), nonce, conf->algorithm,
opaque_param,
domain ? domain : "",
- stale ? ", stale=true" : "", qop));
+ stale ? ", stale=true" : ""));
return HTTP_UNAUTHORIZED;
}
From 5f61dc79b3aa9ede02068f3fef66ab154900725e Mon Sep 17 00:00:00 2001
From: Joe Orton
Date: Fri, 4 Sep 2026 19:46:16 +0100
Subject: [PATCH 6/8] mod_auth_digest: Use siphash for the nonce hash where
available:
* modules/aaa/mod_auth_digest.c (gen_nonce_hash): Use apr_siphash24_auth()
where APU 1.6 or later provides it, keeping the SHA-1 hash otherwise.
Take a pool for the message buffer, and length-prefix the realm.
* test/modules/aaa/test_002_nonce.py (test_digest_021): Take the length of
the hash from the nonce rather than assuming SHA-1's.
Co-Authored-By: Claude Fable 5
GitHub: closes #730
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937716 13f79535-47bb-0310-9956-ffa450edef68
(cherry picked from commit 8be3ef87f0308fe733fc36f3edbee4e3aa618a37)
---
modules/aaa/mod_auth_digest.c | 59 ++++++++++++++++++++++++++++++++---
1 file changed, 54 insertions(+), 5 deletions(-)
diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c
index 6cfee69db87..fac9251fb92 100644
--- a/modules/aaa/mod_auth_digest.c
+++ b/modules/aaa/mod_auth_digest.c
@@ -44,6 +44,7 @@
* opposite of sharing: the two would have to be configured explicitly.
*/
+#include "apu_version.h"
#include "apr_sha1.h"
#include "apr_base64.h"
#include "apr_lib.h"
@@ -82,6 +83,21 @@
#error mod_auth_digest requires APR with random and shared memory support
#endif
+/* The nonce is authenticated with a keyed hash, so that a client cannot
+ * forge one. apr_siphash() is a MAC, and is what that calls for; it is
+ * available in APU 1.6 / APR 2.0 and later. Older APR falls back to the
+ * original SHA-1 of the secret followed by the message, which is not a
+ * sound construction - a Merkle-Damgard hash keyed by a prefix can be
+ * extended without the key - but which is not attackable here, since the
+ * padding such an extension needs cannot survive the opaque being parsed
+ * as a hex number before the nonce is checked. */
+#if APU_MAJOR_VERSION > 1 || (APU_MAJOR_VERSION == 1 && APU_MINOR_VERSION >= 6)
+#define DIGEST_SIPHASH_NONCE 1
+#include "apr_siphash.h"
+#else
+#define DIGEST_SIPHASH_NONCE 0
+#endif
+
/* struct to hold the configuration info */
typedef struct digest_config_struct {
@@ -99,9 +115,16 @@ typedef struct digest_config_struct {
#define NEXTNONCE_DELTA apr_time_from_sec(30)
/* The server nonce has fixed length and is the concatenation of:
- * base64(apr_time_t timestamp) + hex(SHA1(realm+time[+opaque])) */
+ * base64(apr_time_t timestamp) + hex(keyed hash of realm+time[+opaque])
+ * The hash is half as long with siphash, which produces 64 bits: enough for
+ * a value which can only be attacked by presenting it to the server, there
+ * being no way to test a candidate offline. */
#define NONCE_TIME_LEN (((sizeof(apr_time_t)+2)/3)*4)
+#if DIGEST_SIPHASH_NONCE
+#define NONCE_HASH_LEN (2*APR_SIPHASH_DSIZE)
+#else
#define NONCE_HASH_LEN (2*APR_SHA1_DIGESTSIZE)
+#endif
#define NONCE_LEN (int )(NONCE_TIME_LEN + NONCE_HASH_LEN)
/* Evaluates to true if nonce string is valid. Since the time part of
* the nonce is a base64 encoding of an apr_time_t (8 bytes), it
@@ -113,6 +136,10 @@ typedef struct digest_config_struct {
#define SECRET_LEN 20
#define RETAINED_DATA_ID "mod_auth_digest"
+#if DIGEST_SIPHASH_NONCE && SECRET_LEN < APR_SIPHASH_KSIZE
+#error the secret is too short to key siphash
+#endif
+
/* client list definitions */
@@ -1093,11 +1120,31 @@ static int init_digest_request(request_rec *r)
/* Writes the hash part of the server nonce to hash, which must be of
* minimum size (NONCE_HASH_LEN+1). */
-static void gen_nonce_hash(char hash[NONCE_HASH_LEN+1], const char *timestr, const char *opaque,
+static void gen_nonce_hash(apr_pool_t *p, char hash[NONCE_HASH_LEN+1],
+ const char *timestr, const char *opaque,
const server_rec *server,
- const digest_config_rec *conf,
+ const digest_config_rec *conf,
const char *realm)
{
+#if DIGEST_SIPHASH_NONCE
+ unsigned char mac[APR_SIPHASH_DSIZE];
+ const char *msg;
+
+ /* siphash takes the whole message at once, having no streaming
+ * interface, so the fields are joined here rather than fed in one at a
+ * time. The realm is length-prefixed, which keeps the boundaries
+ * between the fields unambiguous whatever they contain: without it a
+ * realm ending in what another realm's timestamp begins with would
+ * hash the same. An absent opaque is the empty string, which is what
+ * the challenge side passes for it too. */
+ msg = apr_psprintf(p, "%" APR_SIZE_T_FMT ":%s:%s:%s",
+ (apr_size_t)strlen(realm), realm, timestr,
+ opaque ? opaque : "");
+
+ apr_siphash24_auth(mac, msg, strlen(msg), secret);
+
+ ap_bin2hex(mac, APR_SIPHASH_DSIZE, hash);
+#else
unsigned char sha1[APR_SHA1_DIGESTSIZE];
apr_sha1_ctx_t ctx;
@@ -1113,6 +1160,7 @@ static void gen_nonce_hash(char hash[NONCE_HASH_LEN+1], const char *timestr, con
apr_sha1_final(sha1, &ctx);
ap_bin2hex(sha1, APR_SHA1_DIGESTSIZE, hash);
+#endif
}
@@ -1136,7 +1184,7 @@ static const char *gen_nonce(apr_pool_t *p, apr_time_t now, const char *opaque,
t.time = apr_atomic_inc32(otn_counter) + 1;
}
apr_base64_encode_binary(nonce, t.arr, sizeof(t.arr));
- gen_nonce_hash(nonce+NONCE_TIME_LEN, nonce, opaque, server, conf, realm);
+ gen_nonce_hash(p, nonce+NONCE_TIME_LEN, nonce, opaque, server, conf, realm);
return nonce;
}
@@ -1415,7 +1463,8 @@ static int check_nonce(request_rec *r, digest_header_rec *resp,
tmp = resp->nonce[NONCE_TIME_LEN];
resp->nonce[NONCE_TIME_LEN] = '\0';
apr_base64_decode_binary(nonce_time.arr, resp->nonce);
- gen_nonce_hash(hash, resp->nonce, resp->opaque, r->server, conf, ap_auth_name(r));
+ gen_nonce_hash(r->pool, hash, resp->nonce, resp->opaque, r->server, conf,
+ ap_auth_name(r));
resp->nonce[NONCE_TIME_LEN] = tmp;
resp->nonce_time = nonce_time.time;
From 82f835926b3f43f7496f03d0bf76668d793f8b3a Mon Sep 17 00:00:00 2001
From: Joe Orton
Date: Fri, 4 Sep 2026 19:50:21 +0100
Subject: [PATCH 7/8] * test/modules/aaa: Copied from trunk at 8be3ef87f0.
Co-Authored-By: Claude Fable 5.1
---
test/modules/aaa/__init__.py | 1 +
test/modules/aaa/conftest.py | 105 ++++++++
test/modules/aaa/digest_client.py | 134 ++++++++++
test/modules/aaa/env.py | 79 ++++++
.../aaa/htdocs/digest/default/secret.txt | 1 +
.../htdocs/digest/domain/nested/secret.txt | 1 +
.../aaa/htdocs/digest/domain/secret.txt | 1 +
.../digest/nccheck-shortlife/secret.txt | 1 +
.../aaa/htdocs/digest/nccheck/secret.txt | 1 +
.../aaa/htdocs/digest/neverexpire/secret.txt | 1 +
.../aaa/htdocs/digest/noprovider/secret.txt | 1 +
.../htdocs/digest/onetime-nccheck/secret.txt | 1 +
.../aaa/htdocs/digest/onetime/secret.txt | 1 +
.../aaa/htdocs/digest/shortlife/secret.txt | 1 +
.../aaa/test_001_challenge_response.py | 180 +++++++++++++
test/modules/aaa/test_002_nonce.py | 134 ++++++++++
test/modules/aaa/test_003_nccheck.py | 143 +++++++++++
test/modules/aaa/test_004_domain.py | 56 +++++
test/modules/aaa/test_005_provider.py | 37 +++
test/modules/aaa/test_006_config_errors.py | 86 +++++++
test/modules/aaa/test_007_replay.py | 237 ++++++++++++++++++
test/modules/aaa/test_008_onetime_nccheck.py | 194 ++++++++++++++
test/modules/aaa/test_009_restart.py | 200 +++++++++++++++
test/modules/aaa/test_010_eviction.py | 120 +++++++++
24 files changed, 1716 insertions(+)
create mode 100644 test/modules/aaa/__init__.py
create mode 100644 test/modules/aaa/conftest.py
create mode 100644 test/modules/aaa/digest_client.py
create mode 100644 test/modules/aaa/env.py
create mode 100644 test/modules/aaa/htdocs/digest/default/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/domain/nested/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/domain/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/nccheck/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/neverexpire/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/noprovider/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/onetime/secret.txt
create mode 100644 test/modules/aaa/htdocs/digest/shortlife/secret.txt
create mode 100644 test/modules/aaa/test_001_challenge_response.py
create mode 100644 test/modules/aaa/test_002_nonce.py
create mode 100644 test/modules/aaa/test_003_nccheck.py
create mode 100644 test/modules/aaa/test_004_domain.py
create mode 100644 test/modules/aaa/test_005_provider.py
create mode 100644 test/modules/aaa/test_006_config_errors.py
create mode 100644 test/modules/aaa/test_007_replay.py
create mode 100644 test/modules/aaa/test_008_onetime_nccheck.py
create mode 100644 test/modules/aaa/test_009_restart.py
create mode 100644 test/modules/aaa/test_010_eviction.py
diff --git a/test/modules/aaa/__init__.py b/test/modules/aaa/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/test/modules/aaa/__init__.py
@@ -0,0 +1 @@
+
diff --git a/test/modules/aaa/conftest.py b/test/modules/aaa/conftest.py
new file mode 100644
index 00000000000..1ea2aba7966
--- /dev/null
+++ b/test/modules/aaa/conftest.py
@@ -0,0 +1,105 @@
+import logging
+import os
+import sys
+
+import pytest
+
+from .env import AAATestEnv
+from pyhttpd.conf import HttpdConf
+
+sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
+
+
+def pytest_report_header(config, start_path):
+ env = AAATestEnv()
+ return f"mod_auth_digest [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
+
+
+def _digest_dir(docs, path, extra_lines):
+ lines = [
+ f'',
+ ' AuthType Digest',
+ f' AuthName "{AAATestEnv.REALM}"',
+ ]
+ lines.extend(f" {l}" for l in extra_lines)
+ lines.append(' Require valid-user')
+ lines.append('')
+ return lines
+
+
+@pytest.fixture(scope="package")
+def env(pytestconfig) -> AAATestEnv:
+ level = logging.INFO
+ console = logging.StreamHandler()
+ console.setLevel(level)
+ console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
+ logging.getLogger('').addHandler(console)
+ logging.getLogger('').setLevel(level=level)
+ env = AAATestEnv(pytestconfig=pytestconfig)
+ env.setup_httpd()
+ env.apache_access_log_clear()
+ env.httpd_error_log.clear_log()
+
+ docs = env.server_docs_dir
+ pwfile = env.digest_pwfile
+ conf = HttpdConf(env)
+ # Pin the client table to its historical size, ~12 entries, rather than
+ # the current default of ~140: the tests which need an entry to be
+ # garbage collected (085) drive that by filling the table with bare
+ # requests, and a table an order of magnitude larger makes them an order
+ # of magnitude slower for nothing.
+ conf.add('AuthDigestShmemSize 1000')
+ conf.add(_digest_dir(docs, "default", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ ]))
+ conf.add(_digest_dir(docs, "nccheck", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNcCheck On',
+ ]))
+ conf.add(_digest_dir(docs, "nccheck-shortlife", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNcCheck On',
+ 'AuthDigestNonceLifetime 2',
+ ]))
+ conf.add(_digest_dir(docs, "shortlife", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime 2',
+ ]))
+ conf.add(_digest_dir(docs, "neverexpire", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime -1',
+ ]))
+ conf.add(_digest_dir(docs, "onetime", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime 0',
+ ]))
+ conf.add(_digest_dir(docs, "onetime-nccheck", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestNonceLifetime 0',
+ 'AuthDigestNcCheck On',
+ ]))
+ conf.add(_digest_dir(docs, "domain", [
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{pwfile}"',
+ 'AuthDigestDomain "/digest/domain/" "https://mirror.example.org/other/"',
+ ]))
+ conf.add(_digest_dir(docs, "noprovider", [
+ # AuthDigestProvider intentionally omitted: falls back to "file".
+ f'AuthUserFile "{pwfile}"',
+ ]))
+ conf.install()
+ assert env.apache_restart() == 0
+ return env
+
+
+@pytest.fixture(autouse=True, scope="package")
+def _stop_package_scope(env):
+ yield
+ assert env.apache_stop() == 0
diff --git a/test/modules/aaa/digest_client.py b/test/modules/aaa/digest_client.py
new file mode 100644
index 00000000000..b0acf0fc8ad
--- /dev/null
+++ b/test/modules/aaa/digest_client.py
@@ -0,0 +1,134 @@
+"""Minimal hand-rolled RFC 2617 Digest auth client.
+
+curl's own `--digest` handles the challenge/response handshake transparently,
+which is no good for testing edge cases (tampered nonces, replayed
+nonce-counts, wrong realms, bad algorithm tokens, ...). This module lets
+tests parse a WWW-Authenticate challenge, compute the expected response by
+hand, and build a (possibly deliberately broken) Authorization header.
+
+mod_auth_digest here only implements qop="auth" (see modules/aaa/mod_auth_digest.c
+Open Issues: "MD5-sess and auth-int are not yet implemented"), so this client
+only implements the qop=auth request-digest/response-auth formulas from
+RFC 2617 section 3.2.2.
+"""
+
+import hashlib
+import re
+from dataclasses import dataclass
+from typing import Dict, List, Optional
+
+_PARAM_RE = re.compile(r'(\w+)=(?:"([^"]*)"|([^\s,]+))\s*,?\s*')
+
+
+def _md5hex(s: str) -> str:
+ return hashlib.md5(s.encode('utf-8')).hexdigest()
+
+
+def parse_params(value: str) -> Dict[str, str]:
+ """Parse a comma-separated key=value / key="value" list, as used by
+ both WWW-Authenticate and Authentication-Info header values."""
+ params = {}
+ for m in _PARAM_RE.finditer(value):
+ key = m.group(1)
+ val = m.group(2) if m.group(2) is not None else m.group(3)
+ params[key.lower()] = val
+ return params
+
+
+@dataclass
+class DigestChallenge:
+ realm: Optional[str]
+ nonce: Optional[str]
+ algorithm: Optional[str] = None
+ opaque: Optional[str] = None
+ domain: Optional[str] = None
+ qop: Optional[str] = None
+ stale: bool = False
+ raw: str = ""
+
+ @staticmethod
+ def parse(www_authenticate: str) -> 'DigestChallenge':
+ assert www_authenticate.startswith("Digest "), \
+ f"not a Digest challenge: {www_authenticate}"
+ params = parse_params(www_authenticate[len("Digest "):])
+ return DigestChallenge(
+ realm=params.get('realm'),
+ nonce=params.get('nonce'),
+ algorithm=params.get('algorithm'),
+ opaque=params.get('opaque'),
+ domain=params.get('domain'),
+ qop=params.get('qop'),
+ stale=params.get('stale', '').lower() == 'true',
+ raw=www_authenticate,
+ )
+
+ def domain_list(self) -> List[str]:
+ return self.domain.split() if self.domain else []
+
+
+def ha1(username: str, realm: str, password: str) -> str:
+ return _md5hex(f"{username}:{realm}:{password}")
+
+
+def ha2(method: str, uri: str) -> str:
+ return _md5hex(f"{method}:{uri}")
+
+
+def request_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
+ qop: str, ha2_hex: str) -> str:
+ return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
+
+
+def rspauth_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
+ qop: str, uri: str) -> str:
+ """Authentication-Info's rspauth uses A2 = ':' + uri (no method)."""
+ ha2_hex = _md5hex(f":{uri}")
+ return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
+
+
+def build_authorization(username: str, challenge: DigestChallenge, password: str,
+ method: str, uri: str, nc: str = "00000001",
+ cnonce: str = "0a4f113b3c2e7a1d", qop: Optional[str] = "auth",
+ realm: Optional[str] = None, nonce_val: Optional[str] = None,
+ algorithm: Optional[str] = None, response: Optional[str] = None,
+ opaque: Optional[str] = None, include_opaque: bool = True,
+ include_qop_fields: bool = True, extra: Optional[List[str]] = None
+ ) -> str:
+ """Build a Digest Authorization header value.
+
+ By default this builds a *correct* response for the given challenge and
+ credentials. Any of realm=/nonce_val=/algorithm=/response=/opaque= can be
+ overridden to construct deliberately invalid headers, and qop=None with
+ include_qop_fields=False builds a legacy RFC 2069-style header (no qop,
+ cnonce, or nc) to prove that path is rejected.
+ """
+ eff_realm = challenge.realm if realm is None else realm
+ eff_nonce = challenge.nonce if nonce_val is None else nonce_val
+ if response is None:
+ h1 = ha1(username, eff_realm, password)
+ h2 = ha2(method, uri)
+ if qop:
+ response = request_digest(h1, eff_nonce, nc, cnonce, qop, h2)
+ else:
+ # legacy RFC 2069: MD5(HA1:nonce:HA2), no qop/cnonce/nc
+ response = _md5hex(f"{h1}:{eff_nonce}:{h2}")
+
+ parts = [
+ f'username="{username}"',
+ f'realm="{eff_realm}"',
+ f'nonce="{eff_nonce}"',
+ f'uri="{uri}"',
+ f'response="{response}"',
+ ]
+ if algorithm is not None:
+ parts.append(f'algorithm={algorithm}')
+ if qop and include_qop_fields:
+ parts.append(f'qop={qop}')
+ parts.append(f'nc={nc}')
+ parts.append(f'cnonce="{cnonce}"')
+ eff_opaque = challenge.opaque if (opaque is None and include_opaque) else opaque
+ if eff_opaque:
+ parts.append(f'opaque="{eff_opaque}"')
+ if extra:
+ parts.extend(extra)
+ return "Digest " + ", ".join(parts)
diff --git a/test/modules/aaa/env.py b/test/modules/aaa/env.py
new file mode 100644
index 00000000000..4fe47580f32
--- /dev/null
+++ b/test/modules/aaa/env.py
@@ -0,0 +1,79 @@
+import hashlib
+import inspect
+import logging
+import os
+from typing import List, Optional
+
+from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
+from pyhttpd.result import ExecResult
+
+log = logging.getLogger(__name__)
+
+
+class AAATestSetup(HttpdTestSetup):
+
+ def __init__(self, env: 'HttpdTestEnv'):
+ super().__init__(env=env)
+ self.add_source_dir(os.path.dirname(inspect.getfile(AAATestSetup)))
+ self.add_modules(["auth_digest", "authn_file", "authn_core",
+ "authz_core", "authz_user"])
+
+
+class AAATestEnv(HttpdTestEnv):
+
+ REALM = "AAA Digest Realm"
+ DIGEST_USER = "digestuser"
+ DIGEST_PASSWORD = "digestpass2617"
+ DIGEST_USER2 = "otheruser"
+ DIGEST_PASSWORD2 = "otherpass2617"
+
+ def __init__(self, pytestconfig=None):
+ super().__init__(pytestconfig=pytestconfig)
+ self.add_httpd_log_modules(["auth_digest", "authn_file", "authz_core"])
+ self._digest_pwfile = f"{self.server_dir}/digest.passwd"
+
+ def setup_httpd(self, setup: HttpdTestSetup = None):
+ super().setup_httpd(setup=AAATestSetup(env=self))
+ self._write_digest_pwfile()
+
+ def _write_digest_pwfile(self):
+ def ha1(user, password):
+ return hashlib.md5(
+ f"{user}:{self.REALM}:{password}".encode()).hexdigest()
+
+ with open(self._digest_pwfile, 'w') as fd:
+ fd.write(f"{self.DIGEST_USER}:{self.REALM}:"
+ f"{ha1(self.DIGEST_USER, self.DIGEST_PASSWORD)}\n")
+ fd.write(f"{self.DIGEST_USER2}:{self.REALM}:"
+ f"{ha1(self.DIGEST_USER2, self.DIGEST_PASSWORD2)}\n")
+
+ @property
+ def digest_pwfile(self) -> str:
+ return self._digest_pwfile
+
+ def configtest(self, directory_lines: List[str], extra_top_lines: Optional[List[str]] = None
+ ) -> ExecResult:
+ """Run `httpd -t` against a minimal, standalone config built from the
+ already-generated modules.conf plus `directory_lines` wrapped in a
+ block over the shared docroot. Used to test directives
+ that are rejected at config-check time (e.g. AuthDigestQop values
+ other than 'auth') without touching the package's running server.
+ """
+ conf_path = os.path.join(self.gen_dir, "digest-configtest.conf")
+ modules_conf = os.path.join(self.server_conf_dir, "modules.conf")
+ lines = [
+ f'ServerRoot "{self.server_dir}"',
+ f'Include "{modules_conf}"',
+ f'DocumentRoot "{self.server_docs_dir}"',
+ f'Listen {self.http_port2}',
+ ]
+ if extra_top_lines:
+ lines.extend(extra_top_lines)
+ lines.append(f'')
+ lines.extend(f" {l}" for l in directory_lines)
+ lines.append('')
+ with open(conf_path, 'w') as fd:
+ fd.write('\n'.join(lines))
+ fd.write('\n')
+ httpd_bin = os.path.join(self.bin_dir, 'httpd')
+ return self.run([httpd_bin, '-t', '-f', conf_path])
diff --git a/test/modules/aaa/htdocs/digest/default/secret.txt b/test/modules/aaa/htdocs/digest/default/secret.txt
new file mode 100644
index 00000000000..6135131adf6
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/default/secret.txt
@@ -0,0 +1 @@
+digest-default-secret
diff --git a/test/modules/aaa/htdocs/digest/domain/nested/secret.txt b/test/modules/aaa/htdocs/digest/domain/nested/secret.txt
new file mode 100644
index 00000000000..28140b2a187
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/domain/nested/secret.txt
@@ -0,0 +1 @@
+digest-domain-nested-secret
diff --git a/test/modules/aaa/htdocs/digest/domain/secret.txt b/test/modules/aaa/htdocs/digest/domain/secret.txt
new file mode 100644
index 00000000000..1103f6e9a0c
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/domain/secret.txt
@@ -0,0 +1 @@
+digest-domain-secret
diff --git a/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt b/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt
new file mode 100644
index 00000000000..fe15209e018
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt
@@ -0,0 +1 @@
+digest-nccheck-secret
diff --git a/test/modules/aaa/htdocs/digest/nccheck/secret.txt b/test/modules/aaa/htdocs/digest/nccheck/secret.txt
new file mode 100644
index 00000000000..fe15209e018
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/nccheck/secret.txt
@@ -0,0 +1 @@
+digest-nccheck-secret
diff --git a/test/modules/aaa/htdocs/digest/neverexpire/secret.txt b/test/modules/aaa/htdocs/digest/neverexpire/secret.txt
new file mode 100644
index 00000000000..5375ef5f8d2
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/neverexpire/secret.txt
@@ -0,0 +1 @@
+digest-neverexpire-secret
diff --git a/test/modules/aaa/htdocs/digest/noprovider/secret.txt b/test/modules/aaa/htdocs/digest/noprovider/secret.txt
new file mode 100644
index 00000000000..f9de590a307
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/noprovider/secret.txt
@@ -0,0 +1 @@
+digest-noprovider-secret
diff --git a/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt b/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt
new file mode 100644
index 00000000000..945bf8d92d3
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt
@@ -0,0 +1 @@
+digest-onetime-secret
diff --git a/test/modules/aaa/htdocs/digest/onetime/secret.txt b/test/modules/aaa/htdocs/digest/onetime/secret.txt
new file mode 100644
index 00000000000..945bf8d92d3
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/onetime/secret.txt
@@ -0,0 +1 @@
+digest-onetime-secret
diff --git a/test/modules/aaa/htdocs/digest/shortlife/secret.txt b/test/modules/aaa/htdocs/digest/shortlife/secret.txt
new file mode 100644
index 00000000000..fe422776b36
--- /dev/null
+++ b/test/modules/aaa/htdocs/digest/shortlife/secret.txt
@@ -0,0 +1 @@
+digest-shortlife-secret
diff --git a/test/modules/aaa/test_001_challenge_response.py b/test/modules/aaa/test_001_challenge_response.py
new file mode 100644
index 00000000000..aa6ff1217b2
--- /dev/null
+++ b/test/modules/aaa/test_001_challenge_response.py
@@ -0,0 +1,180 @@
+"""RFC 2617 Digest challenge/response scenarios against mod_auth_digest's
+default configuration (AuthDigestProvider file, AuthDigestQop auth (the only
+supported value), AuthDigestNonceLifetime 300, no AuthDigestDomain).
+"""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestChallengeResponse:
+
+ def url(self, env, path="secret.txt", location="default"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location="default"):
+ r = env.curl_get(self.url(env, location=location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def test_digest_001_no_credentials(self, env):
+ # No Authorization header at all -> 401 with a well-formed challenge.
+ r = env.curl_get(self.url(env))
+ assert r.response["status"] == 401
+ auth = r.response["header"]["www-authenticate"]
+ challenge = dc.DigestChallenge.parse(auth)
+ assert challenge.realm == AAATestEnv.REALM
+ assert challenge.algorithm == "MD5"
+ assert challenge.qop == "auth"
+ assert challenge.stale is False
+ # no AuthDigestDomain configured for this Location -> no domain=
+ assert challenge.domain is None
+ # nonce-count checking is off and lifetime isn't 0 here, so the
+ # server has no reason to track this client -> no opaque=
+ assert challenge.opaque is None
+
+ def test_digest_002_success(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-default-secret\n"
+
+ def test_digest_003_rspauth(self, env):
+ # Authentication-Info's rspauth= must match what we independently
+ # compute from the same HA1 -- proves the server round-trips the
+ # session parameters (nonce/nc/cnonce/qop) correctly.
+ challenge = self.challenge(env)
+ nc = "00000001"
+ cnonce = "test-cnonce-rspauth"
+ uri = "/digest/default/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
+ expected = dc.rspauth_digest(h1, challenge.nonce, nc, cnonce, "auth", uri)
+ assert ai["rspauth"] == expected
+ assert ai["qop"] == "auth"
+ assert ai["nc"] == nc
+ assert ai["cnonce"] == cnonce
+
+ def test_digest_004_wrong_password(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, "not-the-password",
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01794"])
+
+ def test_digest_005_unknown_user(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ "no-such-user", challenge, "whatever",
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01790"])
+
+ def test_digest_006_second_user(self, env):
+ # a distinct user in the same password file also works
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER2, challenge, AAATestEnv.DIGEST_PASSWORD2,
+ method="GET", uri="/digest/default/secret.txt")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+
+ def test_digest_007_wrong_realm(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ realm="Some Other Realm")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01788"])
+
+ def test_digest_008_bad_algorithm_token(self, env):
+ # a client claiming an algorithm other than MD5 is rejected outright,
+ # even though the response hash below is computed correctly for MD5.
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ algorithm="MD5-sess")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01789"])
+
+ def test_digest_009_legacy_no_qop_rejected(self, env):
+ # RFC 2069-style digest (no qop/cnonce/nc) is syntactically valid but
+ # explicitly no longer supported by this module.
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ qop=None, include_qop_fields=False)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH10560"])
+
+ def test_digest_010_malformed_header_missing_field(self, env):
+ # missing "uri" entirely -> header is syntactically INVALID, so the
+ # server issues a fresh (non-stale) challenge rather than evaluating
+ # the (nonexistent) response hash.
+ challenge = self.challenge(env)
+ h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
+ auth = ('Digest username="digestuser", '
+ f'realm="{challenge.realm}", nonce="{challenge.nonce}", '
+ f'response="{h1}", qop=auth, nc=00000001, cnonce="x"')
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is False
+ env.httpd_error_log.ignore_recent(lognos=["AH01782"])
+
+ def test_digest_011_wrong_scheme(self, env):
+ r = env.curl_get(self.url(env), options=[
+ "-H", "Authorization: Basic ZGlnZXN0dXNlcjpkaWdlc3RwYXNz"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01781"])
+
+ def test_digest_012_uri_mismatch(self, env):
+ # The Authorization uri= must match the actual request-target; a
+ # self-consistent response computed for a *different* uri than the
+ # one actually requested is rejected as a bad request, before the
+ # hash is even checked.
+ challenge = self.challenge(env)
+ other_uri = "/digest/default/other-secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=other_uri)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 400
+ env.httpd_error_log.ignore_recent(lognos=["AH01786"])
+
+ def test_digest_013_invalid_opaque(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ opaque="not-a-hex-number")
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01787"])
+
+ def test_digest_014_tampered_response_hash(self, env):
+ challenge = self.challenge(env)
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/default/secret.txt",
+ response="0" * 32)
+ r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01794"])
diff --git a/test/modules/aaa/test_002_nonce.py b/test/modules/aaa/test_002_nonce.py
new file mode 100644
index 00000000000..5d176f33bcd
--- /dev/null
+++ b/test/modules/aaa/test_002_nonce.py
@@ -0,0 +1,134 @@
+"""Nonce lifecycle scenarios: tampered nonces, AuthDigestNonceLifetime
+expiry/reissue, a never-expiring nonce, and the one-time-nonce
+(AuthDigestNonceLifetime 0) case.
+"""
+
+import time
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestNonce:
+
+ def url(self, env, location, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def authenticate(self, env, location, challenge, nc="00000001",
+ cnonce="nonce-test-cnonce", uri=None):
+ uri = uri or f"/digest/{location}/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce)
+ return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_020_tampered_nonce_is_stale(self, env):
+ challenge = self.challenge(env, "default")
+ # flip a character in the middle of the opaque nonce blob: it stays
+ # the right length but its embedded hash no longer verifies.
+ bad = list(challenge.nonce)
+ mid = len(bad) // 2
+ bad[mid] = 'x' if bad[mid] != 'x' else 'y'
+ challenge.nonce = ''.join(bad)
+ r = self.authenticate(env, "default", challenge)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_021_garbage_nonce_hash_is_stale(self, env):
+ # A nonce must still look like "b64(time)+hex(hash)" (VALID_NONCE in
+ # mod_auth_digest.c checks length and the '=' padding boundary) to
+ # even be considered for a hash check; something that doesn't match
+ # that shape is instead rejected as a malformed header (see
+ # test_digest_010). Here we keep the genuine time-prefix (so the
+ # shape is valid) but replace the whole hash suffix with garbage, to
+ # hit check_nonce()'s "hash is not %s" path distinctly from
+ # test_digest_020's single-flipped-character tamper.
+ #
+ # The hash length depends on which keyed hash the module was built
+ # with, so take it from the nonce rather than assuming: the time is
+ # base64 of 8 bytes and so ends with the only '=' in the string.
+ challenge = self.challenge(env, "default")
+ time_prefix = challenge.nonce[:challenge.nonce.index('=') + 1]
+ hash_len = len(challenge.nonce) - len(time_prefix)
+ challenge.nonce = time_prefix + ("f" * hash_len)
+ r = self.authenticate(env, "default", challenge)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_022_short_lifetime_expires(self, env):
+ # AuthDigestNonceLifetime 2 for this location.
+ challenge = self.challenge(env, "shortlife")
+ r = self.authenticate(env, "shortlife", challenge)
+ assert r.response["status"] == 200
+
+ time.sleep(3)
+ # same nonce, now past its lifetime -> 401 stale=true
+ r = self.authenticate(env, "shortlife", challenge)
+ assert r.response["status"] == 401
+ stale_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert stale_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ # the fresh nonce from the stale challenge works again
+ r = self.authenticate(env, "shortlife", stale_challenge)
+ assert r.response["status"] == 200
+
+ def test_digest_023_never_expiring_nonce(self, env):
+ # AuthDigestNonceLifetime -1 for this location: no NcCheck is
+ # configured, so the identical Authorization line can simply be
+ # replayed after a delay and must still succeed both times.
+ challenge = self.challenge(env, "neverexpire")
+ r1 = self.authenticate(env, "neverexpire", challenge)
+ assert r1.response["status"] == 200
+
+ time.sleep(3)
+ r2 = self.authenticate(env, "neverexpire", challenge)
+ assert r2.response["status"] == 200
+
+ def test_digest_024_one_time_nonce_rejects_reuse(self, env):
+ # AuthDigestNonceLifetime 0: a successful request immediately
+ # supersedes its nonce (the tracked "last_nonce" moves on to the
+ # nextnonce from Authentication-Info), so replaying the very same
+ # nonce right afterwards must fail as stale. Each request against
+ # this client (success OR failure) advances the tracked nonce again,
+ # so this test does exactly one success followed by exactly one
+ # reuse -- no longer chain that would need to account for that.
+ challenge = self.challenge(env, "onetime")
+ assert challenge.opaque is not None, \
+ "one-time-nonce tracking requires an opaque to identify the client"
+
+ r1 = self.authenticate(env, "onetime", challenge)
+ assert r1.response["status"] == 200
+ ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
+ assert "nextnonce" in ai1
+ assert ai1["nextnonce"] != challenge.nonce
+
+ # reusing the exact same (now superseded) nonce fails as stale
+ r2 = self.authenticate(env, "onetime", challenge)
+ assert r2.response["status"] == 401
+ stale_challenge = dc.DigestChallenge.parse(r2.response["header"]["www-authenticate"])
+ assert stale_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_025_one_time_nonce_chain_continues(self, env):
+ # Following the nextnonce handed out on a successful response lets
+ # the client keep authenticating, one hop at a time.
+ challenge = self.challenge(env, "onetime")
+ r1 = self.authenticate(env, "onetime", challenge)
+ assert r1.response["status"] == 200
+ ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
+
+ challenge.nonce = ai1["nextnonce"]
+ r2 = self.authenticate(env, "onetime", challenge)
+ assert r2.response["status"] == 200
+ ai2 = dc.parse_params(r2.response["header"]["authentication-info"])
+ assert ai2["nextnonce"] != ai1["nextnonce"]
diff --git a/test/modules/aaa/test_003_nccheck.py b/test/modules/aaa/test_003_nccheck.py
new file mode 100644
index 00000000000..539c182b66c
--- /dev/null
+++ b/test/modules/aaa/test_003_nccheck.py
@@ -0,0 +1,143 @@
+"""AuthDigestNcCheck replay-detection scenarios.
+
+The semantics are those of RFC 7616 3.4.3: the nonce-count is counted by
+the client per-nonce, so the server tracks a count per (client, nonce) pair
+and requires it to strictly increase. Within one nonce, an nc which has
+already been seen is a replay and is rejected; a *higher* nc than expected
+is not, since the client also counts the requests it sends to URIs in the
+protection space which turn out not to need authentication, and the server
+never sees those. Moving to a newer nonce starts a fresh count, and a nonce
+the client has already moved on from is rejected.
+
+The tracked count is only ever updated for a fully verified request, so a
+failed request cannot disturb the count of the client whose opaque it
+quotes; test_007_replay.py covers that property directly.
+"""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestNcCheck:
+
+ def url(self, env, location, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def authenticate(self, env, location, challenge, nc, cnonce="ncc-test-cnonce",
+ include_opaque=True):
+ uri = f"/digest/{location}/secret.txt"
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=uri, nc=nc, cnonce=cnonce,
+ include_opaque=include_opaque)
+ return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_030_nccheck_requires_opaque(self, env):
+ # with AuthDigestNcCheck on, the server cannot verify nc without
+ # having tracked this client via its opaque -- omitting the opaque
+ # therefore fails, even with nc=00000001. It is rejected before the
+ # nc check is even reached: the nonce hash is computed over the
+ # opaque (gen_nonce_hash()), so a nonce quoted without the opaque it
+ # was issued with does not verify, and that is reported as stale.
+ challenge = self.challenge(env, "nccheck")
+ assert challenge.opaque is not None
+ r = self.authenticate(env, "nccheck", challenge, nc="00000001", include_opaque=False)
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert new_challenge.stale is True
+ env.httpd_error_log.ignore_recent(lognos=["AH01776"])
+
+ def test_digest_031_nccheck_sequential_ok(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
+ assert r2.response["status"] == 200
+ r3 = self.authenticate(env, "nccheck", challenge, nc="00000003")
+ assert r3.response["status"] == 200
+
+ def test_digest_032_nccheck_replay_rejected(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
+ assert r2.response["status"] == 200
+
+ # replay an already-used nc -> rejected, and NOT reported as stale
+ # (this is a distinct failure mode from an invalid/expired nonce).
+ r3 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r3.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(r3.response["header"]["www-authenticate"])
+ assert new_challenge.stale is False
+ env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+ # recovery: the rejected attempt handed out a fresh challenge for
+ # this client, and following it -- new nonce, so the count starts
+ # over at 00000001 -- authenticates again.
+ r4 = self.authenticate(env, "nccheck", new_challenge, nc="00000001")
+ assert r4.response["status"] == 200
+
+ # the superseded nonce is not usable any more, at any nc.
+ r5 = self.authenticate(env, "nccheck", challenge, nc="00000003")
+ assert r5.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+ def test_digest_033_nccheck_skip_ahead_allowed(self, env):
+ challenge = self.challenge(env, "nccheck")
+ r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+
+ # skipping ahead is allowed: nc only has to be higher than the
+ # highest already seen for this nonce, not exactly one more. A
+ # client legitimately produces gaps by sending counted requests to
+ # URIs in the protection space which don't need authentication, and
+ # a higher nc is not a replay in any case.
+ r2 = self.authenticate(env, "nccheck", challenge, nc="00000009")
+ assert r2.response["status"] == 200
+
+ # ...and the skipped-over counts are spent: they are no longer
+ # accepted afterwards.
+ r3 = self.authenticate(env, "nccheck", challenge, nc="00000005")
+ assert r3.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01774"])
+
+ def test_digest_034_no_nccheck_allows_replay(self, env):
+ # the "default" location has no AuthDigestNcCheck (Off by default),
+ # so replaying the exact same nc is not detected or rejected.
+ challenge = self.challenge(env, "default")
+ r1 = self.authenticate(env, "default", challenge, nc="00000001")
+ assert r1.response["status"] == 200
+ r2 = self.authenticate(env, "default", challenge, nc="00000001")
+ assert r2.response["status"] == 200
+
+ def test_digest_035_out_of_range_opaque_is_not_truncated(self, env):
+ # The opaque is a 32-bit client id. A value which would truncate onto
+ # a live id must not select that client. This is observable in the
+ # challenge which comes back: a client the server still knows is
+ # re-challenged with its own opaque, whereas an unknown one is given a
+ # freshly minted opaque and stale=true.
+ challenge = self.challenge(env, "nccheck")
+ assert self.authenticate(env, "nccheck", challenge,
+ nc="00000001").response["status"] == 200
+
+ # 2^32 + the live id, which truncates to the live id in 32 bits
+ crafted = "%x" % ((1 << 32) + int(challenge.opaque, 16))
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/nccheck/secret.txt", nc="00000002",
+ cnonce="trunc-cnonce", opaque=crafted)
+ r = env.curl_get(env.mkurl("http", "aaa", "/digest/nccheck/secret.txt"),
+ options=["-H", f"Authorization: {auth}"])
+ # AH01787 with the range check in place; AH01776 (nonce hash) if the
+ # opaque were truncated onto the live client instead
+ env.httpd_error_log.ignore_recent(lognos=["AH01787", "AH01776"])
+ assert r.response["status"] == 401
+ new_challenge = dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"])
+ assert new_challenge.opaque != crafted, \
+ "an out-of-range opaque was truncated onto a live client id"
diff --git a/test/modules/aaa/test_004_domain.py b/test/modules/aaa/test_004_domain.py
new file mode 100644
index 00000000000..829d923552f
--- /dev/null
+++ b/test/modules/aaa/test_004_domain.py
@@ -0,0 +1,56 @@
+"""AuthDigestDomain: presence, format, and inheritance of the domain=
+attribute in the WWW-Authenticate challenge.
+"""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestDomain:
+
+ def url(self, env, path):
+ return env.mkurl("http", "aaa", path)
+
+ def test_digest_040_domain_attribute_present(self, env):
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ # set_uri_list() (mod_auth_digest.c) builds a single quoted,
+ # space-separated list from the configured AuthDigestDomain URIs.
+ assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
+ assert challenge.domain_list() == [
+ "/digest/domain/", "https://mirror.example.org/other/"]
+
+ def test_digest_041_no_domain_configured_omits_attribute(self, env):
+ r = env.curl_get(self.url(env, "/digest/default/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert challenge.domain is None
+
+ def test_digest_042_domain_location_still_authenticates(self, env):
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/domain/secret.txt")
+ r = env.curl_get(self.url(env, "/digest/domain/secret.txt"),
+ options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-domain-secret\n"
+
+ def test_digest_043_domain_inherited_by_nested_path(self, env):
+ # AuthDigestDomain is set on /digest/domain/; a path nested below it
+ # inherits the same directory config (same realm/credentials/domain).
+ r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert challenge.realm == AAATestEnv.REALM
+ assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
+
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri="/digest/domain/nested/secret.txt")
+ r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"),
+ options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-domain-nested-secret\n"
diff --git a/test/modules/aaa/test_005_provider.py b/test/modules/aaa/test_005_provider.py
new file mode 100644
index 00000000000..d7d3fbb85ad
--- /dev/null
+++ b/test/modules/aaa/test_005_provider.py
@@ -0,0 +1,37 @@
+"""AuthDigestProvider scenarios."""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+
+class TestDigestProvider:
+
+ def url(self, env, path):
+ return env.mkurl("http", "aaa", path)
+
+ def test_digest_050_omitted_provider_defaults_to_file(self, env):
+ # /digest/noprovider/ has no AuthDigestProvider directive at all;
+ # mod_auth_digest falls back to the "file" provider (mod_authn_file)
+ # by default (see get_hash() / AUTHN_DEFAULT_PROVIDER in mod_auth.h).
+ path = "/digest/noprovider/secret.txt"
+ r = env.curl_get(self.url(env, path))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=path)
+ r = env.curl_get(self.url(env, path), options=["-H", f"Authorization: {auth}"])
+ assert r.response["status"] == 200
+ assert r.response["body"].decode() == "digest-noprovider-secret\n"
+
+ def test_digest_051_unknown_provider_rejected_at_config_time(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider no-such-provider',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "Unknown Authn provider" in r.stderr
diff --git a/test/modules/aaa/test_006_config_errors.py b/test/modules/aaa/test_006_config_errors.py
new file mode 100644
index 00000000000..e1284abfdf0
--- /dev/null
+++ b/test/modules/aaa/test_006_config_errors.py
@@ -0,0 +1,86 @@
+"""Config-time validation for directives whose *documented* syntax (see
+docs/manual/mod/mod_auth_digest.xml) is broader than what this build's
+mod_auth_digest.c actually implements: AuthDigestQop only accepts "auth"
+(qop=none/auth-int are rejected -- the "Open Issues" comment in the source
+notes MD5-sess and auth-int were removed as incomplete), AuthDigestAlgorithm
+only accepts "MD5", and AuthDigestShmemSize enforces a minimum size. These
+are all checked with `httpd -t` against a throwaway config so the shared
+package server is never disturbed.
+"""
+
+from .env import AAATestEnv
+
+
+class TestDigestConfigErrors:
+
+ def test_digest_060_qop_none_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop none',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "AuthDigestQop" in r.stderr
+
+ def test_digest_061_qop_auth_int_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop auth-int',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "AuthDigestQop" in r.stderr
+
+ def test_digest_062_qop_auth_accepted(self, env):
+ # the only value actually supported must still work.
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestQop auth',
+ 'Require valid-user',
+ ])
+ assert r.exit_code == 0
+
+ def test_digest_063_algorithm_md5_sess_rejected(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestAlgorithm MD5-sess',
+ 'Require valid-user',
+ ])
+ assert r.exit_code != 0
+ assert "Unsupported algorithm" in r.stderr
+
+ def test_digest_064_algorithm_md5_accepted(self, env):
+ r = env.configtest([
+ 'AuthType Digest',
+ f'AuthName "{AAATestEnv.REALM}"',
+ 'AuthDigestProvider file',
+ f'AuthUserFile "{env.digest_pwfile}"',
+ 'AuthDigestAlgorithm MD5',
+ 'Require valid-user',
+ ])
+ assert r.exit_code == 0
+
+ def test_digest_065_shmemsize_too_small_rejected(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 10"])
+ assert r.exit_code != 0
+ assert "AuthDigestShmemSize" in r.stderr
+
+ def test_digest_066_shmemsize_valid_accepted(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 1000"])
+ assert r.exit_code == 0
+
+ def test_digest_067_shmemsize_units_accepted(self, env):
+ r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 64K"])
+ assert r.exit_code == 0
diff --git a/test/modules/aaa/test_007_replay.py b/test/modules/aaa/test_007_replay.py
new file mode 100644
index 00000000000..ba61ed9eaaa
--- /dev/null
+++ b/test/modules/aaa/test_007_replay.py
@@ -0,0 +1,237 @@
+"""Replay-attack scenarios against AuthDigestNcCheck.
+
+AuthDigestNcCheck exists to detect replayed requests: the server tracks the
+highest nonce-count it has accepted from a client (identified by its opaque)
+for the nonce that client is using, and requires each request to raise it.
+
+The security property under test here is not just "the replayed request is
+rejected", but that rejecting it must not damage the legitimate client:
+
+ With nonce-count checking enabled, a replay attack MUST NOT affect the
+ original (legitimate) client by resetting its nonce count.
+
+It used to. On a failed authentication mod_auth_digest issues a fresh
+challenge via note_digest_auth_failure(), and for an already-known
+(opaque-identified) client that path reset client->nonce_count to 0, while
+the post_read_request hook re-incremented the count from 0 on the next
+request carrying that opaque. An attacker who could make *any* request fail
+for the victim's opaque therefore rewound the victim's counter, with two
+consequences:
+
+ * the legitimate client's next in-sequence nc no longer matched, so it
+ was locked out (denial of service against the victim), and
+ * the attacker's replayed request lined up with the rewound counter and
+ was accepted -- 200, 401, 200, 401, ... for one captured header, or
+ every time if the attacker rewound the counter deliberately first.
+
+The count is now tracked per (client, nonce) and updated only for a request
+which has been fully verified, so a request which fails to authenticate
+leaves the victim's state untouched.
+"""
+
+import time
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+# See the note in test_003_nccheck.py: a failed nc check is not reported as
+# stale, since it is a distinct failure mode from an invalid/expired nonce.
+NC_FAILED = "AH01774"
+NONCE_HASH_INVALID = "AH01776"
+PASSWORD_MISMATCH = "AH01794"
+
+
+class TestDigestReplay:
+
+ LOCATION = "nccheck"
+
+ def url(self, env, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{self.LOCATION}/{path}")
+
+ @property
+ def uri(self):
+ return f"/digest/{self.LOCATION}/secret.txt"
+
+ def challenge(self, env):
+ r = env.curl_get(self.url(env))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def victim_header(self, challenge, nc, cnonce="victim-cnonce"):
+ """A correct Authorization header from the legitimate client."""
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri, nc=nc, cnonce=cnonce)
+
+ def attacker_header(self, challenge, nc="00000001", cnonce="attacker-cnonce"):
+ """A well-formed Digest header carrying the victim's opaque and nonce
+ but a bogus response digest. An attacker who has merely *seen* one of
+ the victim's requests can build this; no credentials are needed."""
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, "not-the-password",
+ method="GET", uri=self.uri, nc=nc, cnonce=cnonce,
+ response="0" * 32)
+
+ def send(self, env, auth):
+ return env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_070_replay_does_not_lock_out_legit_client(self, env):
+ # The legitimate client authenticates a few times, in sequence.
+ challenge = self.challenge(env)
+ for nc in ["00000001", "00000002", "00000003"]:
+ assert self.send(env, self.victim_header(challenge, nc)).response["status"] == 200
+
+ # An attacker replays a request captured earlier in that sequence.
+ # Rejecting it is correct...
+ replayed = self.victim_header(challenge, "00000002")
+ replay_status = self.send(env, replayed).response["status"]
+
+ # ...but it must not disturb the legitimate client, which knows
+ # nothing of the replay and simply carries on with its next nc.
+ r = self.send(env, self.victim_header(challenge, "00000004"))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert replay_status == 401
+ assert r.response["status"] == 200, \
+ "the replay reset the victim's nonce-count and locked it out"
+
+ def test_digest_071_bogus_request_does_not_lock_out_legit_client(self, env):
+ # Same property, but the attacker does not even need to have captured
+ # a complete valid request: any well-formed Digest header quoting the
+ # victim's opaque is enough to rewind the victim's counter.
+ challenge = self.challenge(env)
+ for nc in ["00000001", "00000002"]:
+ assert self.send(env, self.victim_header(challenge, nc)).response["status"] == 200
+
+ bogus_status = self.send(env, self.attacker_header(challenge)).response["status"]
+
+ r = self.send(env, self.victim_header(challenge, "00000003"))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, PASSWORD_MISMATCH])
+ assert bogus_status == 401
+ assert r.response["status"] == 200, \
+ "a bogus request reset the victim's nonce-count and locked it out"
+
+ def test_digest_072_captured_request_is_never_accepted_twice(self, env):
+ # The flip side of the same defect. One captured Authorization header
+ # is replayed verbatim; the first send is the genuine request, so it
+ # succeeds, and every later send must be rejected. Before the fix the
+ # rejection rewound the counter, so the replay after it lined up
+ # again: the observed pattern was 200, 401, 200, 401, ...
+ challenge = self.challenge(env)
+ captured = self.victim_header(challenge, "00000001", cnonce="captured-cnonce")
+
+ assert self.send(env, captured).response["status"] == 200
+ statuses = [self.send(env, captured).response["status"] for _ in range(4)]
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert statuses == [401, 401, 401, 401], \
+ f"replayed request was accepted again: {statuses}"
+
+ def test_digest_073_attacker_cannot_force_replay_to_succeed(self, env):
+ # Severity check: the attacker must not be able to line the counter
+ # up on demand. Before the fix, sending a bogus request first rewound
+ # the counter to 0, so the replay that followed succeeded every
+ # single time.
+ challenge = self.challenge(env)
+ captured = self.victim_header(challenge, "00000001", cnonce="captured-cnonce")
+ assert self.send(env, captured).response["status"] == 200
+
+ statuses = []
+ for _ in range(3):
+ self.send(env, self.attacker_header(challenge))
+ statuses.append(self.send(env, captured).response["status"])
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, PASSWORD_MISMATCH])
+ assert statuses == [401, 401, 401], \
+ f"attacker replayed at will by forcing a counter reset: {statuses}"
+
+ def test_digest_074_legit_client_recovers_via_fresh_challenge(self, env):
+ # Invariant: a client whose nc is rejected is handed a fresh
+ # challenge, and following that challenge -- new nonce, so the count
+ # starts over at 1 -- gets it working again. Simply never resetting
+ # the count, without tying it to the nonce it was counted for, would
+ # break this.
+ challenge = self.challenge(env)
+ assert self.send(env, self.victim_header(challenge, "00000001")).response["status"] == 200
+
+ # provoke the rejection with a replay of that first request
+ r = self.send(env, self.victim_header(challenge, "00000001"))
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert fresh.stale is False
+ assert fresh.opaque == challenge.opaque, \
+ "the client keeps its identity across a re-challenge"
+ assert fresh.nonce != challenge.nonce
+
+ r = self.send(env, self.victim_header(fresh, "00000001"))
+ assert r.response["status"] == 200
+
+ def test_digest_075_nonce_is_bound_to_opaque(self, env):
+ # A captured header cannot be re-pointed at a *different* client
+ # session to dodge that session's nonce-count: the nonce hash is
+ # computed over the opaque (gen_nonce_hash()), so quoting one
+ # client's nonce under another client's opaque fails the hash check
+ # outright, and is reported as stale.
+ victim = self.challenge(env)
+ captured = self.victim_header(victim, "00000001", cnonce="captured-cnonce")
+ assert self.send(env, captured).response["status"] == 200
+
+ attacker = self.challenge(env)
+ assert attacker.opaque != victim.opaque
+ spliced = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, victim, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri, nc="00000001", cnonce="captured-cnonce",
+ opaque=attacker.opaque)
+ r = self.send(env, spliced)
+ env.httpd_error_log.ignore_recent(lognos=[NONCE_HASH_INVALID])
+ assert r.response["status"] == 401
+ assert dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"]).stale is True
+
+
+class TestDigestNcCheckExpiry:
+ """AuthDigestNcCheck combined with an expiring nonce.
+
+ The nonce is checked before the nonce-count, so that an expired nonce
+ still produces a "stale=true" challenge rather than being reported as a
+ replay -- the client then retries silently against the fresh nonce, with
+ its count restarted at 1.
+ """
+
+ LOCATION = "nccheck-shortlife" # AuthDigestNcCheck On, lifetime 2s
+
+ def url(self, env):
+ return env.mkurl("http", "aaa", f"/digest/{self.LOCATION}/secret.txt")
+
+ def challenge(self, env):
+ r = env.curl_get(self.url(env))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def send(self, env, challenge, nc):
+ auth = dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=f"/digest/{self.LOCATION}/secret.txt", nc=nc,
+ cnonce="expiry-cnonce")
+ return env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
+
+ def test_digest_076_expired_nonce_restarts_the_count(self, env):
+ challenge = self.challenge(env)
+ assert self.send(env, challenge, "00000001").response["status"] == 200
+ assert self.send(env, challenge, "00000002").response["status"] == 200
+
+ time.sleep(3)
+
+ # past its lifetime: reported as stale, not as a nonce-count failure
+ r = self.send(env, challenge, "00000003")
+ assert r.response["status"] == 401
+ fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert fresh.stale is True
+
+ # the client restarts its count for the fresh nonce, which must not
+ # collide with the count already tracked for the expired one
+ assert self.send(env, fresh, "00000001").response["status"] == 200
+ assert self.send(env, fresh, "00000002").response["status"] == 200
+
+ # and the expired nonce stays unusable
+ r = self.send(env, challenge, "00000004")
+ assert r.response["status"] == 401
+ env.httpd_error_log.ignore_recent(lognos=["AH01776", NC_FAILED])
diff --git a/test/modules/aaa/test_008_onetime_nccheck.py b/test/modules/aaa/test_008_onetime_nccheck.py
new file mode 100644
index 00000000000..cab4f81cf23
--- /dev/null
+++ b/test/modules/aaa/test_008_onetime_nccheck.py
@@ -0,0 +1,194 @@
+"""One-time nonces (AuthDigestNonceLifetime 0), alone and with AuthDigestNcCheck.
+
+With a lifetime of 0 the server hands the client a nextnonce on every
+successful response, and a nonce may be used once: it is accepted only if
+it is newer than the last nonce that client used. The client counts from 1
+again for each new nonce, so with AuthDigestNcCheck also on, every request
+legitimately carries nc=00000001.
+
+The security property here is the one from test_007_replay.py, applied to
+the other piece of per-client state:
+
+ A request which fails to authenticate MUST NOT invalidate the nonce
+ which the legitimate client is holding.
+
+It did, when the client's state was the last nonce *issued* to it:
+note_digest_auth_failure() generates a fresh nonce and recorded it there,
+and any request quoting the client's opaque can provoke a challenge. So an
+eavesdropper who had captured one Authorization header could replay it at
+will -- the replay itself was correctly rejected, but it moved the stored
+nonce on, and the victim's next request was then refused. The opaque is in
+the clear in every challenge and every request, and such a captured header
+never goes stale for this purpose, since it works by failing.
+
+This needed no credentials and, despite where it was first noticed, no
+AuthDigestNcCheck: the tests below run against both locations to pin that
+the defect was in the one-time-nonce path, not in the combination.
+
+The state is now the last nonce the client actually *used*, which nothing
+unauthenticated can move.
+"""
+
+import pytest
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+BOTH = ["onetime", "onetime-nccheck"]
+
+NC_FAILED = "AH01774"
+NONCE_HASH_INVALID = "AH01776"
+PASSWORD_MISMATCH = "AH01794"
+CLIENT_UNKNOWN = "AH10618"
+
+
+class TestOneTimeNonce:
+
+ def url(self, env, location):
+ return env.mkurl("http", "aaa", f"/digest/{location}/secret.txt")
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ challenge = dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"])
+ assert challenge.opaque is not None, \
+ "one-time nonces are tracked per client, so an opaque is required"
+ return challenge
+
+ def header(self, location, challenge, nc="00000001", cnonce="onetime-cnonce",
+ response=None):
+ """A correct Authorization header, unless response= overrides the
+ digest -- an attacker can build that from an observed request
+ without knowing the password."""
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=f"/digest/{location}/secret.txt", nc=nc,
+ cnonce=cnonce, response=response)
+
+ def send(self, env, location, auth):
+ return env.curl_get(self.url(env, location),
+ options=["-H", f"Authorization: {auth}"])
+
+ def follow_nextnonce(self, r, challenge):
+ """Advance the client to the nextnonce it was just handed."""
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ assert "nextnonce" in ai
+ assert ai["nextnonce"] != challenge.nonce
+ challenge.nonce = ai["nextnonce"]
+
+ def test_digest_080_nccheck_does_not_break_the_onetime_chain(self, env):
+ # Each nonce is new, so the client's count restarts at 1 every time
+ # and the nonce-count check must not object. (Before the nonce-count
+ # was tracked per-nonce this alternated 200, 401, 200, 401, ...)
+ challenge = self.challenge(env, "onetime-nccheck")
+ for _ in range(4):
+ r = self.send(env, "onetime-nccheck", self.header(
+ "onetime-nccheck", challenge, nc="00000001"))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_081_onetime_nonce_rejects_immediate_replay(self, env, location):
+ challenge = self.challenge(env, location)
+ captured = self.header(location, challenge)
+ assert self.send(env, location, captured).response["status"] == 200
+
+ r = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert r.response["status"] == 401
+ assert dc.DigestChallenge.parse(
+ r.response["header"]["www-authenticate"]).stale is True
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_082_onetime_nonce_rejects_replay_after_rotation(self, env, location):
+ # The captured header stays rejected once the client has moved on
+ # through the nextnonce chain.
+ challenge = self.challenge(env, location)
+ captured = self.header(location, challenge)
+ r = self.send(env, location, captured)
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ r = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert r.response["status"] == 401
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_083_replay_does_not_invalidate_the_clients_nonce(self, env, location):
+ # The eavesdropper's version: no credentials, no forgery, just one
+ # captured Authorization header replayed after the client has moved
+ # on. Rejecting it is correct; denying the client's next request is
+ # not.
+ challenge = self.challenge(env, location)
+ captured = self.header(location, challenge)
+ r = self.send(env, location, captured)
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ replay_status = self.send(env, location, captured).response["status"]
+
+ r = self.send(env, location, self.header(location, challenge))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert replay_status == 401
+ assert r.response["status"] == 200, \
+ "the replay moved the client's one-time nonce on and locked it out"
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_084_bogus_request_does_not_invalidate_the_clients_nonce(
+ self, env, location):
+ # Same property with a forged digest rather than a captured one, so
+ # it holds however the attacker's request comes to fail.
+ challenge = self.challenge(env, location)
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+
+ bogus = self.header(location, challenge, cnonce="bogus",
+ response="0" * 32)
+ bogus_status = self.send(env, location, bogus).response["status"]
+
+ r = self.send(env, location, self.header(location, challenge))
+ env.httpd_error_log.ignore_recent(
+ lognos=[NC_FAILED, NONCE_HASH_INVALID, PASSWORD_MISMATCH])
+ assert bogus_status == 401
+ assert r.response["status"] == 200, \
+ "the bogus request moved the client's one-time nonce on and locked it out"
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_085_replay_rejected_when_the_client_entry_is_gone(self, env,
+ location):
+ # The client table is small here -- conftest pins AuthDigestShmemSize
+ # to 1000 bytes, "~ 12 entries" -- so filling it makes gc() discard
+ # entries. The pressure has to come from clients which have
+ # authenticated: gc() discards the entries of clients which never
+ # did first, so bare requests no longer evict anybody who matters.
+ #
+ # A captured request must still not be replayable once the client's
+ # entry has gone. It used to be: check_nonce() skipped the one-time
+ # comparison entirely when the client was unknown, so the nonce was
+ # taken on trust and the replay served the protected resource.
+ challenge = self.challenge(env, location)
+ captured = self.header(location, challenge)
+ assert self.send(env, location, captured).response["status"] == 200
+ assert self.send(env, location, captured).response["status"] == 401
+
+ # Note that the victim is left alone while the table fills: looking
+ # its entry up would move it to the front of its bucket, which is
+ # exactly what saves an entry from gc().
+ for _ in range(30):
+ other = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, other)).response["status"] == 200
+
+ r = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
+ assert r.response["status"] == 401, \
+ "captured request replayed once the client entry was evicted"
+ gone = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert gone.opaque != challenge.opaque, \
+ "the entry was not evicted, so the gc'd-client path is untested"
diff --git a/test/modules/aaa/test_009_restart.py b/test/modules/aaa/test_009_restart.py
new file mode 100644
index 00000000000..d09a3c23b57
--- /dev/null
+++ b/test/modules/aaa/test_009_restart.py
@@ -0,0 +1,200 @@
+"""Per-client state across a server restart.
+
+The client table lives in a shared memory segment created by post_config,
+and that segment does not survive a restart: the restart loop in
+server/main.c clears pconf, which destroys the segment, and post_config
+then builds a new one, empty.
+
+The nonce does survive, because the secret it is hashed with is kept in
+retained data across restarts. So a client returning after a restart
+presents a nonce which still verifies, naming per-client state which no
+longer exists.
+
+The right answer for such a client is a stale=true challenge: its
+credentials were never in doubt, only the server-side state backing its
+nonce is gone, so it should retry silently against the fresh nonce rather
+than being told its authentication failed. mod_auth_digest does exactly
+that when it finds the client unknown (AH10618).
+
+It stopped doing it once the id had been handed to somebody else, which the
+ids restarting from 1 made routine rather than exotic: the first clients
+seen after a restart took exactly the ids the clients from before it were
+still quoting. A returning client was then checked against a *different*
+client's entry and reported as a nonce-count failure -- stale=false, which
+a browser shows as a failed password.
+
+With one-time nonces it was a replay hole rather than a wrong answer to the
+user. Single use is enforced by remembering the last nonce each client
+used, and that memory dies with the segment while the nonce does not; the
+one-time counter restarts at 0, so a nonce from before the restart outranks
+anything the new holder of its id has reached, and was accepted. An
+eavesdropper who had captured one used request only had to wait for a
+restart.
+
+The ids are now seeded randomly for each segment, so a returning client's
+opaque no longer names anybody, and it takes the unknown-client path above.
+"""
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+NC_FAILED = "AH01774"
+CLIENT_UNKNOWN = "AH10618"
+ONETIME_REUSED = "AH01779"
+
+
+class TestDigestRestart:
+
+ def url(self, env, location, path="secret.txt"):
+ return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
+
+ def uri(self, location):
+ return f"/digest/{location}/secret.txt"
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def header(self, location, challenge, nc="00000001", cnonce="restart-cnonce"):
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri(location), nc=nc, cnonce=cnonce)
+
+ def send(self, env, location, auth):
+ return env.curl_get(self.url(env, location),
+ options=["-H", f"Authorization: {auth}"])
+
+ def reload(self, env):
+ assert env.apache_reload() == 0, "graceful restart failed"
+
+ def challenge_of(self, r):
+ """The challenge carried by a 401 response."""
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def follow_nextnonce(self, r, challenge):
+ ai = dc.parse_params(r.response["header"]["authentication-info"])
+ assert "nextnonce" in ai
+ challenge.nonce = ai["nextnonce"]
+
+ def test_digest_090_returning_client_is_challenged_as_stale(self, env):
+ # A client which authenticated before a restart comes back afterwards
+ # with the nonce it was holding. The state naming its opaque is gone,
+ # so the request cannot be accepted -- but the client did nothing
+ # wrong, and must be told to retry rather than that it failed.
+ location = "nccheck"
+ self.reload(env)
+ challenge = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, challenge)).response["status"] == 200
+
+ self.reload(env)
+
+ r = self.send(env, location, self.header(location, challenge, "00000002"))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
+ assert r.response["status"] == 401
+ fresh = self.challenge_of(r)
+ assert fresh.stale, \
+ "a client returning after a restart was told its authentication " \
+ "failed, rather than being asked to retry with a fresh nonce"
+
+ # ...and the retry against that fresh challenge succeeds.
+ assert self.send(env, location,
+ self.header(location, fresh)).response["status"] == 200
+
+ def test_digest_091_returning_client_is_stale_even_if_its_id_was_reused(self, env):
+ # Same property, but now the id space has caught up: after the restart
+ # a new client is handed the id the returning client still quotes.
+ # That is not an unlikely coincidence -- the counter restarts from 1,
+ # so the first clients seen after a restart take exactly the ids the
+ # clients from before it are holding.
+ location = "nccheck"
+ self.reload(env)
+ challenge = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, challenge)).response["status"] == 200
+
+ self.reload(env)
+
+ # A different client arrives first and is given the recycled id.
+ # Today that newcomer is handed the returning client's id, because
+ # the counter restarts at 1. The assertions below deliberately do not
+ # require it: a server which stops recycling ids satisfies this
+ # property by making the returning client simply unknown, which is
+ # the outcome under test either way.
+ newcomer = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, newcomer)).response["status"] == 200
+
+ r = self.send(env, location, self.header(location, challenge, "00000002"))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
+ assert r.response["status"] == 401
+ assert self.challenge_of(r).stale, \
+ "a returning client was reported as a possible replay attack " \
+ "because its id had been given to somebody else"
+
+ def test_digest_092_onetime_nonce_from_before_a_restart_is_not_accepted(self, env):
+ # One-time nonces are ordered by a counter which restarts at 0 with
+ # the segment, while the nonce itself stays verifiable. A client
+ # holding an unused nonce from before the restart therefore presents
+ # a counter value the new server has not reached yet.
+ location = "onetime"
+ self.reload(env)
+ challenge = self.challenge(env, location)
+ for _ in range(5):
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, challenge)
+ # challenge.nonce is now an unused nonce, well ahead of the counter
+ # that a restart will reset to 0.
+
+ self.reload(env)
+
+ newcomer = self.challenge(env, location)
+ r = self.send(env, location, self.header(location, newcomer))
+ assert r.response["status"] == 200
+ self.follow_nextnonce(r, newcomer)
+
+ # The nonce from the previous server generation must not be usable.
+ stale_use = self.send(env, location, self.header(location, challenge))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN,
+ ONETIME_REUSED])
+ assert stale_use.response["status"] == 401, \
+ "a one-time nonce issued before the restart was accepted after it"
+
+ # ...and the client which legitimately owns that entry still works.
+ assert self.send(env, location,
+ self.header(location, newcomer)).response["status"] == 200, \
+ "the stale nonce locked out the client holding that id"
+
+ def test_digest_093_onetime_request_cannot_be_replayed_across_a_restart(self, env):
+ # The severity case. A one-time nonce is single-use because the server
+ # remembers the last nonce each client used -- and that memory does
+ # not survive a restart, while the nonce does. So an eavesdropper who
+ # captured one *successfully used* request needs only to wait for a
+ # restart: the counter it outranks has gone back to zero, and the id
+ # it names is handed straight back out.
+ location = "onetime"
+ self.reload(env)
+ challenge = self.challenge(env, location)
+ for _ in range(5):
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ captured = self.header(location, challenge) # a used request
+ self.follow_nextnonce(r, challenge)
+
+ assert self.send(env, location, captured).response["status"] == 401, \
+ "the captured request was not single-use before the restart"
+
+ self.reload(env)
+
+ # Somebody authenticates, so the recycled id names a live entry.
+ newcomer = self.challenge(env, location)
+ assert self.send(env, location,
+ self.header(location, newcomer)).response["status"] == 200
+
+ replay = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN,
+ ONETIME_REUSED])
+ assert replay.response["status"] == 401, \
+ "a captured one-time request was replayed successfully after a restart"
diff --git a/test/modules/aaa/test_010_eviction.py b/test/modules/aaa/test_010_eviction.py
new file mode 100644
index 00000000000..ed6af5bb69d
--- /dev/null
+++ b/test/modules/aaa/test_010_eviction.py
@@ -0,0 +1,120 @@
+"""Which client entries are discarded when the table is full.
+
+The client table is a fixed-size shared memory segment (AuthDigestShmemSize,
+pinned small by conftest here), and gc() makes room by discarding one entry
+from each bucket. An entry is allocated for any request which needs a
+challenge, including one carrying no credentials at all, since the challenge
+has to carry the identifier the client will be tracked by. An attacker can
+therefore fill the table with bare requests as fast as it can send them.
+
+What that costs the clients already using the server depends on which entry
+gc() picks. Discarding a client which has authenticated costs it a request:
+its nonce-count and last-used nonce go with the entry, so its next request
+is answered with a fresh challenge (stale=true) and has to be retried.
+Discarding a client which has never authenticated costs nothing at all --
+the entry records nothing yet, and the client simply gets a new identifier
+with its next challenge.
+
+So gc() prefers the entries which have never been used to authenticate,
+which are exactly the ones a flood of unauthenticated requests creates.
+
+"Never used to authenticate" is last_nonce_time == 0, which is exact in both
+of the configurations that track clients: an accepted request stores the
+time its nonce was generated at, which is a wall-clock time for a nonce with
+a lifetime and a counter which starts at 1 for a one-time nonce. Neither is
+ever 0. The tests below run against both to pin that.
+"""
+
+import pytest
+
+from . import digest_client as dc
+from .env import AAATestEnv
+
+NC_FAILED = "AH01774"
+CLIENT_UNKNOWN = "AH10618"
+
+# A location tracking clients for the nonce-count, and one tracking them for
+# one-time nonces: the two put different kinds of value in last_nonce_time.
+BOTH = ["nccheck", "onetime"]
+
+
+class TestDigestEviction:
+
+ def url(self, env, location):
+ return env.mkurl("http", "aaa", f"/digest/{location}/secret.txt")
+
+ def uri(self, location):
+ return f"/digest/{location}/secret.txt"
+
+ def challenge(self, env, location):
+ r = env.curl_get(self.url(env, location))
+ assert r.response["status"] == 401
+ return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+
+ def header(self, location, challenge, nc="00000001", cnonce="eviction-cnonce"):
+ return dc.build_authorization(
+ AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
+ method="GET", uri=self.uri(location), nc=nc, cnonce=cnonce)
+
+ def send(self, env, location, auth):
+ return env.curl_get(self.url(env, location),
+ options=["-H", f"Authorization: {auth}"])
+
+ def advance(self, r, challenge):
+ """Prepare the client's next request, and return the nc to send with
+ it. A one-time nonce is replaced by the nextnonce just handed out,
+ and the count restarts at 1 for it; otherwise the nonce stays and the
+ count goes up."""
+ ai = r.response["header"].get("authentication-info")
+ if ai:
+ params = dc.parse_params(ai)
+ if "nextnonce" in params:
+ challenge.nonce = params["nextnonce"]
+ return "00000001"
+ return "00000002"
+
+ def flood(self, env, location, count):
+ """Bare requests, each of which allocates a client entry."""
+ for _ in range(count):
+ env.curl_get(self.url(env, location))
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_100_authenticated_client_survives_a_flood(self, env, location):
+ # The legitimate client authenticates, so its entry now records the
+ # nonce it used.
+ challenge = self.challenge(env, location)
+ r = self.send(env, location, self.header(location, challenge))
+ assert r.response["status"] == 200
+ nc = self.advance(r, challenge)
+
+ # An attacker fills the table several times over with requests which
+ # carry no credentials at all.
+ self.flood(env, location, 60)
+
+ # The victim carries on. Its entry must still be there: it is the
+ # only one in the table which is worth keeping.
+ r = self.send(env, location, self.header(location, challenge, nc))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
+ assert r.response["status"] == 200, \
+ "a flood of unauthenticated requests evicted an authenticated client"
+
+ @pytest.mark.parametrize("location", BOTH)
+ def test_digest_101_unused_entries_are_the_ones_discarded(self, env, location):
+ # The counterpart: the entries a flood creates are themselves the
+ # ones discarded, so a flood cannot fill the table permanently. The
+ # opaque handed out at the start of one no longer names an entry by
+ # the end, which the server reports by minting a new one rather than
+ # echoing it back -- and as a stale challenge, since nothing was
+ # wrong with the client's credentials.
+ first = self.challenge(env, location)
+ self.flood(env, location, 60)
+
+ r = self.send(env, location, self.header(location, first))
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
+ assert r.response["status"] == 401
+ again = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
+ assert again.opaque != first.opaque, \
+ "the unused entry should have been discarded by the flood"
+ assert again.stale, \
+ "a client whose unused entry was discarded should be re-challenged " \
+ "as stale, not told its authentication failed"
From 12d24a9d54a7b59e6120d3348abe83c6af7e2cda Mon Sep 17 00:00:00 2001
From: Joe Orton
Date: Wed, 26 Aug 2026 07:01:21 +0000
Subject: [PATCH 8/8] * .gitleaks.toml: Add gitleaks allowlist to prevent false
positives from embedded passwords in test/modules/aaa. See
https://github.com/gitleaks/gitleaks for details. [skip ci]
git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937465 13f79535-47bb-0310-9956-ffa450edef68
(cherry picked from commit c5ec8836cbf82d9482b7f6ef18742dc9d49788fa)
---
.gitleaks.toml | 11 +++++++++++
1 file changed, 11 insertions(+)
create mode 100644 .gitleaks.toml
diff --git a/.gitleaks.toml b/.gitleaks.toml
new file mode 100644
index 00000000000..4493f699c0e
--- /dev/null
+++ b/.gitleaks.toml
@@ -0,0 +1,11 @@
+# Gitleaks scanner config to prevent false positives
+# see https://github.com/gitleaks/gitleaks
+[allowlist]
+description = "Global Allowlist"
+
+# Ignore based on any subset of the file path
+paths = [
+# Ignore all authentication tests which do contain
+# embedded test passwords.
+'''test\/modules\/aaa\/*\.py''',
+]