From: Joe Orton Date: Thu, 13 Aug 2026 15:14:58 +0000 (+0000) Subject: test/modules/aaa: Add a pytest suite for mod_auth_digest. X-Git-Url: http://git.ipfire.org/gitweb/index.cgi?a=commitdiff_plain;h=32850221dfe0a7ce9dc24148055ada21ef3e373c;p=thirdparty%2Fapache%2Fhttpd.git test/modules/aaa: Add a pytest suite for mod_auth_digest. Assisted-by: Claude Sonnet 5 GitHub: PR #705 git-svn-id: https://svn.apache.org/repos/asf/httpd/httpd/trunk@1937103 13f79535-47bb-0310-9956-ffa450edef68 --- diff --git a/test/modules/aaa/conftest.py b/test/modules/aaa/conftest.py new file mode 100644 index 0000000000..3e50e5a2d0 --- /dev/null +++ b/test/modules/aaa/conftest.py @@ -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'', + ' AuthType Digest', + f' AuthName "{AAATestEnv.REALM}"', + ] + lines.extend(f" {l}" for l in extra_lines) + lines.append(' Require valid-user') + lines.append('') + 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 index 0000000000..b0acf0fc8a --- /dev/null +++ b/test/modules/aaa/digest_client.py @@ -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 index 0000000000..0e8ed377e9 --- /dev/null +++ b/test/modules/aaa/env.py @@ -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 + 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'') + lines.extend(f" {l}" for l in directory_lines) + lines.append('') + 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 index 0000000000..6135131adf --- /dev/null +++ b/test/modules/aaa/htdocs/digest/default/secret.txt @@ -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 index 0000000000..28140b2a18 --- /dev/null +++ b/test/modules/aaa/htdocs/digest/domain/nested/secret.txt @@ -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 index 0000000000..1103f6e9a0 --- /dev/null +++ b/test/modules/aaa/htdocs/digest/domain/secret.txt @@ -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 index 0000000000..fe15209e01 --- /dev/null +++ b/test/modules/aaa/htdocs/digest/nccheck/secret.txt @@ -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 index 0000000000..5375ef5f8d --- /dev/null +++ b/test/modules/aaa/htdocs/digest/neverexpire/secret.txt @@ -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 index 0000000000..f9de590a30 --- /dev/null +++ b/test/modules/aaa/htdocs/digest/noprovider/secret.txt @@ -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 index 0000000000..945bf8d92d --- /dev/null +++ b/test/modules/aaa/htdocs/digest/onetime/secret.txt @@ -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 index 0000000000..fe422776b3 --- /dev/null +++ b/test/modules/aaa/htdocs/digest/shortlife/secret.txt @@ -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 index 0000000000..aa6ff1217b --- /dev/null +++ b/test/modules/aaa/test_001_challenge_response.py @@ -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 index 0000000000..3c6079def4 --- /dev/null +++ b/test/modules/aaa/test_002_nonce.py @@ -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 index 0000000000..f7e7520bc0 --- /dev/null +++ b/test/modules/aaa/test_003_nccheck.py @@ -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 index 0000000000..829d923552 --- /dev/null +++ b/test/modules/aaa/test_004_domain.py @@ -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 index 0000000000..d7d3fbb85a --- /dev/null +++ b/test/modules/aaa/test_005_provider.py @@ -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 index 0000000000..e1284abfdf --- /dev/null +++ b/test/modules/aaa/test_006_config_errors.py @@ -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