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:20 UTC
svn commit: r1937105 - in httpd/httpd/trunk: docs/log-message-tags modules/aaa test/modules/aaa test/modules/aaa/htdocs/digest/onetime-nccheck
Author: jorton
Date: Thu Aug 13 15:15:19 2026
New Revision: 1937105
Log:
* modules/aaa/mod_auth_digest.c: Consolidate one-time nonce handling
in check_and_record_nonce, accepting a nonce iff it is newer than
the last used, and re-challenging as stale when reused.
* test/modules/aaa/test_008_onetime_nccheck.py: Add tests for one-time
nonces, alone and with AuthDigestNcCheck.
* test/modules/aaa/conftest.py: Add an AuthDigestNonceLifetime 0 plus
AuthDigestNcCheck location.
Assisted-by: Claude Opus 5 (1M context) <no...@anthropic.com>
GitHub: PR #705
Added:
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime-nccheck/
httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt
httpd/httpd/trunk/test/modules/aaa/test_008_onetime_nccheck.py
Modified:
httpd/httpd/trunk/docs/log-message-tags/next-number
httpd/httpd/trunk/modules/aaa/mod_auth_digest.c
httpd/httpd/trunk/test/modules/aaa/conftest.py
Modified: httpd/httpd/trunk/docs/log-message-tags/next-number
==============================================================================
--- httpd/httpd/trunk/docs/log-message-tags/next-number Thu Aug 13 15:15:07 2026 (r1937104)
+++ httpd/httpd/trunk/docs/log-message-tags/next-number Thu Aug 13 15:15:19 2026 (r1937105)
@@ -1 +1 @@
-10618
+10619
Modified: httpd/httpd/trunk/modules/aaa/mod_auth_digest.c
==============================================================================
--- httpd/httpd/trunk/modules/aaa/mod_auth_digest.c Thu Aug 13 15:15:07 2026 (r1937104)
+++ httpd/httpd/trunk/modules/aaa/mod_auth_digest.c Thu Aug 13 15:15:19 2026 (r1937105)
@@ -122,7 +122,6 @@ typedef struct hash_entry {
* 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;
static struct hash_table {
@@ -139,6 +138,14 @@ static struct hash_table {
enum hdr_sts { NO_HEADER, NOT_DIGEST, INVALID, VALID };
+/* Outcome of checking a request's nonce and nonce-count against the state
+ * tracked for its client. */
+enum nonce_state {
+ NONCE_ACCEPTED, /* recorded as the latest used by this client */
+ NONCE_STALE, /* already used, or the client is unknown */
+ NONCE_BAD_COUNT /* nonce-count did not increase: possible replay */
+};
+
typedef struct digest_header_struct {
const char *scheme;
const char *realm;
@@ -1009,7 +1016,10 @@ static const char *gen_nonce(apr_pool_t
t.time = now;
}
else {
- t.time = apr_atomic_inc32(otn_counter);
+ /* Nonces are ordered by this counter rather than by time; the +1
+ * is because apr_atomic_inc32() returns the previous value, and a
+ * nonce time of zero means "no nonce used yet" in a client entry. */
+ 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);
@@ -1029,7 +1039,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, 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)
@@ -1106,9 +1116,6 @@ static void note_digest_auth_failure(req
/* Setup nonce */
nonce = gen_nonce(r->pool, r->request_time, opaque, r->server, conf, ap_auth_name(r));
- if (resp->client && conf->nonce_lifetime == 0) {
- memcpy(resp->client->last_nonce, nonce, NONCE_LEN+1);
- }
/* setup domain attribute. We want to send this attribute wherever
* possible so that the client won't send the Authorization header
@@ -1236,76 +1243,108 @@ static authn_status get_hash(request_rec
return auth_result;
}
-/* Check the nonce-count of a request against the count tracked for this
- * client, and update the tracked count.
+/* Check the nonce and nonce-count of a fully verified request against the
+ * state tracked for its client, record them, and generate a new challenge
+ * if they are not acceptable.
*
- * 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.
+ * Both are compared against what the client last *used*, never against what
+ * was last issued to it: a nonce is issued whenever a challenge is
+ * generated, and anything quoting the client's opaque can provoke a
+ * challenge, so tracking what was issued lets an unauthenticated request
+ * invalidate the nonce which the legitimate client is holding.
+ *
+ * A one-time nonce (AuthDigestNonceLifetime 0) is therefore accepted iff it
+ * is newer than the last nonce this client used, which permits it exactly
+ * once. Otherwise, with AuthDigestNcCheck, a newer nonce starts a new count
+ * and the same nonce must raise it: the nonce-count is counted by the client
+ * per-nonce (RFC 7616 3.4.3). Within a nonce the count must strictly
+ * increase, but it need not increase by exactly one, since 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.
+ * it quotes.
*/
-static int check_and_record_nonce(const request_rec *r,
- const digest_header_rec *resp,
- const digest_config_rec *conf)
+static int check_and_record_nonce(request_rec *r, digest_header_rec *resp,
+ const digest_config_rec *conf)
{
client_entry *client = resp->client;
- unsigned long nc, tracked;
+ unsigned long nc, tracked = 0;
const char *snc = resp->nonce_count;
char *endptr;
- int accepted;
+ enum nonce_state state = NONCE_STALE;
- if (!conf->check_nc) {
- return OK;
+ if (!conf->check_nc && conf->nonce_lifetime != 0) {
+ return OK; /* nothing is tracked per-client */
}
nc = strtol(snc, &endptr, 16);
if (endptr < (snc+strlen(snc)) && !apr_isspace(*endptr)) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01773)
"invalid nc %s received - not a number", snc);
- return !OK;
+ note_digest_auth_failure(r, conf, resp, 0);
+ return HTTP_UNAUTHORIZED;
}
- if (!client) {
- /* Without an opaque identifying the client there is nothing to
- * check the nonce-count against. */
- return !OK;
- }
+ if (client) {
+ apr_global_mutex_lock(client_lock);
- apr_global_mutex_lock(client_lock);
+ tracked = client->nonce_count;
+ if (conf->nonce_lifetime == 0) {
+ /* one-time nonce: usable until it has been used */
+ state = (resp->nonce_time > client->last_nonce_time)
+ ? NONCE_ACCEPTED : NONCE_STALE;
+ }
+ else if (resp->nonce_time > client->last_nonce_time
+ || (resp->nonce_time == client->last_nonce_time
+ && nc > tracked)) {
+ state = NONCE_ACCEPTED;
+ }
+ else {
+ state = NONCE_BAD_COUNT;
+ }
- tracked = client->nonce_count;
+ if (state == NONCE_ACCEPTED) {
+ client->last_nonce_time = resp->nonce_time;
+ client->nonce_count = nc;
+ }
- /* 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);
}
- apr_global_mutex_unlock(client_lock);
-
- if (!accepted) {
+ if (!client) {
+ ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(10618)
+ "client %lu is no longer known - sending new nonce",
+ resp->opaque_num);
+ }
+ else if (state == NONCE_STALE) {
+ ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(01779)
+ "user %s: one-time-nonce %s already used - sending "
+ "new nonce", r->user, resp->nonce);
+ }
+ else if (state == NONCE_BAD_COUNT) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01774)
"Warning, possible replay attack: nonce-count check "
"failed: %lu is not above %lu for nonce %s", nc,
tracked, resp->nonce);
- return !OK;
}
- return OK;
+ switch (state) {
+ case NONCE_ACCEPTED:
+ return OK;
+
+ case NONCE_STALE:
+ /* the credentials were good, so the client can silently retry with
+ * the nonce from this challenge */
+ note_digest_auth_failure(r, conf, resp, 1);
+ return HTTP_UNAUTHORIZED;
+
+ default:
+ note_digest_auth_failure(r, conf, resp, 0);
+ return HTTP_UNAUTHORIZED;
+ }
}
static int check_nonce(request_rec *r, digest_header_rec *resp,
@@ -1350,16 +1389,8 @@ static int check_nonce(request_rec *r, d
return HTTP_UNAUTHORIZED;
}
}
- else if (conf->nonce_lifetime == 0 && resp->client) {
- if (memcmp(resp->client->last_nonce, resp->nonce, NONCE_LEN)) {
- ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(01779)
- "user %s: one-time-nonce mismatch - sending "
- "new nonce", r->user);
- note_digest_auth_failure(r, conf, resp, 1);
- return HTTP_UNAUTHORIZED;
- }
- }
- /* else (lifetime < 0) => never expires */
+ /* else (lifetime <= 0) => never expires by time; a one-time nonce is
+ * retired by use, in check_and_record_nonce() */
return OK;
}
@@ -1672,12 +1703,7 @@ static int authenticate_digest_user(requ
return res;
}
- if (check_and_record_nonce(r, resp, conf) != OK) {
- note_digest_auth_failure(r, conf, resp, 0);
- return HTTP_UNAUTHORIZED;
- }
-
- return OK;
+ return check_and_record_nonce(r, resp, conf);
}
/* Authentication-Info header code. */
@@ -1716,7 +1742,6 @@ static int add_auth_info(request_rec *r)
const char *nonce = gen_nonce(r->pool, 0, resp->opaque, r->server,
conf, ap_auth_name(r));
nextnonce = apr_pstrcat(r->pool, ", nextnonce=\"", nonce, "\"", NULL);
- memcpy(resp->client->last_nonce, nonce, NONCE_LEN+1);
}
/* else nonce never expires, hence no nextnonce */
Modified: httpd/httpd/trunk/test/modules/aaa/conftest.py
==============================================================================
--- httpd/httpd/trunk/test/modules/aaa/conftest.py Thu Aug 13 15:15:07 2026 (r1937104)
+++ httpd/httpd/trunk/test/modules/aaa/conftest.py Thu Aug 13 15:15:19 2026 (r1937105)
@@ -73,6 +73,12 @@ def env(pytestconfig) -> AAATestEnv:
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}"',
Added: httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt Thu Aug 13 15:15:19 2026 (r1937105)
@@ -0,0 +1 @@
+digest-onetime-secret
Added: httpd/httpd/trunk/test/modules/aaa/test_008_onetime_nccheck.py
==============================================================================
--- /dev/null 00:00:00 1970 (empty, because file is newly added)
+++ httpd/httpd/trunk/test/modules/aaa/test_008_onetime_nccheck.py Thu Aug 13 15:15:19 2026 (r1937105)
@@ -0,0 +1,185 @@
+"""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"
+
+
+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 -- AuthDigestShmemSize defaults to 1000
+ # bytes, "~ 12 entries" -- and a request with no credentials at all
+ # allocates an entry, since the challenge it gets back has to carry an
+ # opaque. An attacker can therefore make gc() discard a client's entry
+ # for the price of a dozen bare requests.
+ #
+ # A captured request must still not be replayable once that has
+ # happened. 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
+
+ for _ in range(40):
+ env.curl_get(self.url(env, location))
+
+ r = self.send(env, location, captured)
+ env.httpd_error_log.ignore_recent(lognos=[NC_FAILED])
+ assert r.response["status"] == 401, \
+ "captured request replayed once the client entry was evicted"