Author: jorton
Date: Thu Aug 13 15:15:07 2026
New Revision: 1937104
Log:
* modules/aaa/mod_auth_digest.c: Fix AuthDigestNcCheck: track the
nonce-count per-nonce, require it to increase (RFC 7616 3.4.3), and
record it only for a fully verified request.
* test/modules/aaa/test_007_replay.py: Add replay tests.
* test/modules/aaa/test_003_nccheck.py, test/modules/aaa/conftest.py:
Update for the new semantics.
Assisted-by: Claude Opus 5 (1M context) <[email protected]>
GitHub: PR #705
Added:
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck-shortlife/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt
httpd/httpd/trunk/test/modules/aaa/test_007_replay.py
Modified:
httpd/httpd/trunk/modules/aaa/mod_auth_digest.c
httpd/httpd/trunk/test/modules/aaa/conftest.py
httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py
Modified: httpd/httpd/trunk/modules/aaa/mod_auth_digest.c
==============================================================================
--- httpd/httpd/trunk/modules/aaa/mod_auth_digest.c Thu Aug 13 15:14:58 2026 (r1937103)
+++ httpd/httpd/trunk/modules/aaa/mod_auth_digest.c Thu Aug 13 15:15:07 2026 (r1937104)
@@ -118,7 +118,10 @@ typedef struct digest_config_struct {
typedef struct hash_entry {
unsigned long key; /* the key for this entry */
struct hash_entry *next; /* next entry in the bucket */
- unsigned long nonce_count; /* for nonce-count checking */
+ unsigned long nonce_count; /* highest nonce-count seen
+ * for last_nonce_time */
+ apr_time_t last_nonce_time; /* nonce of the last request
+ * accepted for this client */
char last_nonce[NONCE_LEN+1]; /* for one-time nonce's */
} client_entry;
@@ -932,21 +935,22 @@ static int get_digest_rec(request_rec *r
}
-/* Because the browser may preemptively send auth info, incrementing the
- * nonce-count when it does, and because the client does not get notified
- * if the URI didn't need authentication after all, we need to be sure to
- * update the nonce-count each time we receive an Authorization header no
- * matter what the final outcome of the request. Furthermore this is a
- * convenient place to get the request-uri (before any subrequests etc
- * are initiated) and to initialize the request_config.
+/* This is a convenient place to parse the Authorization header, to get the
+ * request-uri (before any subrequests etc are initiated) and to initialize
+ * the request_config.
+ *
+ * Note that the nonce-count tracked for the client is deliberately NOT
+ * updated here: the state of an authenticated client must not be altered
+ * by a request which has not (yet) been authenticated, or a replayed or
+ * bogus request quoting the client's opaque would be able to rewind that
+ * state. See check_and_record_nonce().
*
* Note that this must be called after mod_proxy had its go so that
* r->proxyreq is set correctly.
*/
-static int parse_hdr_and_update_nc(request_rec *r)
+static int parse_digest_header(request_rec *r)
{
digest_header_rec *resp;
- int res;
if (!ap_is_initial_req(r)) {
return DECLINED;
@@ -959,11 +963,8 @@ static int parse_hdr_and_update_nc(reque
resp->method = r->method;
ap_set_module_config(r->request_config, &auth_digest_module, resp);
- res = get_digest_rec(r, resp);
+ get_digest_rec(r, resp);
resp->client = get_client(resp->opaque_num, r);
- if (res == OK && resp->client) {
- resp->client->nonce_count++;
- }
return DECLINED;
}
@@ -1028,7 +1029,7 @@ static const char *gen_nonce(apr_pool_t
static client_entry *gen_client(const request_rec *r)
{
apr_uint32_t op = apr_atomic_inc32(opaque_counter);
- client_entry new_entry = { 0, NULL, 0, "" }, *entry;
+ client_entry new_entry = { 0, NULL, 0, 0, "" }, *entry;
if (!(entry = add_client(op, &new_entry, r->server))) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01769)
@@ -1088,9 +1089,11 @@ static void note_digest_auth_failure(req
}
}
else {
+ /* Note that the nonce-count tracked for this client is left alone
+ * 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. */
opaque = resp->opaque;
- /* we're generating a new nonce, so reset the nonce-count */
- resp->client->nonce_count = 0;
}
if (opaque[0]) {
@@ -1233,12 +1236,33 @@ static authn_status get_hash(request_rec
return auth_result;
}
-static int check_nc(const request_rec *r, const digest_header_rec *resp,
- const digest_config_rec *conf)
+/* Check the nonce-count of a request against the count tracked for this
+ * client, and update the tracked count.
+ *
+ * The nonce-count is counted by the client per-nonce (RFC 7616 3.4.3), so
+ * the count tracked here is tied to the nonce it was counted for: a request
+ * using a newer nonce starts a new count, and a request using an older
+ * nonce is replaying a superseded one. Within a single nonce the count must
+ * strictly increase, but it need not increase by exactly one: the client
+ * also counts the requests it sends to URIs in the protection space which
+ * turn out not to need authentication, and this server never sees those.
+ *
+ * This must only be called for a request which is fully verified - both the
+ * response digest and the nonce - so that a request which fails to
+ * authenticate cannot alter the state tracked for the client whose opaque
+ * it quotes. Otherwise a bogus or replayed request could rewind the count
+ * and so lock out the legitimate client, and line the count up with the
+ * nonce-count of the replayed request itself.
+ */
+static int check_and_record_nonce(const request_rec *r,
+ const digest_header_rec *resp,
+ const digest_config_rec *conf)
{
- unsigned long nc;
+ client_entry *client = resp->client;
+ unsigned long nc, tracked;
const char *snc = resp->nonce_count;
char *endptr;
+ int accepted;
if (!conf->check_nc) {
return OK;
@@ -1251,15 +1275,33 @@ static int check_nc(const request_rec *r
return !OK;
}
- if (!resp->client) {
+ if (!client) {
+ /* Without an opaque identifying the client there is nothing to
+ * check the nonce-count against. */
return !OK;
}
- if (nc != resp->client->nonce_count) {
+ apr_global_mutex_lock(client_lock);
+
+ tracked = client->nonce_count;
+
+ /* Accept, and record, iff the client has moved on to a newer nonce (in
+ * which case this is the first request counted for that nonce), or is
+ * still on the tracked nonce and has raised the count. */
+ accepted = (resp->nonce_time > client->last_nonce_time)
+ || (resp->nonce_time == client->last_nonce_time && nc > tracked);
+ if (accepted) {
+ client->last_nonce_time = resp->nonce_time;
+ client->nonce_count = nc;
+ }
+
+ apr_global_mutex_unlock(client_lock);
+
+ if (!accepted) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01774)
- "Warning, possible replay attack: nonce-count "
- "check failed: %lu != %lu", nc,
- resp->client->nonce_count);
+ "Warning, possible replay attack: nonce-count check "
+ "failed: %lu is not above %lu for nonce %s", nc,
+ tracked, resp->nonce);
return !OK;
}
@@ -1621,17 +1663,20 @@ static int authenticate_digest_user(requ
}
}
- if (check_nc(r, resp, conf) != OK) {
- note_digest_auth_failure(r, conf, resp, 0);
- return HTTP_UNAUTHORIZED;
- }
-
- /* Note: this check is done last so that a "stale=true" can be
- generated if the nonce is old */
+ /* Note: the nonce is checked before the nonce-count so that the
+ * nonce-count state is only ever updated for a request which is using
+ * a nonce this server issued, and so that a request using an expired
+ * nonce gets a "stale=true" challenge (and hence a silent retry with a
+ * fresh nonce-count) rather than being reported as a replay. */
if ((res = check_nonce(r, resp, conf))) {
return res;
}
+ if (check_and_record_nonce(r, resp, conf) != OK) {
+ note_digest_auth_failure(r, conf, resp, 0);
+ return HTTP_UNAUTHORIZED;
+ }
+
return OK;
}
@@ -1665,8 +1710,6 @@ static int add_auth_info(request_rec *r)
gen_nonce(r->pool, r->request_time,
resp->opaque, r->server, conf, ap_auth_name(r)),
"\"", NULL);
- if (resp->client)
- resp->client->nonce_count = 0;
}
}
else if (conf->nonce_lifetime == 0 && resp->client) {
@@ -1733,7 +1776,7 @@ static void register_hooks(apr_pool_t *p
ap_hook_pre_config(pre_init, NULL, NULL, APR_HOOK_MIDDLE);
ap_hook_post_config(initialize_module, NULL, cfgPost, APR_HOOK_MIDDLE);
ap_hook_child_init(initialize_child, NULL, NULL, APR_HOOK_MIDDLE);
- ap_hook_post_read_request(parse_hdr_and_update_nc, parsePre, NULL, APR_HOOK_MIDDLE);
+ ap_hook_post_read_request(parse_digest_header, parsePre, NULL, APR_HOOK_MIDDLE);
ap_hook_check_authn(authenticate_digest_user, NULL, NULL, APR_HOOK_MIDDLE,
AP_AUTH_INTERNAL_PER_CONF);
Modified: httpd/httpd/trunk/test/modules/aaa/conftest.py
==============================================================================
--- httpd/httpd/trunk/test/modules/aaa/conftest.py Thu Aug 13 15:14:58 2026 (r1937103)
+++ httpd/httpd/trunk/test/modules/aaa/conftest.py Thu Aug 13 15:15:07 2026 (r1937104)
@@ -52,6 +52,12 @@ def env(pytestconfig) -> AAATestEnv:
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}"',
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt Thu Aug 13 15:15:07 2026 (r1937104)
@@ -0,0 +1 @@
+digest-nccheck-secret
Modified: httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py
==============================================================================
--- httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py Thu Aug 13 15:14:58 2026 (r1937103)
+++ httpd/httpd/trunk/test/modules/aaa/test_003_nccheck.py Thu Aug 13 15:15:07 2026 (r1937104)
@@ -1,17 +1,17 @@
"""AuthDigestNcCheck replay-detection scenarios.
-Note the actual semantics here are stricter than a sliding replay window:
-the server keeps its own count of authenticated requests seen for a client
-(incremented on *every* request carrying that client's opaque, whether or
-not it goes on to authenticate) and requires the client's nc to match it
-*exactly* -- so both replays of an old nc and skipping ahead are rejected.
-A failed nc check also resets the server's tracked count back to 0, as part
-of issuing a fresh challenge for the client (see note_digest_auth_failure()
-in mod_auth_digest.c: an existing, opaque-identified client always gets its
-nonce_count reset when a new challenge is generated for it, regardless of
-*why* the challenge is being reissued) -- so recovery after a rejected nc
-means starting the sequence over at 00000001, not continuing where the
-client left off.
+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
@@ -40,13 +40,17 @@ class TestDigestNcCheck:
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 the check outright, even with nc=00000001.
+ # 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 False
+ 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")
@@ -72,21 +76,34 @@ class TestDigestNcCheck:
assert new_challenge.stale is False
env.httpd_error_log.ignore_recent(lognos=["AH01774"])
- # the rejected attempt reset the server's tracked count to 0 (a new
- # challenge was issued for this client), so recovery restarts the
- # sequence at 00000001 -- continuing from 00000003 would NOT work.
- r4 = self.authenticate(env, "nccheck", challenge, nc="00000001")
+ # 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
- def test_digest_033_nccheck_skip_ahead_rejected(self, env):
+ # 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 rejected too: nc must match exactly, not just
- # be higher than what was last accepted.
+ # 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"] == 401
+ 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):
Added: httpd/httpd/trunk/test/modules/aaa/test_007_replay.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_007_replay.py Thu Aug 13 15:15:07 2026 (r1937104)
@@ -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])
lmpx.com only provides a reader for public news (NNTP) servers. It is not
affiliated with the servers or forums shown here and is not responsible for
the content of articles, which is written by their respective authors.