You are viewing a plain text version of this content. The canonical link for it is here.
Posted to cvs@httpd.apache.org by jo...@apache.org on 2026/08/13 15:15:08 UTC
svn commit: r1937104 - in httpd/httpd/trunk: modules/aaa test/modules/aaa test/modules/aaa/htdocs/digest/nccheck-shortlife
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) <no...@anthropic.com>
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])