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
472 changes: 268 additions & 204 deletions modules/aaa/mod_auth_digest.c

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions test/modules/aaa/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ def env(pytestconfig) -> AAATestEnv:
f'AuthUserFile "{pwfile}"',
'AuthDigestDomain "/digest/domain/" "https://mirror.example.org/other/"',
]))
# Tracks clients like "nccheck", but with a domain; a client crossing
# between the two is still the same user (test_015).
conf.add(_digest_dir(docs, "nccheck-domain", [
'AuthDigestProvider file',
f'AuthUserFile "{pwfile}"',
'AuthDigestNcCheck On',
'AuthDigestDomain "/digest/nccheck-domain/"',
]))
conf.add(_digest_dir(docs, "noprovider", [
# AuthDigestProvider intentionally omitted: falls back to "file".
f'AuthUserFile "{pwfile}"',
Expand All @@ -114,6 +122,19 @@ def env(pytestconfig) -> AAATestEnv:
' Require valid-user',
'</Location>',
])
# A second server with the same realm but its own accounts, on its own
# port so that the main server keeps the rest. The main server's
# <Directory> sections apply here too; this one overrides the password
# file for the nccheck location.
conf.add(f'Listen {env.http_port2}')
conf.start_vhost(domains=[f"aaa2.{env.http_tld}"], port=env.http_port2,
doc_root=docs)
conf.add(_digest_dir(docs, "nccheck", [
'AuthDigestProvider file',
f'AuthUserFile "{env.digest_pwfile2}"',
'AuthDigestNcCheck On',
]))
conf.end_vhost()
conf.install()
assert env.apache_restart() == 0
return env
Expand Down
11 changes: 11 additions & 0 deletions test/modules/aaa/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,15 @@ class AAATestEnv(HttpdTestEnv):
DIGEST_PASSWORD = "digestpass2617"
DIGEST_USER2 = "otheruser"
DIGEST_PASSWORD2 = "otherpass2617"
# DIGEST_USER's password in the second file, for a vhost with its own
# accounts under the same realm.
DIGEST_PASSWORD_VHOST2 = "vhost2pass2617"

def __init__(self, pytestconfig=None):
super().__init__(pytestconfig=pytestconfig)
self.add_httpd_log_modules(["auth_digest", "authn_file", "authz_core"])
self._digest_pwfile = os.path.join(self.server_dir, "digest.passwd")
self._digest_pwfile2 = os.path.join(self.server_dir, "digest2.passwd")

def setup_httpd(self, setup: HttpdTestSetup = None):
super().setup_httpd(setup=AAATestSetup(env=self))
Expand All @@ -46,11 +50,18 @@ def ha1(user, password):
f"{ha1(self.DIGEST_USER, self.DIGEST_PASSWORD)}\n")
fd.write(f"{self.DIGEST_USER2}:{self.REALM}:"
f"{ha1(self.DIGEST_USER2, self.DIGEST_PASSWORD2)}\n")
with open(self._digest_pwfile2, 'w') as fd:
fd.write(f"{self.DIGEST_USER}:{self.REALM}:"
f"{ha1(self.DIGEST_USER, self.DIGEST_PASSWORD_VHOST2)}\n")

@property
def digest_pwfile(self) -> str:
return self._digest_pwfile

@property
def digest_pwfile2(self) -> str:
return self._digest_pwfile2

