1
0
mirror of https://github.com/apache/httpd.git synced 2026-10-23 22:49:01 +03:00

test/modules/aaa: Add a pytest suite for mod_auth_digest.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
GitHub: PR #705


git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937103 13f79535-47bb-0310-9956-ffa450edef68
This commit is contained in:
Joe Orton
2026-08-13 15:14:58 +00:00
parent e5052c87ee
commit 32850221df
17 changed files with 895 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
import logging
import os
import sys
import pytest
from .env import AAATestEnv
from pyhttpd.conf import HttpdConf
sys.path.append(os.path.join(os.path.dirname(__file__), '../..'))
def pytest_report_header(config, start_path):
env = AAATestEnv()
return f"mod_auth_digest [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]"
def _digest_dir(docs, path, extra_lines):
lines = [
f'<Directory "{docs}/digest/{path}">',
' AuthType Digest',
f' AuthName "{AAATestEnv.REALM}"',
]
lines.extend(f" {l}" for l in extra_lines)
lines.append(' Require valid-user')
lines.append('</Directory>')
return lines
@pytest.fixture(scope="package")
def env(pytestconfig) -> AAATestEnv:
level = logging.INFO
console = logging.StreamHandler()
console.setLevel(level)
console.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
logging.getLogger('').addHandler(console)
logging.getLogger('').setLevel(level=level)
env = AAATestEnv(pytestconfig=pytestconfig)
env.setup_httpd()
env.apache_access_log_clear()
env.httpd_error_log.clear_log()
docs = env.server_docs_dir
pwfile = env.digest_pwfile
conf = HttpdConf(env)
conf.add(_digest_dir(docs, "default", [
'AuthDigestProvider file',
f'AuthUserFile "{pwfile}"',
]))
conf.add(_digest_dir(docs, "nccheck", [
'AuthDigestProvider file',
f'AuthUserFile "{pwfile}"',
'AuthDigestNcCheck On',
]))
conf.add(_digest_dir(docs, "shortlife", [
'AuthDigestProvider file',
f'AuthUserFile "{pwfile}"',
'AuthDigestNonceLifetime 2',
]))
conf.add(_digest_dir(docs, "neverexpire", [
'AuthDigestProvider file',
f'AuthUserFile "{pwfile}"',
'AuthDigestNonceLifetime -1',
]))
conf.add(_digest_dir(docs, "onetime", [
'AuthDigestProvider file',
f'AuthUserFile "{pwfile}"',
'AuthDigestNonceLifetime 0',
]))
conf.add(_digest_dir(docs, "domain", [
'AuthDigestProvider file',
f'AuthUserFile "{pwfile}"',
'AuthDigestDomain "/digest/domain/" "https://mirror.example.org/other/"',
]))
conf.add(_digest_dir(docs, "noprovider", [
# AuthDigestProvider intentionally omitted: falls back to "file".
f'AuthUserFile "{pwfile}"',
]))
conf.install()
assert env.apache_restart() == 0
return env
@pytest.fixture(autouse=True, scope="package")
def _stop_package_scope(env):
yield
assert env.apache_stop() == 0

View File

