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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 21 additions & 24 deletions .github/workflows/linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -219,56 +219,46 @@ jobs:
env: |
NOTEST_LIBS=-lubsan
TEST_UBSAN=1
TEST_PYTEST=1
# -------------------------------------------------------------------------
- name: ASan
notest-cflags: -ggdb -fsanitize=address -fno-sanitize-recover=address -fno-omit-frame-pointer
config: --enable-mods-shared=reallyall
pkgs: nghttp2-client
env: |
APR_VERSION=1.7.x
APU_VERSION=1.7.x
APU_CONFIG="--with-crypto --with-ldap"
TEST_ASAN=1
TEST_PYTEST=1
CLEAR_CACHE=1
# -------------------------------------------------------------------------
- name: ASan, pool-debug
notest-cflags: -ggdb -fsanitize=address -fno-sanitize-recover=address -fno-omit-frame-pointer
config: --enable-mods-shared=reallyall
pkgs: nghttp2-client
env: |
APR_VERSION=1.7.x
APR_CONFIG="--enable-pool-debug"
APU_VERSION=1.7.x
APU_CONFIG="--with-crypto --with-ldap"
TEST_ASAN=1
TEST_PYTEST=1
CLEAR_CACHE=1
# -------------------------------------------------------------------------
- name: HTTP/2 test suite
# Runs every pytest-based test suite (pytest_suite/ + all
# test/modules/*/ pyhttpd suites except modules/md, which needs a
# local ACME/pebble server that isn't available here) via `make
# check-all-pytest`. See TEST_PYTEST in test/travis_run_linux.sh.
- name: Python pytest test suites
config: --enable-mods-shared=reallyall --with-mpm=event --enable-mpms-shared=all
pkgs: curl python3-pytest nghttp2-client python3-cryptography python3-requests python3-multipart python3-filelock python3-websockets
pkgs: nghttp2-client
env: |
APR_VERSION=1.7.6
APU_VERSION=1.6.3
APU_CONFIG="--with-crypto"
NO_TEST_FRAMEWORK=1
TEST_INSTALL=1
TEST_H2=1
TEST_CORE=1
TEST_PROXY=1
# -------------------------------------------------------------------------
### TODO: if: *condition_not_24x
### TODO: pebble install is broken.
# - name: ACME test suite
# config: --enable-mods-shared=reallyall --with-mpm=event --enable-mpms-shared=event
# pkgs: >-
# python3-pytest nghttp2-client python3-cryptography python3-requests python3-filelock
# golang-1.17 curl
# env: |
# APR_VERSION=1.7.6
# APU_VERSION=1.6.3
# APU_CONFIG="--with-crypto"
# GOROOT=/usr/lib/go-1.17
# NO_TEST_FRAMEWORK=1
# TEST_INSTALL=1
# TEST_MD=1
TEST_PYTEST=1
# -------------------------------------------------------------------------
### TODO: if: *condition_not_24x
- name: Configured w/reduced exports
Expand Down Expand Up @@ -365,9 +355,9 @@ jobs:
- name: Install prerequisites
run: sudo apt-get install -o Acquire::Retries=5
cpanminus libtool-bin libapr1-dev libaprutil1-dev
liblua5.3-dev libbrotli-dev libcurl4-openssl-dev
liblua5.3-dev libbrotli-dev libcurl4-openssl-dev
libnghttp2-dev libjansson-dev libpcre2-dev gdb
perl-doc libsasl2-dev ${{ matrix.pkgs }} check
perl-doc libsasl2-dev curl pipx ${{ matrix.pkgs }} check
- uses: actions/checkout@v6
- uses: actions/checkout@v6
with:
Expand All @@ -394,6 +384,11 @@ jobs:
name: config.log-${{ env.JOBID }}
path: |
/home/runner/build/**/config.log
- name: Install uv
if: env.TEST_PYTEST == '1'
run: |
pipx install uv
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Build and test
run: ./test/travis_run_linux.sh
- uses: actions/upload-artifact@v7
Expand All @@ -403,3 +398,5 @@ jobs:
path: |
**/config.log
test/perl-framework/t/logs/error_log
test/pytest_suite/t/logs/error_log
test/gen/apache/logs/error_log
Empty file added test/modules/aaa/__init__.py
Empty file.
87 changes: 87 additions & 0 deletions test/modules/aaa/conftest.py
Original file line number Diff line number Diff line change
@@ -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
134 changes: 134 additions & 0 deletions test/modules/aaa/digest_client.py
Original file line number Diff line number Diff line change
@@ -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)
Loading