def configtest(self, directory_lines: List[str], extra_top_lines: Optional[List[str]] = None
) -> ExecResult:
"""Run `httpd -t` against a minimal, standalone config built from the
Expand Down
1 change: 1 addition & 0 deletions test/modules/aaa/htdocs/digest/nccheck-domain/secret.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
digest-nccheck-domain-secret
20 changes: 20 additions & 0 deletions test/modules/aaa/test_001_challenge_response.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,23 @@ def test_digest_015_uri_bad_escaping_rejected(self, env):
assert r.response["status"] == 400, \
f"uri={bad_uri!r} was not rejected"
env.httpd_error_log.ignore_recent(lognos=["AH01783"])

def test_digest_016_wrong_password_unknown_opaque_not_stale(self, env):
# A wrong password is wrong whatever opaque the request quotes. An
# opaque this server has no entry for (never issued, or garbage
# collected) used to force stale=true onto the challenge, so the
# client retried silently with the password it had just been told
# was wrong -- once -- before being re-prompted.
challenge = self.challenge(env, location="nccheck")
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, "not-the-password",
method="GET", uri="/digest/nccheck/secret.txt",
opaque="deadbeef")
r = env.curl_get(self.url(env, location="nccheck"),
options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert fresh.stale is False
# ...though the unknown opaque is still replaced by one of ours.
assert fresh.opaque not in (None, "deadbeef")
env.httpd_error_log.ignore_recent(lognos=["AH01794"])
8 changes: 5 additions & 3 deletions test/modules/aaa/test_003_nccheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ def test_digest_032_nccheck_replay_rejected(self, env):
r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
assert r2.response["status"] == 200

# replay an already-used nc -> rejected, and NOT reported as stale
# (this is a distinct failure mode from an invalid/expired nonce).
# replay an already-used nc -> rejected, and reported as stale: the
# digest verified, so the credentials are good, and a client which
# sent requests out of nonce-count order retries silently on the
# fresh nonce rather than being told its password was wrong.
r3 = self.authenticate(env, "nccheck", challenge, nc="00000001")
assert r3.response["status"] == 401
new_challenge = dc.DigestChallenge.parse(r3.response["header"]["www-authenticate"])
assert new_challenge.stale is False
assert new_challenge.stale is True
env.httpd_error_log.ignore_recent(lognos=["AH01774"])

# recovery: the rejected attempt handed out a fresh challenge for
Expand Down
6 changes: 3 additions & 3 deletions test/modules/aaa/test_007_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
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.
# A failed nc check is reported as stale (the digest verified, so the
# credentials are good): the client retries silently on the fresh nonce.
NC_FAILED = "AH01774"
NONCE_HASH_INVALID = "AH01776"
PASSWORD_MISMATCH = "AH01794"
Expand Down Expand Up @@ -156,7 +156,7 @@ def test_digest_074_legit_client_recovers_via_fresh_challenge(self, env):
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.stale is True
assert fresh.opaque == challenge.opaque, \
"the client keeps its identity across a re-challenge"
assert fresh.nonce != challenge.nonce
Expand Down
29 changes: 29 additions & 0 deletions test/modules/aaa/test_012_truncated_nonce.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,32 @@ def test_digest_012_01(self, env):
assert r2.response["status"] == 401

env.httpd_error_log.ignore_recent(lognos=["AH01782"])

def test_digest_012_02_time_not_base64(self, env):
# A nonce of the right length, ending in the '=' the time field
# must end in, whose time field is not base64 beyond its first
# four characters. The decoder stops there, so the time is only
# partly decoded; the nonce must be refused for that, before the
# partial time is used for anything.
url = env.mkurl("http", "aaa", "/digest/default/secret.txt")
r1 = env.curl_get(url)
assert r1.response["status"] == 401
challenge = dc.DigestChallenge.parse(
r1.response["header"]["www-authenticate"])
eq_pos = challenge.nonce.index("=")
bad_nonce = "AAAA" + "!" * (eq_pos - 4) + challenge.nonce[eq_pos:]
assert len(bad_nonce) == len(challenge.nonce)

auth_header = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/default/secret.txt",
nonce_val=bad_nonce)

r2 = env.curl_get(url, options=['-H', f'Authorization: {auth_header}'])
assert r2.response["status"] == 401
stale = dc.DigestChallenge.parse(r2.response["header"]["www-authenticate"])
assert stale.stale is True

not_base64 = re.compile(r".*invalid nonce .* - time is not base64")
assert env.httpd_error_log.scan_recent(not_base64)
env.httpd_error_log.ignore_recent(matches=[not_base64.pattern])
83 changes: 83 additions & 0 deletions test/modules/aaa/test_014_mixed_lifetime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""A realm with both a one-time-nonce location (AuthDigestNonceLifetime 0)
and a timed one, which nothing stops a configuration from having: there is
no directory merge for the directive, so every <Directory> starts from the
defaults.

The time field of a nonce is a timestamp at the timed location and a small
ordinal at the one-time one, and a one-time client records the last nonce
it used by that field. So a *timed* nonce, verified at the one-time
location, used to be recorded as the client's last ordinal -- some 1.7e15,
which the counter will never reach -- after which every genuine one-time
nonce that client was handed was "already used", and the client locked out
until its entry was garbage collected.

The nonce is bound to the opaque, so the timed nonce has to have been
minted for the one-time client's opaque; that just takes quoting the opaque
at the timed location, which issues one when it tracks clients
(AuthDigestNcCheck here). A browser does exactly this by itself: both
locations are one protection space, so it carries its current nonce and
opaque from one to the other. No attacker is needed to hit this, though
anybody with an account on the realm could do it to another client's
opaque.

A nonce now verifies only for the kind of lifetime it was minted under.
"""

