Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
445 changes: 189 additions & 256 deletions modules/aaa/mod_auth_digest.c

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions test/modules/aaa/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ def env(pytestconfig) -> AAATestEnv:
# AuthDigestProvider intentionally omitted: falls back to "file".
f'AuthUserFile "{pwfile}"',
]))
# A request which fails header validation (e.g. no Host on HTTP/1.1)
# is answered before the Digest module's post_read_request runs, so it
# has no per-request Digest record; an ErrorDocument redirecting it into
# a Digest-protected location must not then dereference that record.
conf.add('ErrorDocument 400 /digest/default/secret.txt')
# A reverse-proxied location which authenticates with Digest at the
# proxy (so the backend is never reached and need not be reachable).
# The challenge is an origin-style WWW-Authenticate, so AuthDigestDomain
# applies to it just as for a non-proxied location.
conf.add([
'ProxyPass "/pxdomain/" "http://127.0.0.1:1/unused/"',
'<Location "/pxdomain/">',
' AuthType Digest',
f' AuthName "{AAATestEnv.REALM}"',
' AuthDigestProvider file',
f' AuthUserFile "{pwfile}"',
' AuthDigestDomain "/pxdomain/"',
' Require valid-user',
'</Location>',
])
conf.install()
assert env.apache_restart() == 0
return env
Expand Down
2 changes: 1 addition & 1 deletion test/modules/aaa/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def __init__(self, env: 'HttpdTestEnv'):
super().__init__(env=env)
self.add_source_dir(os.path.dirname(inspect.getfile(AAATestSetup)))
self.add_modules(["auth_digest", "authn_file", "authn_core",
"authz_core", "authz_user"])
"authz_core", "authz_user", "proxy", "proxy_http"])


class AAATestEnv(HttpdTestEnv):
Expand Down
19 changes: 18 additions & 1 deletion test/modules/aaa/test_001_challenge_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def test_digest_013_invalid_opaque(self, env):
opaque="not-a-hex-number")
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
env.httpd_error_log.ignore_recent(lognos=["AH01787"])
env.httpd_error_log.ignore_recent(lognos=["AH01782", "AH01787"])

def test_digest_014_tampered_response_hash(self, env):
challenge = self.challenge(env)
Expand All @@ -178,3 +178,20 @@ def test_digest_014_tampered_response_hash(self, env):
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
env.httpd_error_log.ignore_recent(lognos=["AH01794"])

def test_digest_015_uri_bad_escaping_rejected(self, env):
# The Authorization uri= is compared against the request-target after
# %-decoding both. A uri= that fails to decode -- an encoded NUL or
# slash -- must be rejected, not silently truncated at the bad octet
# so that its prefix matches the request-target.
challenge = self.challenge(env)
for bad_uri in ["/digest/default/secret.txt%00ignored",
"/digest/default%2fsecret.txt"]:
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=bad_uri)
r = env.curl_get(self.url(env),
options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 400, \
f"uri={bad_uri!r} was not rejected"
env.httpd_error_log.ignore_recent(lognos=["AH01783"])
76 changes: 55 additions & 21 deletions test/modules/aaa/test_003_nccheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,28 +116,62 @@ def test_digest_034_no_nccheck_allows_replay(self, env):
assert r2.response["status"] == 200

def test_digest_035_out_of_range_opaque_is_not_truncated(self, env):
# The opaque is a 32-bit client id. A value which would truncate onto
# a live id must not select that client. This is observable in the
# challenge which comes back: a client the server still knows is
# re-challenged with its own opaque, whereas an unknown one is given a
# freshly minted opaque and stale=true.
# The opaque is a 32- or 64-bit client id. A value which would
# truncate onto a live id must not select that client. This is
# observable in the challenge which comes back: a client the server
# still knows is re-challenged with its own opaque, whereas an unknown
# one is given a freshly minted opaque and stale=true.
challenge = self.challenge(env, "nccheck")
assert self.authenticate(env, "nccheck", challenge,
nc="00000001").response["status"] == 200

# 2^32 + the live id, which truncates to the live id in 32 bits
crafted = "%x" % ((1 << 32) + int(challenge.opaque, 16))
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/nccheck/secret.txt", nc="00000002",
cnonce="trunc-cnonce", opaque=crafted)
r = env.curl_get(env.mkurl("http", "aaa", "/digest/nccheck/secret.txt"),
options=["-H", f"Authorization: {auth}"])
# AH01787 with the range check in place; AH01776 (nonce hash) if the
# opaque were truncated onto the live client instead
env.httpd_error_log.ignore_recent(lognos=["AH01787", "AH01776"])
assert r.response["status"] == 401
new_challenge = dc.DigestChallenge.parse(
r.response["header"]["www-authenticate"])
assert new_challenge.opaque != crafted, \
"an out-of-range opaque was truncated onto a live client id"
# 2^bits + the live id, which truncates to the live id in that many
# bits; one of the two is out of range whichever width is in use
for bits in (32, 64):
crafted = "%x" % ((1 << bits) + int(challenge.opaque, 16))
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/nccheck/secret.txt", nc="00000002",
cnonce="trunc-cnonce", opaque=crafted)
r = env.curl_get(env.mkurl("http", "aaa", "/digest/nccheck/secret.txt"),
options=["-H", f"Authorization: {auth}"])
# AH01782 (AH01787 before 64-bit ids) for an opaque out of range,
# AH01776 (nonce hash) for one in range but not the opaque the
# nonce was issued with
env.httpd_error_log.ignore_recent(lognos=["AH01782", "AH01787",
"AH01776"])
assert r.response["status"] == 401
new_challenge = dc.DigestChallenge.parse(
r.response["header"]["www-authenticate"])
assert new_challenge.opaque != crafted, \
f"an opaque 2^{bits} above a live client id was truncated onto it"

