]> git.ipfire.org Git - thirdparty/apache/httpd.git/commitdiff
test/modules/aaa: Add a pytest suite for mod_auth_digest.
authorJoe Orton <jorton@apache.org>
Thu, 13 Aug 2026 15:14:58 +0000 (15:14 +0000)
committerJoe Orton <jorton@apache.org>
Thu, 13 Aug 2026 15:14:58 +0000 (15:14 +0000)
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

17 files changed:
test/modules/aaa/conftest.py [new file with mode: 0644]
test/modules/aaa/digest_client.py [new file with mode: 0644]
test/modules/aaa/env.py [new file with mode: 0644]
test/modules/aaa/htdocs/digest/default/secret.txt [new file with mode: 0644]
test/modules/aaa/htdocs/digest/domain/nested/secret.txt [new file with mode: 0644]
test/modules/aaa/htdocs/digest/domain/secret.txt [new file with mode: 0644]
test/modules/aaa/htdocs/digest/nccheck/secret.txt [new file with mode: 0644]
test/modules/aaa/htdocs/digest/neverexpire/secret.txt [new file with mode: 0644]
test/modules/aaa/htdocs/digest/noprovider/secret.txt [new file with mode: 0644]
test/modules/aaa/htdocs/digest/onetime/secret.txt [new file with mode: 0644]
test/modules/aaa/htdocs/digest/shortlife/secret.txt [new file with mode: 0644]
test/modules/aaa/test_001_challenge_response.py [new file with mode: 0644]
test/modules/aaa/test_002_nonce.py [new file with mode: 0644]
test/modules/aaa/test_003_nccheck.py [new file with mode: 0644]
test/modules/aaa/test_004_domain.py [new file with mode: 0644]
test/modules/aaa/test_005_provider.py [new file with mode: 0644]
test/modules/aaa/test_006_config_errors.py [new file with mode: 0644]

diff --git a/test/modules/aaa/conftest.py b/test/modules/aaa/conftest.py
new file mode 100644 (file)
index 0000000..3e50e5a
--- /dev/null
@@ -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
diff --git a/test/modules/aaa/digest_client.py b/test/modules/aaa/digest_client.py
new file mode 100644 (file)
index 0000000..b0acf0f
--- /dev/null
@@ -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)
diff --git a/test/modules/aaa/env.py b/test/modules/aaa/env.py
new file mode 100644 (file)
index 0000000..0e8ed37
--- /dev/null
@@ -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])
diff --git a/test/modules/aaa/htdocs/digest/default/secret.txt b/test/modules/aaa/htdocs/digest/default/secret.txt
new file mode 100644 (file)
index 0000000..6135131
--- /dev/null
@@ -0,0 +1 @@
+digest-default-secret
diff --git a/test/modules/aaa/htdocs/digest/domain/nested/secret.txt b/test/modules/aaa/htdocs/digest/domain/nested/secret.txt
new file mode 100644 (file)
index 0000000..28140b2
--- /dev/null
@@ -0,0 +1 @@
+digest-domain-nested-secret
diff --git a/test/modules/aaa/htdocs/digest/domain/secret.txt b/test/modules/aaa/htdocs/digest/domain/secret.txt
new file mode 100644 (file)
index 0000000..1103f6e
--- /dev/null
@@ -0,0 +1 @@
+digest-domain-secret
diff --git a/test/modules/aaa/htdocs/digest/nccheck/secret.txt b/test/modules/aaa/htdocs/digest/nccheck/secret.txt
new file mode 100644 (file)
index 0000000..fe15209
--- /dev/null
@@ -0,0 +1 @@
+digest-nccheck-secret
diff --git a/test/modules/aaa/htdocs/digest/neverexpire/secret.txt b/test/modules/aaa/htdocs/digest/neverexpire/secret.txt
new file mode 100644 (file)
index 0000000..5375ef5
--- /dev/null
@@ -0,0 +1 @@
+digest-neverexpire-secret
diff --git a/test/modules/aaa/htdocs/digest/noprovider/secret.txt b/test/modules/aaa/htdocs/digest/noprovider/secret.txt
new file mode 100644 (file)
index 0000000..f9de590
--- /dev/null
@@ -0,0 +1 @@
+digest-noprovider-secret
diff --git a/test/modules/aaa/htdocs/digest/onetime/secret.txt b/test/modules/aaa/htdocs/digest/onetime/secret.txt
new file mode 100644 (file)
index 0000000..945bf8d
--- /dev/null
@@ -0,0 +1 @@
+digest-onetime-secret
diff --git a/test/modules/aaa/htdocs/digest/shortlife/secret.txt b/test/modules/aaa/htdocs/digest/shortlife/secret.txt
new file mode 100644 (file)
index 0000000..fe42277
--- /dev/null
@@ -0,0 +1 @@
+digest-shortlife-secret
diff --git a/test/modules/aaa/test_001_challenge_response.py b/test/modules/aaa/test_001_challenge_response.py
new file mode 100644 (file)
index 0000000..aa6ff12
--- /dev/null
@@ -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"])
diff --git a/test/modules/aaa/test_002_nonce.py b/test/modules/aaa/test_002_nonce.py
new file mode 100644 (file)
index 0000000..3c6079d
--- /dev/null
@@ -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"]
diff --git a/test/modules/aaa/test_003_nccheck.py b/test/modules/aaa/test_003_nccheck.py
new file mode 100644 (file)
index 0000000..f7e7520
--- /dev/null
@@ -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
diff --git a/test/modules/aaa/test_004_domain.py b/test/modules/aaa/test_004_domain.py
new file mode 100644 (file)
index 0000000..829d923
--- /dev/null
@@ -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"
diff --git a/test/modules/aaa/test_005_provider.py b/test/modules/aaa/test_005_provider.py
new file mode 100644 (file)
index 0000000..d7d3fbb
--- /dev/null
@@ -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
diff --git a/test/modules/aaa/test_006_config_errors.py b/test/modules/aaa/test_006_config_errors.py
new file mode 100644 (file)
index 0000000..e1284ab
--- /dev/null
@@ -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