diff --git a/changes-entries/digest-authinfo-errors.txt b/changes-entries/digest-authinfo-errors.txt
new file mode 100644
index 00000000000..7d5ba783ea7
--- /dev/null
+++ b/changes-entries/digest-authinfo-errors.txt
@@ -0,0 +1,3 @@
+ *) mod_auth_digest: With one-time nonces (AuthDigestNonceLifetime 0),
+ stop needlessly re-challenging a client after an error response.
+ [Joe Orton]
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
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",
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"