def test_digest_036_malformed_nc_rejected(self, env):
# The nonce-count is 8 hex digits. A value which is not -- a negative
# number, non-hex, or the wrong length -- must be rejected rather
# than parsed leniently into a wrapped count.
challenge = self.challenge(env, "nccheck")
for bad in ["-0000001", "zzzzzzzz", "000000001", "1", "0000 001"]:
r = self.authenticate(env, "nccheck", challenge, nc=bad,
cnonce=f"nc-{len(bad)}")
assert r.response["status"] == 401, f"nc={bad!r} was accepted"
env.httpd_error_log.ignore_recent(lognos=["AH01782"])

def test_digest_037_malformed_nc_does_not_poison_client(self, env):
# A correctly-signed request carrying nc="-0000001" must not be
# accepted: before the count was validated it was read as a huge
# unsigned value and stored, locking the client out of every later
# (smaller) in-sequence count.
challenge = self.challenge(env, "nccheck")
assert self.authenticate(env, "nccheck", challenge,
nc="00000001").response["status"] == 200

r = self.authenticate(env, "nccheck", challenge, nc="-0000001",
cnonce="poison-cnonce")
assert r.response["status"] == 401, "a negative nc was accepted"

# the legitimate client carries on with its next in-sequence count
assert self.authenticate(env, "nccheck", challenge,
nc="00000002").response["status"] == 200, \
"a malformed nc locked the client out"
env.httpd_error_log.ignore_recent(lognos=["AH01782"])
18 changes: 18 additions & 0 deletions test/modules/aaa/test_006_config_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,21 @@ def test_digest_066_shmemsize_valid_accepted(self, env):
def test_digest_067_shmemsize_units_accepted(self, env):
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 64K"])
assert r.exit_code == 0

def test_digest_068_shmemsize_trailing_junk_rejected(self, env):
# Junk after the unit character must be rejected, not silently
# ignored (which read "64Kfoo" as 64K).
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 64Kfoo"])
assert r.exit_code != 0
assert "AuthDigestShmemSize" in r.stderr

def test_digest_069_shmemsize_no_room_for_entry_rejected(self, env):
# A size which would hold the table and an entry by their bare
# struct sizes, but not once the per-allocation rmm overhead is
# counted, must be rejected: otherwise every request needing an
# entry gets a 503 the config check did not warn about. 120 bytes is
# above those struct sizes but below the overhead-aware minimum on
# both 32- and 64-bit.
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 120"])
assert r.exit_code != 0
assert "AuthDigestShmemSize" in r.stderr
37 changes: 37 additions & 0 deletions test/modules/aaa/test_012_errordoc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""An ErrorDocument redirecting into a Digest-protected location.

A request which fails header validation -- e.g. an HTTP/1.1 request with no
Host header -- is rejected in ap_check_request_header, before
ap_post_read_request runs. The Digest module sets up its per-request record
in a post_read_request hook, so that record was never created for such a
request.

If ErrorDocument then redirects the failure into a Digest-protected
location, the auth hook runs for the redirect and walks back along ->prev
to the original request to find the shared record -- which is NULL here.
Dereferencing it crashes the child, so an unauthenticated client can take
down a worker with a single malformed request.
"""

from .env import AAATestEnv


class TestDigestErrorDocument:

def test_digest_125_errordoc_into_digest_without_prr(self, env):
# Send an HTTP/1.1 request with the Host header removed; the server
# answers 400 before the Digest post_read_request hook runs, and the
# ErrorDocument points into a Digest-protected location.
url = env.mkurl("http", "aaa", "/anything")
r = env.curl_get(url, options=["--http1.1", "-H", "Host:"])

# The request must get an HTTP response, not a reset connection from
# a crashed child.
assert r.exit_code == 0, \
f"no HTTP response (curl exit {r.exit_code}): the worker likely " \
f"crashed handling the ErrorDocument"
assert r.response["status"] in (400, 401)

# The server is still serving afterwards.
r = env.curl_get(env.mkurl("http", "aaa", "/digest/default/secret.txt"))
assert r.response["status"] == 401
25 changes: 25 additions & 0 deletions test/modules/aaa/test_013_proxy_domain.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""AuthDigestDomain on a reverse-proxied location.

Digest auth can run at a reverse proxy, for a location mapped by ProxyPass.
Such a request carries PROXYREQ_REVERSE, but its 401 is an ordinary
origin-style WWW-Authenticate (the client authenticates to this server, not
through it), so the configured AuthDigestDomain belongs in the challenge
just as for a non-proxied location.

The suppression test treated any proxy request alike and dropped domain=
for the reverse-proxied case, so clients re-authenticated per URI prefix
and sent the Authorization header outside the intended protection space.
"""

from . import digest_client as dc


class TestDigestProxyDomain:

def test_digest_130_domain_sent_for_reverse_proxy(self, env):
r = env.curl_get(env.mkurl("http", "aaa", "/pxdomain/secret.txt"))
assert r.response["status"] == 401
challenge = dc.DigestChallenge.parse(
r.response["header"]["www-authenticate"])
assert "/pxdomain/" in challenge.domain_list(), \
"AuthDigestDomain was dropped from a reverse-proxied challenge"
Loading