from . import digest_client as dc
from .env import AAATestEnv

NONCE_HASH_INVALID = "AH01776"

ONETIME = "onetime"
TIMED = "nccheck"


class TestMixedLifetime:

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
return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])

def header(self, location, challenge):
return dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=f"/digest/{location}/secret.txt")

def send(self, env, location, auth):
r = env.curl_get(self.url(env, location),
options=["-H", f"Authorization: {auth}"])
return r

def test_digest_140_timed_nonce_does_not_poison_onetime_client(self, env):
# The client is established at the one-time location...
onetime = self.challenge(env, ONETIME)
assert onetime.opaque is not None

# ...and roams to the timed one with that nonce and opaque. The
# nonce is refused (it is not a timed nonce) and the challenge
# carries a timed nonce minted for the same opaque.
r = self.send(env, TIMED, self.header(TIMED, onetime))
assert r.response["status"] == 401
timed = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert timed.stale is True
assert timed.opaque == onetime.opaque

# Back at the one-time location with the timed nonce: this must
# not verify, since that would record its timestamp as the
# client's last one-time ordinal.
r = self.send(env, ONETIME, self.header(ONETIME, timed))
assert r.response["status"] == 401, \
"a timed nonce was accepted at the one-time location"
fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert fresh.stale is True
assert fresh.opaque == onetime.opaque

# The client follows the challenge and is not locked out.
r = self.send(env, ONETIME, self.header(ONETIME, fresh))
env.httpd_error_log.ignore_recent(lognos=[NONCE_HASH_INVALID])
assert r.response["status"] == 200, \
"the client's one-time nonce state was poisoned"
141 changes: 141 additions & 0 deletions test/modules/aaa/test_015_cross_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"""The per-client nonce state is updated only by requests which verify, so
nothing unauthenticated can move it (test_007, test_008). It is still keyed
by the opaque alone, and the opaque is public: in the clear in every
challenge and every request, and not covered by the response digest. So
anybody with an account on the realm can quote another client's opaque,
authenticate as themselves, and have that client's state updated with
their nonce-count or nonce.

For the victim this is a 401 on its next request -- a nonce-count that is
suddenly behind, or a one-time nonce that has suddenly been used -- with a
"possible replay attack" logged against it. Not a bypass, since the
attacker only ever authenticates as the account they hold, but a denial
the victim cannot defend against, and a false replay alarm.

An entry is now bound to the user who first used it, and a request from
anybody else quoting it is challenged afresh with an opaque of its own.

"User" here means within the origin server and realm: the same username
under the same realm on another server, with its own accounts, is somebody
else. The nonce is bound to the realm but not to the server, so it
verifies there. AuthDigestDomain is not part of it: a client keeps one
nonce and opaque per server and realm whatever domains it was given, so
it is the same user in every section of the realm, domain or no domain.
"""

from . import digest_client as dc
from .env import AAATestEnv

NC_FAILED = "AH01774"


class TestCrossClient:

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
return challenge

