From 90e52b128e7eec79164837c8272e5130f949183e Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 4 Sep 2026 20:31:56 +0100 Subject: [PATCH 1/4] mod_auth_digest: Keep Authentication-Info on error responses. * modules/aaa/mod_auth_digest.c (add_auth_info): Add the (Proxy-)Authentication-Info header to err_headers_out, so a one-time nonce client gets its nextnonce after an error response rather than a stale re-challenge. (note_digest_auth_failure): Drop any (Proxy-)Authentication-Info when issuing a challenge, so a post-authentication 401 carries only the fresh nonce. * changes-entries/digest-authinfo-errors.txt: New. Co-Authored-By: Claude Fable 5.1 --- changes-entries/digest-authinfo-errors.txt | 4 ++++ modules/aaa/mod_auth_digest.c | 13 ++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 changes-entries/digest-authinfo-errors.txt diff --git a/changes-entries/digest-authinfo-errors.txt b/changes-entries/digest-authinfo-errors.txt new file mode 100644 index 00000000000..4375609670f --- /dev/null +++ b/changes-entries/digest-authinfo-errors.txt @@ -0,0 +1,4 @@ + *) mod_auth_digest: Add the Authentication-Info response header to + err_headers_out so it survives an error response, letting a client + using one-time nonces continue after a 4xx rather than being + re-challenged as stale. [Joe Orton] diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c index 2e59b7a3976..c08ffff403f 100644 --- a/modules/aaa/mod_auth_digest.c +++ b/modules/aaa/mod_auth_digest.c @@ -1297,6 +1297,14 @@ static int note_digest_auth_failure(request_rec *r, domain = conf->uri_list; } + /* A challenge supersedes any Authentication-Info added at fixups + * for this request, e.g. when a handler notes an auth failure + * after the user was authenticated: the client must not be given + * both a nextnonce and a new nonce. */ + apr_table_unset(r->err_headers_out, + (PROXYREQ_PROXY == r->proxyreq) + ? "Proxy-Authentication-Info" : "Authentication-Info"); + apr_table_mergen(r->err_headers_out, (PROXYREQ_PROXY == r->proxyreq) ? "Proxy-Authenticate" : "WWW-Authenticate", @@ -1878,7 +1886,10 @@ static int add_auth_info(request_rec *r) } if (ai && ai[0]) { - apr_table_mergen(r->headers_out, + /* This must use ->err_headers_out so it survives an error + * response, else a one-time-nonce client loses the nextnonce + * and is stale-challenged on its next request. */ + apr_table_mergen(r->err_headers_out, (PROXYREQ_PROXY == r->proxyreq) ? "Proxy-Authentication-Info" : "Authentication-Info", From 89e3120c71c86f3f834750ae146a7ffb10fd93a7 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 4 Sep 2026 20:31:56 +0100 Subject: [PATCH 2/4] New test suite. * test/modules/aaa/test_011_authinfo_errors.py: Authentication-Info survives an error response. Co-Authored-By: Claude Fable 5.1 --- test/modules/aaa/test_011_authinfo_errors.py | 87 ++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 test/modules/aaa/test_011_authinfo_errors.py diff --git a/test/modules/aaa/test_011_authinfo_errors.py b/test/modules/aaa/test_011_authinfo_errors.py new file mode 100644 index 00000000000..64361f53ba4 --- /dev/null +++ b/test/modules/aaa/test_011_authinfo_errors.py @@ -0,0 +1,87 @@ +"""Authentication-Info survives an error response. + +The header is added at fixups, so unless it goes into err_headers_out it +is dropped when the response turns into an error, costing a one-time-nonce +client the nextnonce and forcing a stale re-challenge. A 404 for a missing +file inside the protected area reaches the error path while fully +authenticated, so it exercises this without a handler like mod_dav. +""" + +from . import digest_client as dc +from .env import AAATestEnv + + +class TestAuthInfoOnError: + + def url(self, env, location, path): + return env.mkurl("http", "aaa", f"/digest/{location}/{path}") + + def uri(self, location, path): + return f"/digest/{location}/{path}" + + def challenge(self, env, location, path="secret.txt"): + r = env.curl_get(self.url(env, location, path)) + assert r.response["status"] == 401 + return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + + def auth(self, env, location, path, challenge, nc="00000001"): + return dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri=self.uri(location, path), nc=nc, + cnonce="authinfo-cnonce") + + def get(self, env, location, path, challenge, nc="00000001"): + return env.curl_get(self.url(env, location, path), options=[ + "-H", f"Authorization: {self.auth(env, location, path, challenge, nc)}"]) + + def test_digest_110_error_response_carries_authentication_info(self, env): + # A 404 for an authenticated request still confirms the response to + # the client: Authentication-Info with a valid rspauth. + location = "default" + challenge = self.challenge(env, location) + r = self.get(env, location, "no-such-file.txt", challenge) + assert r.response["status"] == 404 + assert "authentication-info" in r.response["header"], \ + "the error response dropped Authentication-Info" + ai = dc.parse_params(r.response["header"]["authentication-info"]) + expect = dc.rspauth_digest( + dc.ha1(AAATestEnv.DIGEST_USER, AAATestEnv.REALM, + AAATestEnv.DIGEST_PASSWORD), + challenge.nonce, "00000001", "authinfo-cnonce", "auth", + self.uri(location, "no-such-file.txt")) + assert ai.get("rspauth") == expect, "rspauth wrong on the error response" + + def test_digest_111_onetime_client_continues_after_an_error(self, env): + # Under one-time nonces the error must still hand back a nextnonce, + # or the client's next request is stale-challenged. Prove the client + # can carry straight on to a real request with what the 404 gave it. + location = "onetime" + challenge = self.challenge(env, location) + r = self.get(env, location, "no-such-file.txt", challenge) + assert r.response["status"] == 404 + assert "authentication-info" in r.response["header"], \ + "the one-time-nonce error response dropped Authentication-Info" + ai = dc.parse_params(r.response["header"]["authentication-info"]) + assert "nextnonce" in ai, "no nextnonce to continue with after the error" + + challenge.nonce = ai["nextnonce"] + ok = self.get(env, location, "secret.txt", challenge) + assert ok.response["status"] == 200, \ + "the nextnonce from the error response was not usable" + + def test_digest_112_challenge_has_no_authentication_info(self, env): + # When the error is itself a 401, the challenge stands alone: the + # client is not handed a nextnonce it could not use beside a fresh + # nonce. A wrong password reaches note_digest_auth_failure. + location = "onetime" + challenge = self.challenge(env, location) + bad = dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, "wrong-password", + method="GET", uri=self.uri(location, "secret.txt"), + cnonce="authinfo-cnonce") + r = env.curl_get(self.url(env, location, "secret.txt"), + options=["-H", f"Authorization: {bad}"]) + env.httpd_error_log.ignore_recent(lognos=["AH01794"]) # password mismatch + assert r.response["status"] == 401 + assert "authentication-info" not in r.response["header"], \ + "a 401 challenge carried an Authentication-Info header" From 17a5f9bb358998f7e3d8f47fa4bd9c6bcb9a9cff Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 4 Sep 2026 21:37:55 +0100 Subject: [PATCH 3/4] * docs/manual/mod/mod_auth_digest.xml: Bring the documentation up to date with the module. Document one-time nonces (AuthDigestNonceLifetime 0); reduce AuthDigestQop to auth and AuthDigestAlgorithm to MD5, noting what was removed; state that shared memory is required; and rework the summary and security note to separate authentication from confidentiality and integrity. [skip ci] Co-Authored-By: Claude Fable 5.1 --- docs/manual/mod/mod_auth_digest.xml | 136 ++++++++++++++-------------- 1 file changed, 66 insertions(+), 70 deletions(-) diff --git a/docs/manual/mod/mod_auth_digest.xml b/docs/manual/mod/mod_auth_digest.xml index 21d2a093ad7..0f74a881843 100644 --- a/docs/manual/mod/mod_auth_digest.xml +++ b/docs/manual/mod/mod_auth_digest.xml @@ -31,15 +31,19 @@

This module implements HTTP Digest Authentication - (2617), and - provides an alternative to mod_auth_basic where the - password is not transmitted as cleartext. However, this does - not lead to a significant security advantage over - basic authentication. On the other hand, the password storage on the - server is much less secure with digest authentication than with - basic authentication. Therefore, using basic auth and encrypting the - whole connection using mod_ssl is a much better - alternative.

+ (2617), an alternative to the Basic authentication provided + by mod_auth_basic in which the password is not sent + over the network in cleartext.

+ +

Basic and Digest are both authentication schemes only: they + establish who the client is, and do nothing for the confidentiality or + integrity of the request and response themselves. Over an unencrypted + connection the contents of every request and response are exposed with + either scheme; Digest's one advantage is that the password is not among + them. Digest is therefore not a substitute for a connection secured + with mod_ssl, which protects the credentials and the + data alike, and that one advantage has limits of its own (see the note + below).

AuthName @@ -76,18 +80,18 @@ Note -

Digest authentication was intended to be more secure than basic - authentication, but no longer fulfills that design goal. A - man-in-the-middle attacker can trivially force the browser to downgrade - to basic authentication. And even a passive eavesdropper can brute-force - the password using today's graphics hardware, because the hashing - algorithm used by digest authentication is too fast. Another problem is - that the storage of the passwords on the server is insecure. The contents - of a stolen htdigest file can be used directly for digest authentication. - Therefore using mod_ssl to encrypt the whole connection is - strongly recommended.

-

mod_auth_digest only works properly on platforms - where APR supports shared memory.

+

Digest authentication's one advantage over Basic, keeping the + password off the wire, is conditional. If the client will silently fall + back to Basic when challenged, an active attacker can + strip the Digest challenge and offer Basic instead, and the browser + then sends the password in cleartext; a passive eavesdropper can mount + an offline brute-force attack on a captured response, which the fast + MD5 hash does little to slow; and if the htdigest + file is stolen its stored hashes can be replayed to authenticate to + that realm directly, without the password itself being recovered. + Encrypt the whole connection with mod_ssl instead.

+

mod_auth_digest requires an APR built with shared + memory support, and is not built without it.

@@ -117,29 +121,25 @@ AuthDigestQop -Determines the quality-of-protection to use in digest +Determines the quality-of-protection to use in Digest authentication -AuthDigestQop none|auth|auth-int [auth|auth-int] +AuthDigestQop auth AuthDigestQop auth directory.htaccess AuthConfig -

The AuthDigestQop directive determines - the quality-of-protection to use. auth will - only do authentication (username/password); auth-int is - authentication plus integrity checking (an MD5 hash of the entity - is also computed and checked); none will cause the module - to use the old RFC-2069 digest algorithm (which does not include - integrity checking). Both auth and auth-int may - be specified, in which the case the browser will choose which of - these to use. none should only be used if the browser for - some reason does not like the challenge it receives otherwise.

- - - auth-int is not implemented yet. - +

The AuthDigestQop directive determines the + quality-of-protection to use. Only auth + (authentication) is supported, and it is the default, so this + directive no longer has any effect and is retained only for + compatibility.

+ + Earlier releases also accepted none (the RFC 2069 + Digest scheme, without a quality-of-protection). It has been removed: + a request whose qop is missing or not auth + is now rejected.
@@ -154,26 +154,31 @@ authentication

The AuthDigestNonceLifetime directive - controls how long the server nonce is valid. When the client - contacts the server using an expired nonce the server will send - back a 401 with stale=true. If seconds is - greater than 0 then it specifies the amount of time for which the - nonce is valid; this should probably never be set to less than 10 - seconds. If seconds is less than 0 then the nonce never - expires. -

+ controls how long the server nonce is valid. When the client contacts + the server using an expired nonce the server sends back a 401 with + stale=true, and an RFC-compliant client retries with a + fresh nonce without prompting the user.

+ +
    +
  • If seconds is greater than 0 then it specifies the + amount of time for which the nonce is valid; this should probably + never be set to less than 10 seconds.
  • + +
  • If seconds is 0 then the nonce may be used exactly + once. The server supplies the next nonce in the + Authentication-Info header of each response so the + client can continue; a client which reuses a nonce is answered with + stale=true and retries. One-time nonces give stronger + protection against replaying a captured request, but the client + cannot pipeline requests, and one that does receives a 401 for all + but the first and has to resend them. The protection is worth this + cost mainly for requests that change server state or return content + which varies over time; replaying a request for unchanging content + that the eavesdropper already captured in full gains nothing.
  • + +
  • If seconds is less than 0 then the nonce never + expires.
  • +
@@ -209,8 +214,8 @@ client AuthDigestAlgorithm Selects the algorithm used to calculate the challenge and -response hashes in digest authentication -AuthDigestAlgorithm MD5|MD5-sess +response hashes in Digest authentication +AuthDigestAlgorithm MD5 AuthDigestAlgorithm MD5 directory.htaccess @@ -219,22 +224,13 @@ response hashes in digest authentication

The AuthDigestAlgorithm directive selects the algorithm used to calculate the challenge and response - hashes.

- - - MD5-sess is not correctly implemented yet. - - + hashes. Only MD5 is supported.

AuthDigestDomain -URIs that are in the same protection space for digest +URIs that are in the same protection space for Digest authentication AuthDigestDomain URI [URI] ... directory.htaccess From 18f1b6e8ae514a6bea6ae443e630e5512cd3602b Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 4 Sep 2026 21:48:53 +0100 Subject: [PATCH 4/4] * changes-entries/digest-authinfo-errors.txt: Reword for users; state the visible effect, not the code. [skip ci] Co-Authored-By: Claude Fable 5.1 --- changes-entries/digest-authinfo-errors.txt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/changes-entries/digest-authinfo-errors.txt b/changes-entries/digest-authinfo-errors.txt index 4375609670f..7d5ba783ea7 100644 --- a/changes-entries/digest-authinfo-errors.txt +++ b/changes-entries/digest-authinfo-errors.txt @@ -1,4 +1,3 @@ - *) mod_auth_digest: Add the Authentication-Info response header to - err_headers_out so it survives an error response, letting a client - using one-time nonces continue after a 4xx rather than being - re-challenged as stale. [Joe Orton] + *) mod_auth_digest: With one-time nonces (AuthDigestNonceLifetime 0), + stop needlessly re-challenging a client after an error response. + [Joe Orton]