@@ -0,0 +1,134 @@
"""Minimal hand-rolled RFC 2617 Digest auth client.
curl's own `--digest` handles the challenge/response handshake transparently,
which is no good for testing edge cases (tampered nonces, replayed
nonce-counts, wrong realms, bad algorithm tokens, ...). This module lets
tests parse a WWW-Authenticate challenge, compute the expected response by
hand, and build a (possibly deliberately broken) Authorization header.
mod_auth_digest here only implements qop="auth" (see modules/aaa/mod_auth_digest.c
Open Issues: "MD5-sess and auth-int are not yet implemented"), so this client
only implements the qop=auth request-digest/response-auth formulas from
RFC 2617 section 3.2.2.
"""
import hashlib
import re
from dataclasses import dataclass
from typing import Dict, List, Optional
_PARAM_RE = re.compile(r'(\w+)=(?:"([^"]*)"|([^\s,]+))\s*,?\s*')
def _md5hex(s: str) -> str:
return hashlib.md5(s.encode('utf-8')).hexdigest()
def parse_params(value: str) -> Dict[str, str]:
"""Parse a comma-separated key=value / key="value" list, as used by
both WWW-Authenticate and Authentication-Info header values."""
params = {}
for m in _PARAM_RE.finditer(value):
key = m.group(1)
val = m.group(2) if m.group(2) is not None else m.group(3)
params[key.lower()] = val
return params
@dataclass
class DigestChallenge:
realm: Optional[str]
nonce: Optional[str]
algorithm: Optional[str] = None
opaque: Optional[str] = None
domain: Optional[str] = None
qop: Optional[str] = None
stale: bool = False
raw: str = ""
@staticmethod
def parse(www_authenticate: str) -> 'DigestChallenge':
assert www_authenticate.startswith("Digest "), \
f"not a Digest challenge: {www_authenticate}"
params = parse_params(www_authenticate[len("Digest "):])
return DigestChallenge(
realm=params.get('realm'),
nonce=params.get('nonce'),
algorithm=params.get('algorithm'),
opaque=params.get('opaque'),
domain=params.get('domain'),
qop=params.get('qop'),
stale=params.get('stale', '').lower() == 'true',
raw=www_authenticate,
)
def domain_list(self) -> List[str]:
return self.domain.split() if self.domain else []
def ha1(username: str, realm: str, password: str) -> str:
return _md5hex(f"{username}:{realm}:{password}")
def ha2(method: str, uri: str) -> str:
return _md5hex(f"{method}:{uri}")
def request_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
qop: str, ha2_hex: str) -> str:
return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
def rspauth_digest(ha1_hex: str, nonce: str, nc: str, cnonce: str,
qop: str, uri: str) -> str:
"""Authentication-Info's rspauth uses A2 = ':' + uri (no method)."""
ha2_hex = _md5hex(f":{uri}")
return _md5hex(f"{ha1_hex}:{nonce}:{nc}:{cnonce}:{qop}:{ha2_hex}")
def build_authorization(username: str, challenge: DigestChallenge, password: str,
method: str, uri: str, nc: str = "00000001",
cnonce: str = "0a4f113b3c2e7a1d", qop: Optional[str] = "auth",
realm: Optional[str] = None, nonce_val: Optional[str] = None,
algorithm: Optional[str] = None, response: Optional[str] = None,
opaque: Optional[str] = None, include_opaque: bool = True,
include_qop_fields: bool = True, extra: Optional[List[str]] = None
) -> str:
"""Build a Digest Authorization header value.
By default this builds a *correct* response for the given challenge and
credentials. Any of realm=/nonce_val=/algorithm=/response=/opaque= can be
overridden to construct deliberately invalid headers, and qop=None with
include_qop_fields=False builds a legacy RFC 2069-style header (no qop,
cnonce, or nc) to prove that path is rejected.
"""
eff_realm = challenge.realm if realm is None else realm
eff_nonce = challenge.nonce if nonce_val is None else nonce_val
if response is None:
h1 = ha1(username, eff_realm, password)
h2 = ha2(method, uri)
if qop:
response = request_digest(h1, eff_nonce, nc, cnonce, qop, h2)
else:
# legacy RFC 2069: MD5(HA1:nonce:HA2), no qop/cnonce/nc
response = _md5hex(f"{h1}:{eff_nonce}:{h2}")
parts = [
f'username="{username}"',
f'realm="{eff_realm}"',
f'nonce="{eff_nonce}"',
f'uri="{uri}"',
f'response="{response}"',
]
if algorithm is not None:
parts.append(f'algorithm={algorithm}')
if qop and include_qop_fields:
parts.append(f'qop={qop}')
parts.append(f'nc={nc}')
parts.append(f'cnonce="{cnonce}"')
eff_opaque = challenge.opaque if (opaque is None and include_opaque) else opaque
if eff_opaque:
parts.append(f'opaque="{eff_opaque}"')
if extra:
parts.extend(extra)
return "Digest " + ", ".join(parts)