def header(self, location, challenge, user=AAATestEnv.DIGEST_USER,
password=AAATestEnv.DIGEST_PASSWORD, nc="00000001",
cnonce="victim-cnonce"):
return dc.build_authorization(
user, challenge, password, method="GET",
uri=f"/digest/{location}/secret.txt", nc=nc, cnonce=cnonce)

def intruder(self, location, challenge, nc="00000001"):
"""A correct Authorization header for the *other* account, quoting
the victim's nonce and opaque -- both of which it has seen."""
return self.header(location, challenge, user=AAATestEnv.DIGEST_USER2,
password=AAATestEnv.DIGEST_PASSWORD2, nc=nc,
cnonce="intruder-cnonce")

def send(self, env, location, auth):
return env.curl_get(self.url(env, location),
options=["-H", f"Authorization: {auth}"])

def test_digest_150_other_user_cannot_move_nonce_count(self, env):
location = "nccheck"
victim = self.challenge(env, location)
r = self.send(env, location, self.header(location, victim, nc="00000001"))
assert r.response["status"] == 200

# The other account quotes the victim's opaque with a nonce-count
# well ahead of the victim's. It is not this client's entry, so it
# is challenged with one of its own rather than served.
r = self.send(env, location, self.intruder(location, victim, nc="00000005"))
assert r.response["status"] == 401
intruder = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert intruder.stale is True
assert intruder.opaque != victim.opaque

# The victim's count was left alone.
r = self.send(env, location, self.header(location, victim, nc="00000002"))
assert r.response["status"] == 200, \
"another user's request moved the victim's nonce-count on"

def test_digest_151_other_user_cannot_use_up_onetime_nonce(self, env):
location = "onetime"
victim = self.challenge(env, location)
r = self.send(env, location, self.header(location, victim))
assert r.response["status"] == 200
ai = dc.parse_params(r.response["header"]["authentication-info"])
victim.nonce = ai["nextnonce"]

# The nextnonce went to the victim in the clear; the other account
# presents it, with the victim's opaque, before the victim does.
r = self.send(env, location, self.intruder(location, victim))
assert r.response["status"] == 401
intruder = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert intruder.stale is True
assert intruder.opaque != victim.opaque

# The victim's one-time nonce is still unused.
r = self.send(env, location, self.header(location, victim))
assert r.response["status"] == 200, \
"another user's request used up the victim's one-time nonce"

def test_digest_152_same_name_on_other_server_cannot_move_nonce_count(self, env):
location = "nccheck"
victim = self.challenge(env, location)
r = self.send(env, location, self.header(location, victim, nc="00000001"))
assert r.response["status"] == 200

# The second server has a "digestuser" of its own, with a different
# password, under the same realm. Its holder quotes the victim's
# nonce and opaque there.
url2 = f"http://aaa2.{env.http_tld}:{env.http_port2}/digest/{location}/secret.txt"
auth = self.header(location, victim, password=AAATestEnv.DIGEST_PASSWORD_VHOST2,
nc="00000005", cnonce="intruder-cnonce")
r = env.curl_get(url2, options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
intruder = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert intruder.stale is True
assert intruder.opaque != victim.opaque

r = self.send(env, location, self.header(location, victim, nc="00000002"))
assert r.response["status"] == 200, \
"a same-named user on another server moved the victim's nonce-count on"

def test_digest_153_same_user_across_domains_keeps_entry(self, env):
# A client established at a section without AuthDigestDomain
# carries its nonce and opaque to a section of the same realm which
# has one. It is the same user, so the entry is kept, not
# challenged as somebody else's; and it is kept on the way back.
client = self.challenge(env, "nccheck")
r = self.send(env, "nccheck", self.header("nccheck", client, nc="00000001"))
assert r.response["status"] == 200

r = self.send(env, "nccheck-domain",
self.header("nccheck-domain", client, nc="00000002"))
assert r.response["status"] == 200, \
"the same user was challenged as another on crossing into a domain"

r = self.send(env, "nccheck", self.header("nccheck", client, nc="00000003"))
assert r.response["status"] == 200, \
"the same user was challenged as another on crossing back"
Loading