79
test/modules/aaa/env.py Normal file
View File

@@ -0,0 +1,79 @@
import hashlib
import inspect
import logging
import os
from typing import List, Optional
from pyhttpd.env import HttpdTestEnv, HttpdTestSetup
from pyhttpd.result import ExecResult
log = logging.getLogger(__name__)
class AAATestSetup(HttpdTestSetup):
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"])
class AAATestEnv(HttpdTestEnv):
REALM = "AAA Digest Realm"
DIGEST_USER = "digestuser"
DIGEST_PASSWORD = "digestpass2617"
DIGEST_USER2 = "otheruser"
DIGEST_PASSWORD2 = "otherpass2617"
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")
def setup_httpd(self, setup: HttpdTestSetup = None):
super().setup_httpd(setup=AAATestSetup(env=self))
self._write_digest_pwfile()
def _write_digest_pwfile(self):
def ha1(user, password):
return hashlib.md5(
f"{user}:{self.REALM}:{password}".encode()).hexdigest()
with open(self._digest_pwfile, 'w') as fd:
fd.write(f"{self.DIGEST_USER}:{self.REALM}:"
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")
@property
def digest_pwfile(self) -> str:
return self._digest_pwfile
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
already-generated modules.conf plus `directory_lines` wrapped in a
<Directory> block over the shared docroot. Used to test directives
that are rejected at config-check time (e.g. AuthDigestQop values
other than 'auth') without touching the package's running server.
"""
conf_path = os.path.join(self.gen_dir, "digest-configtest.conf")
modules_conf = os.path.join(self.server_conf_dir, "modules.conf")
lines = [
f'ServerRoot "{self.server_dir}"',
f'Include "{modules_conf}"',
f'DocumentRoot "{self.server_docs_dir}"',
f'Listen {self.http_port2}',
]
if extra_top_lines:
lines.extend(extra_top_lines)
lines.append(f'<Directory "{self.server_docs_dir}">')
lines.extend(f" {l}" for l in directory_lines)
lines.append('</Directory>')
with open(conf_path, 'w') as fd:
fd.write('\n'.join(lines))
fd.write('\n')
httpd_bin = os.path.join(self.bin_dir, 'httpd')
return self.run([httpd_bin, '-t', '-f', conf_path])

View File

@@ -0,0 +1 @@
digest-default-secret

View File

@@ -0,0 +1 @@
digest-domain-nested-secret

View File

@@ -0,0 +1 @@
digest-domain-secret

View File

@@ -0,0 +1 @@
digest-nccheck-secret

View File

@@ -0,0 +1 @@
digest-neverexpire-secret

View File

@@ -0,0 +1 @@
digest-noprovider-secret

View File

@@ -0,0 +1 @@
digest-onetime-secret

View File

@@ -0,0 +1 @@
digest-shortlife-secret

View File

@@ -0,0 +1,180 @@
"""RFC 2617 Digest challenge/response scenarios against mod_auth_digest's
default configuration (AuthDigestProvider file, AuthDigestQop auth (the only
supported value), AuthDigestNonceLifetime 300, no AuthDigestDomain).
"""
from . import digest_client as dc
from .env import AAATestEnv
class TestDigestChallengeResponse:
def url(self, env, path="secret.txt", location="default"):
return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
def challenge(self, env, location="default"):
r = env.curl_get(self.url(env, location=location))
assert r.response["status"] == 401
return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
def test_digest_001_no_credentials(self, env):
# No Authorization header at all -> 401 with a well-formed challenge.
r = env.curl_get(self.url(env))
assert r.response["status"] == 401
auth = r.response["header"]["www-authenticate"]
challenge = dc.DigestChallenge.parse(auth)
assert challenge.realm == AAATestEnv.REALM
assert challenge.algorithm == "MD5"
assert challenge.qop == "auth"
assert challenge.stale is False
# no AuthDigestDomain configured for this Location -> no domain=
assert challenge.domain is None
# nonce-count checking is off and lifetime isn't 0 here, so the
# server has no reason to track this client -> no opaque=
assert challenge.opaque is None
def test_digest_002_success(self, env):
challenge = self.challenge(env)
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/default/secret.txt")
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 200
assert r.response["body"].decode() == "digest-default-secret\n"
def test_digest_003_rspauth(self, env):
# Authentication-Info's rspauth= must match what we independently
# compute from the same HA1 -- proves the server round-trips the
# session parameters (nonce/nc/cnonce/qop) correctly.
challenge = self.challenge(env)
nc = "00000001"
cnonce = "test-cnonce-rspauth"
uri = "/digest/default/secret.txt"
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=uri, nc=nc, cnonce=cnonce)
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 200
ai = dc.parse_params(r.response["header"]["authentication-info"])
h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
expected = dc.rspauth_digest(h1, challenge.nonce, nc, cnonce, "auth", uri)
assert ai["rspauth"] == expected
assert ai["qop"] == "auth"
assert ai["nc"] == nc
assert ai["cnonce"] == cnonce
def test_digest_004_wrong_password(self, env):
challenge = self.challenge(env)
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, "not-the-password",
method="GET", uri="/digest/default/secret.txt")
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_005_unknown_user(self, env):
challenge = self.challenge(env)
auth = dc.build_authorization(
"no-such-user", challenge, "whatever",
method="GET", uri="/digest/default/secret.txt")
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
env.httpd_error_log.ignore_recent(lognos=["AH01790"])
def test_digest_006_second_user(self, env):
# a distinct user in the same password file also works
challenge = self.challenge(env)
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER2, challenge, AAATestEnv.DIGEST_PASSWORD2,
method="GET", uri="/digest/default/secret.txt")
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 200
def test_digest_007_wrong_realm(self, env):
challenge = self.challenge(env)
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/default/secret.txt",
realm="Some Other Realm")
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
env.httpd_error_log.ignore_recent(lognos=["AH01788"])
def test_digest_008_bad_algorithm_token(self, env):
# a client claiming an algorithm other than MD5 is rejected outright,
# even though the response hash below is computed correctly for MD5.
challenge = self.challenge(env)
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/default/secret.txt",
algorithm="MD5-sess")
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
env.httpd_error_log.ignore_recent(lognos=["AH01789"])
def test_digest_009_legacy_no_qop_rejected(self, env):
# RFC 2069-style digest (no qop/cnonce/nc) is syntactically valid but
# explicitly no longer supported by this module.
challenge = self.challenge(env)
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/default/secret.txt",
qop=None, include_qop_fields=False)
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
env.httpd_error_log.ignore_recent(lognos=["AH10560"])
def test_digest_010_malformed_header_missing_field(self, env):
# missing "uri" entirely -> header is syntactically INVALID, so the
# server issues a fresh (non-stale) challenge rather than evaluating
# the (nonexistent) response hash.
challenge = self.challenge(env)
h1 = dc.ha1(AAATestEnv.DIGEST_USER, challenge.realm, AAATestEnv.DIGEST_PASSWORD)
auth = ('Digest username="digestuser", '
f'realm="{challenge.realm}", nonce="{challenge.nonce}", '
f'response="{h1}", qop=auth, nc=00000001, cnonce="x"')
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 401
new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert new_challenge.stale is False
env.httpd_error_log.ignore_recent(lognos=["AH01782"])
def test_digest_011_wrong_scheme(self, env):
r = env.curl_get(self.url(env), options=[
"-H", "Authorization: Basic ZGlnZXN0dXNlcjpkaWdlc3RwYXNz"])
assert r.response["status"] == 401
env.httpd_error_log.ignore_recent(lognos=["AH01781"])
def test_digest_012_uri_mismatch(self, env):
# The Authorization uri= must match the actual request-target; a
# self-consistent response computed for a *different* uri than the
# one actually requested is rejected as a bad request, before the
# hash is even checked.
challenge = self.challenge(env)
other_uri = "/digest/default/other-secret.txt"
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=other_uri)
r = env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 400
env.httpd_error_log.ignore_recent(lognos=["AH01786"])
def test_digest_013_invalid_opaque(self, env):
challenge = self.challenge(env)
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/default/secret.txt",
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"])
def test_digest_014_tampered_response_hash(self, env):
challenge = self.challenge(env)
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/default/secret.txt",
response="0" * 32)
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"])

View File

@@ -0,0 +1,129 @@
"""Nonce lifecycle scenarios: tampered nonces, AuthDigestNonceLifetime
expiry/reissue, a never-expiring nonce, and the one-time-nonce
(AuthDigestNonceLifetime 0) case.
"""
import time
from . import digest_client as dc
from .env import AAATestEnv
class TestDigestNonce:
def url(self, env, location, path="secret.txt"):
return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
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 authenticate(self, env, location, challenge, nc="00000001",
cnonce="nonce-test-cnonce", uri=None):
uri = uri or f"/digest/{location}/secret.txt"
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=uri, nc=nc, cnonce=cnonce)
return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
def test_digest_020_tampered_nonce_is_stale(self, env):
challenge = self.challenge(env, "default")
# flip a character in the middle of the opaque nonce blob: it stays
# the right length but its embedded hash no longer verifies.
bad = list(challenge.nonce)
mid = len(bad) // 2
bad[mid] = 'x' if bad[mid] != 'x' else 'y'
challenge.nonce = ''.join(bad)
r = self.authenticate(env, "default", challenge)
assert r.response["status"] == 401
new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert new_challenge.stale is True
env.httpd_error_log.ignore_recent(lognos=["AH01776"])
def test_digest_021_garbage_nonce_hash_is_stale(self, env):
# A nonce must still look like "b64(time)+sha1hex(hash)" (VALID_NONCE
# in mod_auth_digest.c checks length and the '=' padding boundary) to
# even be considered for a hash check; something that doesn't match
# that shape is instead rejected as a malformed header (see
# test_digest_010). Here we keep the genuine time-prefix (so the
# shape is valid) but replace the whole hash suffix with garbage, to
# hit check_nonce()'s "hash is not %s" path distinctly from
# test_digest_020's single-flipped-character tamper.
challenge = self.challenge(env, "default")
time_prefix = challenge.nonce[:-40]
challenge.nonce = time_prefix + ("f" * 40)
r = self.authenticate(env, "default", challenge)
assert r.response["status"] == 401
new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert new_challenge.stale is True
env.httpd_error_log.ignore_recent(lognos=["AH01776"])
def test_digest_022_short_lifetime_expires(self, env):
# AuthDigestNonceLifetime 2 for this location.
challenge = self.challenge(env, "shortlife")
r = self.authenticate(env, "shortlife", challenge)
assert r.response["status"] == 200
time.sleep(3)
# same nonce, now past its lifetime -> 401 stale=true
r = self.authenticate(env, "shortlife", challenge)
assert r.response["status"] == 401
stale_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert stale_challenge.stale is True
env.httpd_error_log.ignore_recent(lognos=["AH01776"])
# the fresh nonce from the stale challenge works again
r = self.authenticate(env, "shortlife", stale_challenge)
assert r.response["status"] == 200
def test_digest_023_never_expiring_nonce(self, env):
# AuthDigestNonceLifetime -1 for this location: no NcCheck is
# configured, so the identical Authorization line can simply be
# replayed after a delay and must still succeed both times.
challenge = self.challenge(env, "neverexpire")
r1 = self.authenticate(env, "neverexpire", challenge)
assert r1.response["status"] == 200
time.sleep(3)
r2 = self.authenticate(env, "neverexpire", challenge)
assert r2.response["status"] == 200
def test_digest_024_one_time_nonce_rejects_reuse(self, env):
# AuthDigestNonceLifetime 0: a successful request immediately
# supersedes its nonce (the tracked "last_nonce" moves on to the
# nextnonce from Authentication-Info), so replaying the very same
# nonce right afterwards must fail as stale. Each request against
# this client (success OR failure) advances the tracked nonce again,
# so this test does exactly one success followed by exactly one
# reuse -- no longer chain that would need to account for that.
challenge = self.challenge(env, "onetime")
assert challenge.opaque is not None, \
"one-time-nonce tracking requires an opaque to identify the client"
r1 = self.authenticate(env, "onetime", challenge)
assert r1.response["status"] == 200
ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
assert "nextnonce" in ai1
assert ai1["nextnonce"] != challenge.nonce
# reusing the exact same (now superseded) nonce fails as stale
r2 = self.authenticate(env, "onetime", challenge)
assert r2.response["status"] == 401
stale_challenge = dc.DigestChallenge.parse(r2.response["header"]["www-authenticate"])
assert stale_challenge.stale is True
env.httpd_error_log.ignore_recent(lognos=["AH01776"])
def test_digest_025_one_time_nonce_chain_continues(self, env):
# Following the nextnonce handed out on a successful response lets
# the client keep authenticating, one hop at a time.
challenge = self.challenge(env, "onetime")
r1 = self.authenticate(env, "onetime", challenge)
assert r1.response["status"] == 200
ai1 = dc.parse_params(r1.response["header"]["authentication-info"])
challenge.nonce = ai1["nextnonce"]
r2 = self.authenticate(env, "onetime", challenge)
assert r2.response["status"] == 200
ai2 = dc.parse_params(r2.response["header"]["authentication-info"])
assert ai2["nextnonce"] != ai1["nextnonce"]

View File

@@ -0,0 +1,99 @@
"""AuthDigestNcCheck replay-detection scenarios.
Note the actual semantics here are stricter than a sliding replay window:
the server keeps its own count of authenticated requests seen for a client
(incremented on *every* request carrying that client's opaque, whether or
not it goes on to authenticate) and requires the client's nc to match it
*exactly* -- so both replays of an old nc and skipping ahead are rejected.
A failed nc check also resets the server's tracked count back to 0, as part
of issuing a fresh challenge for the client (see note_digest_auth_failure()
in mod_auth_digest.c: an existing, opaque-identified client always gets its
nonce_count reset when a new challenge is generated for it, regardless of
*why* the challenge is being reissued) -- so recovery after a rejected nc
means starting the sequence over at 00000001, not continuing where the
client left off.
"""
from . import digest_client as dc
from .env import AAATestEnv
class TestDigestNcCheck:
def url(self, env, location, path="secret.txt"):
return env.mkurl("http", "aaa", f"/digest/{location}/{path}")
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 authenticate(self, env, location, challenge, nc, cnonce="ncc-test-cnonce",
include_opaque=True):
uri = f"/digest/{location}/secret.txt"
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=uri, nc=nc, cnonce=cnonce,
include_opaque=include_opaque)
return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"])
def test_digest_030_nccheck_requires_opaque(self, env):
# with AuthDigestNcCheck on, the server cannot verify nc without
# having tracked this client via its opaque -- omitting the opaque
# therefore fails the check outright, even with nc=00000001.
challenge = self.challenge(env, "nccheck")
assert challenge.opaque is not None
r = self.authenticate(env, "nccheck", challenge, nc="00000001", include_opaque=False)
assert r.response["status"] == 401
new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert new_challenge.stale is False
def test_digest_031_nccheck_sequential_ok(self, env):
challenge = self.challenge(env, "nccheck")
r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
assert r1.response["status"] == 200
r2 = self.authenticate(env, "nccheck", challenge, nc="00000002")
assert r2.response["status"] == 200
r3 = self.authenticate(env, "nccheck", challenge, nc="00000003")
assert r3.response["status"] == 200
def test_digest_032_nccheck_replay_rejected(self, env):
challenge = self.challenge(env, "nccheck")
r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
assert r1.response["status"] == 200
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).
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
env.httpd_error_log.ignore_recent(lognos=["AH01774"])
# the rejected attempt reset the server's tracked count to 0 (a new
# challenge was issued for this client), so recovery restarts the
# sequence at 00000001 -- continuing from 00000003 would NOT work.
r4 = self.authenticate(env, "nccheck", challenge, nc="00000001")
assert r4.response["status"] == 200
def test_digest_033_nccheck_skip_ahead_rejected(self, env):
challenge = self.challenge(env, "nccheck")
r1 = self.authenticate(env, "nccheck", challenge, nc="00000001")
assert r1.response["status"] == 200
# skipping ahead is rejected too: nc must match exactly, not just
# be higher than what was last accepted.
r2 = self.authenticate(env, "nccheck", challenge, nc="00000009")
assert r2.response["status"] == 401
env.httpd_error_log.ignore_recent(lognos=["AH01774"])
def test_digest_034_no_nccheck_allows_replay(self, env):
# the "default" location has no AuthDigestNcCheck (Off by default),
# so replaying the exact same nc is not detected or rejected.
challenge = self.challenge(env, "default")
r1 = self.authenticate(env, "default", challenge, nc="00000001")
assert r1.response["status"] == 200
r2 = self.authenticate(env, "default", challenge, nc="00000001")
assert r2.response["status"] == 200

View File

@@ -0,0 +1,56 @@
"""AuthDigestDomain: presence, format, and inheritance of the domain=
attribute in the WWW-Authenticate challenge.
"""
from . import digest_client as dc
from .env import AAATestEnv
class TestDigestDomain:
def url(self, env, path):
return env.mkurl("http", "aaa", path)
def test_digest_040_domain_attribute_present(self, env):
r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
assert r.response["status"] == 401
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
# set_uri_list() (mod_auth_digest.c) builds a single quoted,
# space-separated list from the configured AuthDigestDomain URIs.
assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
assert challenge.domain_list() == [
"/digest/domain/", "https://mirror.example.org/other/"]
def test_digest_041_no_domain_configured_omits_attribute(self, env):
r = env.curl_get(self.url(env, "/digest/default/secret.txt"))
assert r.response["status"] == 401
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert challenge.domain is None
def test_digest_042_domain_location_still_authenticates(self, env):
r = env.curl_get(self.url(env, "/digest/domain/secret.txt"))
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/domain/secret.txt")
r = env.curl_get(self.url(env, "/digest/domain/secret.txt"),
options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 200
assert r.response["body"].decode() == "digest-domain-secret\n"
def test_digest_043_domain_inherited_by_nested_path(self, env):
# AuthDigestDomain is set on /digest/domain/; a path nested below it
# inherits the same directory config (same realm/credentials/domain).
r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"))
assert r.response["status"] == 401
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert challenge.realm == AAATestEnv.REALM
assert challenge.domain == "/digest/domain/ https://mirror.example.org/other/"
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri="/digest/domain/nested/secret.txt")
r = env.curl_get(self.url(env, "/digest/domain/nested/secret.txt"),
options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 200
assert r.response["body"].decode() == "digest-domain-nested-secret\n"

View File

@@ -0,0 +1,37 @@
"""AuthDigestProvider scenarios."""
from . import digest_client as dc
from .env import AAATestEnv
class TestDigestProvider:
def url(self, env, path):
return env.mkurl("http", "aaa", path)
def test_digest_050_omitted_provider_defaults_to_file(self, env):
# /digest/noprovider/ has no AuthDigestProvider directive at all;
# mod_auth_digest falls back to the "file" provider (mod_authn_file)
# by default (see get_hash() / AUTHN_DEFAULT_PROVIDER in mod_auth.h).
path = "/digest/noprovider/secret.txt"
r = env.curl_get(self.url(env, path))
assert r.response["status"] == 401
challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
auth = dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=path)
r = env.curl_get(self.url(env, path), options=["-H", f"Authorization: {auth}"])
assert r.response["status"] == 200
assert r.response["body"].decode() == "digest-noprovider-secret\n"
def test_digest_051_unknown_provider_rejected_at_config_time(self, env):
r = env.configtest([
'AuthType Digest',
f'AuthName "{AAATestEnv.REALM}"',
'AuthDigestProvider no-such-provider',
f'AuthUserFile "{env.digest_pwfile}"',
'Require valid-user',
])
assert r.exit_code != 0
assert "Unknown Authn provider" in r.stderr

View File

@@ -0,0 +1,86 @@
"""Config-time validation for directives whose *documented* syntax (see
docs/manual/mod/mod_auth_digest.xml) is broader than what this build's
mod_auth_digest.c actually implements: AuthDigestQop only accepts "auth"
(qop=none/auth-int are rejected -- the "Open Issues" comment in the source
notes MD5-sess and auth-int were removed as incomplete), AuthDigestAlgorithm
only accepts "MD5", and AuthDigestShmemSize enforces a minimum size. These
are all checked with `httpd -t` against a throwaway config so the shared
package server is never disturbed.
"""
from .env import AAATestEnv
class TestDigestConfigErrors:
def test_digest_060_qop_none_rejected(self, env):
r = env.configtest([
'AuthType Digest',
f'AuthName "{AAATestEnv.REALM}"',
'AuthDigestProvider file',
f'AuthUserFile "{env.digest_pwfile}"',
'AuthDigestQop none',
'Require valid-user',
])
assert r.exit_code != 0
assert "AuthDigestQop" in r.stderr
def test_digest_061_qop_auth_int_rejected(self, env):
r = env.configtest([
'AuthType Digest',
f'AuthName "{AAATestEnv.REALM}"',
'AuthDigestProvider file',
f'AuthUserFile "{env.digest_pwfile}"',
'AuthDigestQop auth-int',
'Require valid-user',
])
assert r.exit_code != 0
assert "AuthDigestQop" in r.stderr
def test_digest_062_qop_auth_accepted(self, env):
# the only value actually supported must still work.
r = env.configtest([
'AuthType Digest',
f'AuthName "{AAATestEnv.REALM}"',
'AuthDigestProvider file',
f'AuthUserFile "{env.digest_pwfile}"',
'AuthDigestQop auth',
'Require valid-user',
])
assert r.exit_code == 0
def test_digest_063_algorithm_md5_sess_rejected(self, env):
r = env.configtest([
'AuthType Digest',
f'AuthName "{AAATestEnv.REALM}"',
'AuthDigestProvider file',
f'AuthUserFile "{env.digest_pwfile}"',
'AuthDigestAlgorithm MD5-sess',
'Require valid-user',
])
assert r.exit_code != 0
assert "Unsupported algorithm" in r.stderr
def test_digest_064_algorithm_md5_accepted(self, env):
r = env.configtest([
'AuthType Digest',
f'AuthName "{AAATestEnv.REALM}"',
'AuthDigestProvider file',
f'AuthUserFile "{env.digest_pwfile}"',
'AuthDigestAlgorithm MD5',
'Require valid-user',
])
assert r.exit_code == 0
def test_digest_065_shmemsize_too_small_rejected(self, env):
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 10"])
assert r.exit_code != 0
assert "AuthDigestShmemSize" in r.stderr
def test_digest_066_shmemsize_valid_accepted(self, env):
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 1000"])
assert r.exit_code == 0
def test_digest_067_shmemsize_units_accepted(self, env):
r = env.configtest([], extra_top_lines=["AuthDigestShmemSize 64K"])
assert r.exit_code == 0