diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000000..4493f699c0e --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,11 @@ +# Gitleaks scanner config to prevent false positives +# see https://github.com/gitleaks/gitleaks +[allowlist] +description = "Global Allowlist" + +# Ignore based on any subset of the file path +paths = [ +# Ignore all authentication tests which do contain +# embedded test passwords. +'''test\/modules\/aaa\/*\.py''', +] diff --git a/changes-entries/digest-shmem-size.txt b/changes-entries/digest-shmem-size.txt new file mode 100644 index 00000000000..fbd28a1649f --- /dev/null +++ b/changes-entries/digest-shmem-size.txt @@ -0,0 +1,3 @@ + *) mod_auth_digest: Increase the default AuthDigestShmemSize to 8192 + bytes, tracking around 140 clients rather than around 12. + [Joe Orton] diff --git a/docs/manual/mod/mod_auth_digest.xml b/docs/manual/mod/mod_auth_digest.xml index 392f7c8174a..f89e4171013 100644 --- a/docs/manual/mod/mod_auth_digest.xml +++ b/docs/manual/mod/mod_auth_digest.xml @@ -240,7 +240,7 @@ authentication The amount of shared memory to allocate for keeping track of clients AuthDigestShmemSize size -AuthDigestShmemSize 1000 +AuthDigestShmemSize 8192 server config @@ -254,6 +254,13 @@ of clients 0 and read the error message after trying to start the server.

+

The default holds roughly 140 clients. A client which is discarded + to make room for another is not denied access: it is issued a new + nonce with stale=true, which costs it one extra request. + Note that a request which does not authenticate also takes an entry, + since the challenge sent back to it carries the identifier the client + is tracked by.

+

The size is normally expressed in Bytes, but you may follow the number with a K or an M to express your value as KBytes or MBytes. For example, the following diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c index 0267a83be0d..fac9251fb92 100644 --- a/modules/aaa/mod_auth_digest.c +++ b/modules/aaa/mod_auth_digest.c @@ -21,34 +21,30 @@ * Updated to RFC-2617 by Ronald Tschalär * based on mod_auth, by Rob McCool and Robert S. Thau * - * This module an updated version of modules/standard/mod_digest.c - * It is still fairly new and problems may turn up - submit problem - * reports to the Apache bug-database, or send them directly to me - * at ronald@innovation.ch. - * * Open Issues: - * - qop=auth-int (when streams and trailer support available) - * - nonce-format configurability + * - MD5-sess and auth-int are not implemented; auth-int needs stream and + * trailer support. An incomplete implementation has been removed and + * can be retrieved from svn history. * - Proxy-Authentication-Info header is set by this module, but is * currently ignored by mod_proxy (needs patch to mod_proxy) * - The source of the secret should be run-time directive (with server - * scope: RSRC_CONF) - * - shared-mem not completely tested yet. Seems to work ok for me, - * but... (definitely won't work on Windoze) - * - Sharing a realm among multiple servers has following problems: - * o Server name and port can't be included in nonce-hash - * (we need two nonce formats, which must be configured explicitly) - * o Nonce-count check can't be for equal, or then nonce-count checking - * must be disabled. What we could do is the following: - * (expected < received) ? set expected = received : issue error - * The only problem is that it allows replay attacks when somebody - * captures a packet sent to one server and sends it to another - * one. Should we add "AuthDigestNcCheck Strict"? - * - expired nonces give amaya fits. - * - MD5-sess and auth-int are not yet implemented. An incomplete - * implementation has been removed and can be retrieved from svn history. + * scope: RSRC_CONF). Each server generates its own, so a nonce issued + * by one does not verify at another, and a client moving between them + * is re-challenged every time. + * - Sharing a realm among multiple servers needs more than that secret, + * though. It would be enough for a configuration which tracks no + * per-client state, but the client table, the ids which key it and the + * one-time nonce counter are all per-server, so under AuthDigestNcCheck + * or AuthDigestNonceLifetime 0 a client would still be unknown to + * whichever server it reached next. + * - Sharing the secret would also make a request captured against one + * server replayable against the others, and the nonce-count check + * would not stop that, since each server counts separately. Hashing + * the server name and port into the nonce would, but that is the + * opposite of sharing: the two would have to be configured explicitly. */ +#include "apu_version.h" #include "apr_sha1.h" #include "apr_base64.h" #include "apr_lib.h" @@ -87,6 +83,21 @@ #error mod_auth_digest requires APR with random and shared memory support #endif +/* The nonce is authenticated with a keyed hash, so that a client cannot + * forge one. apr_siphash() is a MAC, and is what that calls for; it is + * available in APU 1.6 / APR 2.0 and later. Older APR falls back to the + * original SHA-1 of the secret followed by the message, which is not a + * sound construction - a Merkle-Damgard hash keyed by a prefix can be + * extended without the key - but which is not attackable here, since the + * padding such an extension needs cannot survive the opaque being parsed + * as a hex number before the nonce is checked. */ +#if APU_MAJOR_VERSION > 1 || (APU_MAJOR_VERSION == 1 && APU_MINOR_VERSION >= 6) +#define DIGEST_SIPHASH_NONCE 1 +#include "apr_siphash.h" +#else +#define DIGEST_SIPHASH_NONCE 0 +#endif + /* struct to hold the configuration info */ typedef struct digest_config_struct { @@ -104,9 +115,16 @@ typedef struct digest_config_struct { #define NEXTNONCE_DELTA apr_time_from_sec(30) /* The server nonce has fixed length and is the concatenation of: - * base64(apr_time_t timestamp) + hex(SHA1(realm+time[+opaque])) */ + * base64(apr_time_t timestamp) + hex(keyed hash of realm+time[+opaque]) + * The hash is half as long with siphash, which produces 64 bits: enough for + * a value which can only be attacked by presenting it to the server, there + * being no way to test a candidate offline. */ #define NONCE_TIME_LEN (((sizeof(apr_time_t)+2)/3)*4) +#if DIGEST_SIPHASH_NONCE +#define NONCE_HASH_LEN (2*APR_SIPHASH_DSIZE) +#else #define NONCE_HASH_LEN (2*APR_SHA1_DIGESTSIZE) +#endif #define NONCE_LEN (int )(NONCE_TIME_LEN + NONCE_HASH_LEN) /* Evaluates to true if nonce string is valid. Since the time part of * the nonce is a base64 encoding of an apr_time_t (8 bytes), it @@ -118,6 +136,10 @@ typedef struct digest_config_struct { #define SECRET_LEN 20 #define RETAINED_DATA_ID "mod_auth_digest" +#if DIGEST_SIPHASH_NONCE && SECRET_LEN < APR_SIPHASH_KSIZE +#error the secret is too short to key siphash +#endif + /* client list definitions */ @@ -202,9 +224,14 @@ static apr_global_mutex_t *client_lock = NULL; static const char *client_mutex_type = "authdigest-client"; static const char *client_shm_filename; -#define DEF_SHMEM_SIZE 1000L /* ~ 12 entries */ -#define DEF_NUM_BUCKETS 15L +#define DEF_SHMEM_SIZE 8192L /* ~ 140 entries */ #define HASH_DEPTH 5 +/* Buckets for a given segment size, so that the default and + * AuthDigestShmemSize cannot disagree. */ +#define NUM_BUCKETS(size_) (((size_) - sizeof(*client_list)) / \ + (sizeof(client_entry *) \ + + HASH_DEPTH * sizeof(client_entry))) +#define DEF_NUM_BUCKETS NUM_BUCKETS(DEF_SHMEM_SIZE) static apr_size_t shmem_size = DEF_SHMEM_SIZE; static unsigned long num_buckets = DEF_NUM_BUCKETS; @@ -274,6 +301,7 @@ static int initialize_tables(server_rec *s, apr_pool_t *ctx) { unsigned long idx; apr_status_t sts; + client_id_t seed; /* set up client list */ @@ -348,7 +376,15 @@ static int initialize_tables(server_rec *s, apr_pool_t *ctx) log_error_and_cleanup("failed to allocate shared memory", -1, s); return !OK; } - *client_id_counter = 1; + /* Start the ids at a random point rather than at 1. This segment does + * not survive a restart, but the nonces naming its entries do, since the + * secret they are hashed with is retained; ids restarting from 1 too + * would hand a returning client's id straight back out, so that client + * would be checked against whichever new client now held it. The ids are + * not secret - they are sent in the clear as the opaque - this only has + * to make them distinct across a restart. */ + ap_random_insecure_bytes(&seed, sizeof seed); + *client_id_counter = seed; /* setup one-time-nonce counter */ @@ -399,15 +435,16 @@ static int initialize_module(apr_pool_t *p, apr_pool_t *plog, if (ap_state_query(AP_SQ_MAIN_STATE) == AP_SQ_MS_CREATE_PRE_CONFIG) return OK; - /* Note: this stuff is currently fixed for the lifetime of the server, - * i.e. even across restarts. This means that A) any shmem-size - * configuration changes are ignored, and B) certain optimizations, - * such as only allocating the smallest necessary entry for each - * client, can't be done. However, the alternative is a nightmare: - * we can't call apr_shm_destroy on a graceful restart because there - * will be children using the tables, and we also don't know when the - * last child dies. Therefore we can never clean up the old stuff, - * creating a creeping memory leak. + /* The client table belongs to one configuration generation: it is + * allocated from pconf, which the restart loop clears - destroying the + * segment - before running this hook again to create a new one. A + * child of the previous generation keeps the mapping it inherited + * until it exits, so the tables are not pulled out from under it. + * + * Per-client state therefore does not survive a restart, and neither + * does the client id space; see the seeding in initialize_tables(). + * A client which returns afterwards is simply unknown, and is + * re-challenged with stale=true at the cost of one extra request. */ return initialize_tables(s, p); } @@ -578,8 +615,7 @@ static const char *set_shmem_size(cmd_parms *cmd, void *config, } shmem_size = size; - num_buckets = (size - sizeof(*client_list)) / - (sizeof(client_entry*) + HASH_DEPTH * sizeof(client_entry)); + num_buckets = NUM_BUCKETS(size); if (num_buckets == 0) { num_buckets = 1; } @@ -815,33 +851,35 @@ static enum nonce_state client_update_nonce(const request_rec *r, */ static unsigned long gc(server_rec *s) { - client_entry *entry, *prev; unsigned long num_removed = 0, idx; - /* garbage collect all last entries */ + /* garbage collect one entry from each bucket */ for (idx = 0; idx < client_list->tbl_len; idx++) { - entry = client_list->table[idx]; - prev = NULL; - - if (!entry) { - /* This bucket is empty. */ - continue; + client_entry **link, **victim = NULL; + int unused = 0; + + /* The last entry is the least recently used, since find_client() + * moves an entry to the front on each access; but prefer a client + * which has never completed an authentication, whose entry records + * nothing and so costs it nothing to lose. Every request which + * fails to authenticate allocates one of those, and without this + * they would evict the clients which are using the server. */ + for (link = &client_list->table[idx]; *link; link = &(*link)->next) { + if ((*link)->last_nonce_time == 0) { + victim = link; + unused = 1; + } + else if (!unused) { + victim = link; + } } - while (entry->next) { /* find last entry */ - prev = entry; - entry = entry->next; - } - if (prev) { - prev->next = NULL; /* cut list */ - } - else { - client_list->table[idx] = NULL; - } - if (entry) { /* remove entry */ + if (victim) { + client_entry *entry = *victim; apr_status_t err; + *victim = entry->next; err = rmm_free(client_rmm, entry); num_removed++; @@ -1082,11 +1120,31 @@ static int init_digest_request(request_rec *r) /* Writes the hash part of the server nonce to hash, which must be of * minimum size (NONCE_HASH_LEN+1). */ -static void gen_nonce_hash(char hash[NONCE_HASH_LEN+1], const char *timestr, const char *opaque, +static void gen_nonce_hash(apr_pool_t *p, char hash[NONCE_HASH_LEN+1], + const char *timestr, const char *opaque, const server_rec *server, - const digest_config_rec *conf, + const digest_config_rec *conf, const char *realm) { +#if DIGEST_SIPHASH_NONCE + unsigned char mac[APR_SIPHASH_DSIZE]; + const char *msg; + + /* siphash takes the whole message at once, having no streaming + * interface, so the fields are joined here rather than fed in one at a + * time. The realm is length-prefixed, which keeps the boundaries + * between the fields unambiguous whatever they contain: without it a + * realm ending in what another realm's timestamp begins with would + * hash the same. An absent opaque is the empty string, which is what + * the challenge side passes for it too. */ + msg = apr_psprintf(p, "%" APR_SIZE_T_FMT ":%s:%s:%s", + (apr_size_t)strlen(realm), realm, timestr, + opaque ? opaque : ""); + + apr_siphash24_auth(mac, msg, strlen(msg), secret); + + ap_bin2hex(mac, APR_SIPHASH_DSIZE, hash); +#else unsigned char sha1[APR_SHA1_DIGESTSIZE]; apr_sha1_ctx_t ctx; @@ -1102,6 +1160,7 @@ static void gen_nonce_hash(char hash[NONCE_HASH_LEN+1], const char *timestr, con apr_sha1_final(sha1, &ctx); ap_bin2hex(sha1, APR_SHA1_DIGESTSIZE, hash); +#endif } @@ -1125,7 +1184,7 @@ static const char *gen_nonce(apr_pool_t *p, apr_time_t now, const char *opaque, t.time = apr_atomic_inc32(otn_counter) + 1; } apr_base64_encode_binary(nonce, t.arr, sizeof(t.arr)); - gen_nonce_hash(nonce+NONCE_TIME_LEN, nonce, opaque, server, conf, realm); + gen_nonce_hash(p, nonce+NONCE_TIME_LEN, nonce, opaque, server, conf, realm); return nonce; } @@ -1183,11 +1242,8 @@ static int note_digest_auth_failure(request_rec *r, const digest_config_rec *conf, digest_header_rec *resp, int stale) { - const char *qop, *opaque = NULL, *opaque_param = "", *domain, *nonce; - client_id_t client_key = 0; - - /* Setup qop */ - qop = ", qop=\"auth\""; + const char *opaque = NULL, *opaque_param = "", *domain, *nonce; + client_id_t client_key; /* Setup opaque */ @@ -1199,10 +1255,15 @@ static int note_digest_auth_failure(request_rec *r, } opaque = ltox(r->pool, client_key); } - /* else no opaque is needed, and none is sent */ + /* else this configuration tracks no per-client state, so no entry + * is allocated and no opaque is sent */ } else if (!client_exists(resp->opaque_num, r)) { - /* client info was gc'd */ + /* We have no record of this client: its entry may have been + * garbage collected, the segment may have been recreated by a + * restart, or the opaque may never have been issued by us. + * Nothing was wrong with the credentials, so the challenge is + * stale and an RFC-compliant client retries silently. */ if ((client_key = client_generate(r)) == 0) { return HTTP_SERVICE_UNAVAILABLE; } @@ -1215,7 +1276,6 @@ static int note_digest_auth_failure(request_rec *r, * here: the client may not even see this challenge (it may have * been triggered by somebody else quoting its opaque), and it is * tied to the nonce it was counted for in any case. */ - client_key = resp->opaque_num; opaque = resp->opaque; } @@ -1227,16 +1287,9 @@ static int note_digest_auth_failure(request_rec *r, nonce = gen_nonce(r->pool, r->request_time, opaque, r->server, conf, ap_auth_name(r)); - /* setup domain attribute. We want to send this attribute wherever - * possible so that the client won't send the Authorization header - * unnecessarily (it's usually > 200 bytes!). - */ - - - /* don't send domain - * - for proxy requests - * - if it's not specified - */ + /* Setup domain, which tells the client which URIs share this + * protection space, so that it does not send the Authorization header + * (usually more than 200 bytes) where it is not needed. */ if (r->proxyreq || !conf->uri_list) { domain = NULL; } @@ -1248,11 +1301,12 @@ static int note_digest_auth_failure(request_rec *r, (PROXYREQ_PROXY == r->proxyreq) ? "Proxy-Authenticate" : "WWW-Authenticate", apr_psprintf(r->pool, "Digest realm=\"%s\", " - "nonce=\"%s\", algorithm=%s%s%s%s%s", + "nonce=\"%s\", algorithm=%s%s%s%s" + ", qop=\"auth\"", ap_auth_name(r), nonce, conf->algorithm, opaque_param, domain ? domain : "", - stale ? ", stale=true" : "", qop)); + stale ? ", stale=true" : "")); return HTTP_UNAUTHORIZED; } @@ -1409,7 +1463,8 @@ static int check_nonce(request_rec *r, digest_header_rec *resp, tmp = resp->nonce[NONCE_TIME_LEN]; resp->nonce[NONCE_TIME_LEN] = '\0'; apr_base64_decode_binary(nonce_time.arr, resp->nonce); - gen_nonce_hash(hash, resp->nonce, resp->opaque, r->server, conf, ap_auth_name(r)); + gen_nonce_hash(r->pool, hash, resp->nonce, resp->opaque, r->server, conf, + ap_auth_name(r)); resp->nonce[NONCE_TIME_LEN] = tmp; resp->nonce_time = nonce_time.time; diff --git a/test/modules/aaa/__init__.py b/test/modules/aaa/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/test/modules/aaa/__init__.py @@ -0,0 +1 @@ + diff --git a/test/modules/aaa/conftest.py b/test/modules/aaa/conftest.py new file mode 100644 index 00000000000..1ea2aba7966 --- /dev/null +++ b/test/modules/aaa/conftest.py @@ -0,0 +1,105 @@ +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) + # Pin the client table to its historical size, ~12 entries, rather than + # the current default of ~140: the tests which need an entry to be + # garbage collected (085) drive that by filling the table with bare + # requests, and a table an order of magnitude larger makes them an order + # of magnitude slower for nothing. + conf.add('AuthDigestShmemSize 1000') + 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, "nccheck-shortlife", [ + 'AuthDigestProvider file', + f'AuthUserFile "{pwfile}"', + 'AuthDigestNcCheck On', + 'AuthDigestNonceLifetime 2', + ])) + 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, "onetime-nccheck", [ + 'AuthDigestProvider file', + f'AuthUserFile "{pwfile}"', + 'AuthDigestNonceLifetime 0', + 'AuthDigestNcCheck On', + ])) + 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 00000000000..b0acf0fc8ad --- /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 00000000000..4fe47580f32 --- /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 = f"{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 00000000000..6135131adf6 --- /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 00000000000..28140b2a187 --- /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 00000000000..1103f6e9a0c --- /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-shortlife/secret.txt b/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt new file mode 100644 index 00000000000..fe15209e018 --- /dev/null +++ b/test/modules/aaa/htdocs/digest/nccheck-shortlife/secret.txt @@ -0,0 +1 @@ +digest-nccheck-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 00000000000..fe15209e018 --- /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 00000000000..5375ef5f8d2 --- /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 00000000000..f9de590a307 --- /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-nccheck/secret.txt b/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt new file mode 100644 index 00000000000..945bf8d92d3 --- /dev/null +++ b/test/modules/aaa/htdocs/digest/onetime-nccheck/secret.txt @@ -0,0 +1 @@ +digest-onetime-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 00000000000..945bf8d92d3 --- /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 00000000000..fe422776b36 --- /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 00000000000..aa6ff1217b2 --- /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 00000000000..5d176f33bcd --- /dev/null +++ b/test/modules/aaa/test_002_nonce.py @@ -0,0 +1,134 @@ +"""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)+hex(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. + # + # The hash length depends on which keyed hash the module was built + # with, so take it from the nonce rather than assuming: the time is + # base64 of 8 bytes and so ends with the only '=' in the string. + challenge = self.challenge(env, "default") + time_prefix = challenge.nonce[:challenge.nonce.index('=') + 1] + hash_len = len(challenge.nonce) - len(time_prefix) + challenge.nonce = time_prefix + ("f" * hash_len) + 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 00000000000..539c182b66c --- /dev/null +++ b/test/modules/aaa/test_003_nccheck.py @@ -0,0 +1,143 @@ +"""AuthDigestNcCheck replay-detection scenarios. + +The semantics are those of RFC 7616 3.4.3: the nonce-count is counted by +the client per-nonce, so the server tracks a count per (client, nonce) pair +and requires it to strictly increase. Within one nonce, an nc which has +already been seen is a replay and is rejected; a *higher* nc than expected +is not, since the client also counts the requests it sends to URIs in the +protection space which turn out not to need authentication, and the server +never sees those. Moving to a newer nonce starts a fresh count, and a nonce +the client has already moved on from is rejected. + +The tracked count is only ever updated for a fully verified request, so a +failed request cannot disturb the count of the client whose opaque it +quotes; test_007_replay.py covers that property directly. +""" + +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, even with nc=00000001. It is rejected before the + # nc check is even reached: the nonce hash is computed over the + # opaque (gen_nonce_hash()), so a nonce quoted without the opaque it + # was issued with does not verify, and that is reported as stale. + 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 True + env.httpd_error_log.ignore_recent(lognos=["AH01776"]) + + 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"]) + + # recovery: the rejected attempt handed out a fresh challenge for + # this client, and following it -- new nonce, so the count starts + # over at 00000001 -- authenticates again. + r4 = self.authenticate(env, "nccheck", new_challenge, nc="00000001") + assert r4.response["status"] == 200 + + # the superseded nonce is not usable any more, at any nc. + r5 = self.authenticate(env, "nccheck", challenge, nc="00000003") + assert r5.response["status"] == 401 + env.httpd_error_log.ignore_recent(lognos=["AH01774"]) + + def test_digest_033_nccheck_skip_ahead_allowed(self, env): + challenge = self.challenge(env, "nccheck") + r1 = self.authenticate(env, "nccheck", challenge, nc="00000001") + assert r1.response["status"] == 200 + + # skipping ahead is allowed: nc only has to be higher than the + # highest already seen for this nonce, not exactly one more. A + # client legitimately produces gaps by sending counted requests to + # URIs in the protection space which don't need authentication, and + # a higher nc is not a replay in any case. + r2 = self.authenticate(env, "nccheck", challenge, nc="00000009") + assert r2.response["status"] == 200 + + # ...and the skipped-over counts are spent: they are no longer + # accepted afterwards. + r3 = self.authenticate(env, "nccheck", challenge, nc="00000005") + assert r3.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 + + def test_digest_035_out_of_range_opaque_is_not_truncated(self, env): + # The opaque is a 32-bit client id. A value which would truncate onto + # a live id must not select that client. This is observable in the + # challenge which comes back: a client the server still knows is + # re-challenged with its own opaque, whereas an unknown one is given a + # freshly minted opaque and stale=true. + challenge = self.challenge(env, "nccheck") + assert self.authenticate(env, "nccheck", challenge, + nc="00000001").response["status"] == 200 + + # 2^32 + the live id, which truncates to the live id in 32 bits + crafted = "%x" % ((1 << 32) + int(challenge.opaque, 16)) + auth = dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri="/digest/nccheck/secret.txt", nc="00000002", + cnonce="trunc-cnonce", opaque=crafted) + r = env.curl_get(env.mkurl("http", "aaa", "/digest/nccheck/secret.txt"), + options=["-H", f"Authorization: {auth}"]) + # AH01787 with the range check in place; AH01776 (nonce hash) if the + # opaque were truncated onto the live client instead + env.httpd_error_log.ignore_recent(lognos=["AH01787", "AH01776"]) + assert r.response["status"] == 401 + new_challenge = dc.DigestChallenge.parse( + r.response["header"]["www-authenticate"]) + assert new_challenge.opaque != crafted, \ + "an out-of-range opaque was truncated onto a live client id" diff --git a/test/modules/aaa/test_004_domain.py b/test/modules/aaa/test_004_domain.py new file mode 100644 index 00000000000..829d923552f --- /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 00000000000..d7d3fbb85ad --- /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 00000000000..e1284abfdf0 --- /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 diff --git a/test/modules/aaa/test_007_replay.py b/test/modules/aaa/test_007_replay.py new file mode 100644 index 00000000000..ba61ed9eaaa --- /dev/null +++ b/test/modules/aaa/test_007_replay.py @@ -0,0 +1,237 @@ +"""Replay-attack scenarios against AuthDigestNcCheck. + +AuthDigestNcCheck exists to detect replayed requests: the server tracks the +highest nonce-count it has accepted from a client (identified by its opaque) +for the nonce that client is using, and requires each request to raise it. + +The security property under test here is not just "the replayed request is +rejected", but that rejecting it must not damage the legitimate client: + + With nonce-count checking enabled, a replay attack MUST NOT affect the + original (legitimate) client by resetting its nonce count. + +It used to. On a failed authentication mod_auth_digest issues a fresh +challenge via note_digest_auth_failure(), and for an already-known +(opaque-identified) client that path reset client->nonce_count to 0, while +the post_read_request hook re-incremented the count from 0 on the next +request carrying that opaque. An attacker who could make *any* request fail +for the victim's opaque therefore rewound the victim's counter, with two +consequences: + + * the legitimate client's next in-sequence nc no longer matched, so it + was locked out (denial of service against the victim), and + * the attacker's replayed request lined up with the rewound counter and + was accepted -- 200, 401, 200, 401, ... for one captured header, or + every time if the attacker rewound the counter deliberately first. + +The count is now tracked per (client, nonce) and updated only for a request +which has been fully verified, so a request which fails to authenticate +leaves the victim's state untouched. +""" + +import time + +from . import digest_client as dc +from .env import AAATestEnv + +# See the note in test_003_nccheck.py: a failed nc check is not reported as +# stale, since it is a distinct failure mode from an invalid/expired nonce. +NC_FAILED = "AH01774" +NONCE_HASH_INVALID = "AH01776" +PASSWORD_MISMATCH = "AH01794" + + +class TestDigestReplay: + + LOCATION = "nccheck" + + def url(self, env, path="secret.txt"): + return env.mkurl("http", "aaa", f"/digest/{self.LOCATION}/{path}") + + @property + def uri(self): + return f"/digest/{self.LOCATION}/secret.txt" + + def challenge(self, env): + r = env.curl_get(self.url(env)) + assert r.response["status"] == 401 + return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + + def victim_header(self, challenge, nc, cnonce="victim-cnonce"): + """A correct Authorization header from the legitimate client.""" + return dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri=self.uri, nc=nc, cnonce=cnonce) + + def attacker_header(self, challenge, nc="00000001", cnonce="attacker-cnonce"): + """A well-formed Digest header carrying the victim's opaque and nonce + but a bogus response digest. An attacker who has merely *seen* one of + the victim's requests can build this; no credentials are needed.""" + return dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, "not-the-password", + method="GET", uri=self.uri, nc=nc, cnonce=cnonce, + response="0" * 32) + + def send(self, env, auth): + return env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"]) + + def test_digest_070_replay_does_not_lock_out_legit_client(self, env): + # The legitimate client authenticates a few times, in sequence. + challenge = self.challenge(env) + for nc in ["00000001", "00000002", "00000003"]: + assert self.send(env, self.victim_header(challenge, nc)).response["status"] == 200 + + # An attacker replays a request captured earlier in that sequence. + # Rejecting it is correct... + replayed = self.victim_header(challenge, "00000002") + replay_status = self.send(env, replayed).response["status"] + + # ...but it must not disturb the legitimate client, which knows + # nothing of the replay and simply carries on with its next nc. + r = self.send(env, self.victim_header(challenge, "00000004")) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED]) + assert replay_status == 401 + assert r.response["status"] == 200, \ + "the replay reset the victim's nonce-count and locked it out" + + def test_digest_071_bogus_request_does_not_lock_out_legit_client(self, env): + # Same property, but the attacker does not even need to have captured + # a complete valid request: any well-formed Digest header quoting the + # victim's opaque is enough to rewind the victim's counter. + challenge = self.challenge(env) + for nc in ["00000001", "00000002"]: + assert self.send(env, self.victim_header(challenge, nc)).response["status"] == 200 + + bogus_status = self.send(env, self.attacker_header(challenge)).response["status"] + + r = self.send(env, self.victim_header(challenge, "00000003")) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, PASSWORD_MISMATCH]) + assert bogus_status == 401 + assert r.response["status"] == 200, \ + "a bogus request reset the victim's nonce-count and locked it out" + + def test_digest_072_captured_request_is_never_accepted_twice(self, env): + # The flip side of the same defect. One captured Authorization header + # is replayed verbatim; the first send is the genuine request, so it + # succeeds, and every later send must be rejected. Before the fix the + # rejection rewound the counter, so the replay after it lined up + # again: the observed pattern was 200, 401, 200, 401, ... + challenge = self.challenge(env) + captured = self.victim_header(challenge, "00000001", cnonce="captured-cnonce") + + assert self.send(env, captured).response["status"] == 200 + statuses = [self.send(env, captured).response["status"] for _ in range(4)] + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED]) + assert statuses == [401, 401, 401, 401], \ + f"replayed request was accepted again: {statuses}" + + def test_digest_073_attacker_cannot_force_replay_to_succeed(self, env): + # Severity check: the attacker must not be able to line the counter + # up on demand. Before the fix, sending a bogus request first rewound + # the counter to 0, so the replay that followed succeeded every + # single time. + challenge = self.challenge(env) + captured = self.victim_header(challenge, "00000001", cnonce="captured-cnonce") + assert self.send(env, captured).response["status"] == 200 + + statuses = [] + for _ in range(3): + self.send(env, self.attacker_header(challenge)) + statuses.append(self.send(env, captured).response["status"]) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, PASSWORD_MISMATCH]) + assert statuses == [401, 401, 401], \ + f"attacker replayed at will by forcing a counter reset: {statuses}" + + def test_digest_074_legit_client_recovers_via_fresh_challenge(self, env): + # Invariant: a client whose nc is rejected is handed a fresh + # challenge, and following that challenge -- new nonce, so the count + # starts over at 1 -- gets it working again. Simply never resetting + # the count, without tying it to the nonce it was counted for, would + # break this. + challenge = self.challenge(env) + assert self.send(env, self.victim_header(challenge, "00000001")).response["status"] == 200 + + # provoke the rejection with a replay of that first request + r = self.send(env, self.victim_header(challenge, "00000001")) + assert r.response["status"] == 401 + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED]) + fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + assert fresh.stale is False + assert fresh.opaque == challenge.opaque, \ + "the client keeps its identity across a re-challenge" + assert fresh.nonce != challenge.nonce + + r = self.send(env, self.victim_header(fresh, "00000001")) + assert r.response["status"] == 200 + + def test_digest_075_nonce_is_bound_to_opaque(self, env): + # A captured header cannot be re-pointed at a *different* client + # session to dodge that session's nonce-count: the nonce hash is + # computed over the opaque (gen_nonce_hash()), so quoting one + # client's nonce under another client's opaque fails the hash check + # outright, and is reported as stale. + victim = self.challenge(env) + captured = self.victim_header(victim, "00000001", cnonce="captured-cnonce") + assert self.send(env, captured).response["status"] == 200 + + attacker = self.challenge(env) + assert attacker.opaque != victim.opaque + spliced = dc.build_authorization( + AAATestEnv.DIGEST_USER, victim, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri=self.uri, nc="00000001", cnonce="captured-cnonce", + opaque=attacker.opaque) + r = self.send(env, spliced) + env.httpd_error_log.ignore_recent(lognos=[NONCE_HASH_INVALID]) + assert r.response["status"] == 401 + assert dc.DigestChallenge.parse( + r.response["header"]["www-authenticate"]).stale is True + + +class TestDigestNcCheckExpiry: + """AuthDigestNcCheck combined with an expiring nonce. + + The nonce is checked before the nonce-count, so that an expired nonce + still produces a "stale=true" challenge rather than being reported as a + replay -- the client then retries silently against the fresh nonce, with + its count restarted at 1. + """ + + LOCATION = "nccheck-shortlife" # AuthDigestNcCheck On, lifetime 2s + + def url(self, env): + return env.mkurl("http", "aaa", f"/digest/{self.LOCATION}/secret.txt") + + def challenge(self, env): + r = env.curl_get(self.url(env)) + assert r.response["status"] == 401 + return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + + def send(self, env, challenge, nc): + auth = dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri=f"/digest/{self.LOCATION}/secret.txt", nc=nc, + cnonce="expiry-cnonce") + return env.curl_get(self.url(env), options=["-H", f"Authorization: {auth}"]) + + def test_digest_076_expired_nonce_restarts_the_count(self, env): + challenge = self.challenge(env) + assert self.send(env, challenge, "00000001").response["status"] == 200 + assert self.send(env, challenge, "00000002").response["status"] == 200 + + time.sleep(3) + + # past its lifetime: reported as stale, not as a nonce-count failure + r = self.send(env, challenge, "00000003") + assert r.response["status"] == 401 + fresh = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + assert fresh.stale is True + + # the client restarts its count for the fresh nonce, which must not + # collide with the count already tracked for the expired one + assert self.send(env, fresh, "00000001").response["status"] == 200 + assert self.send(env, fresh, "00000002").response["status"] == 200 + + # and the expired nonce stays unusable + r = self.send(env, challenge, "00000004") + assert r.response["status"] == 401 + env.httpd_error_log.ignore_recent(lognos=["AH01776", NC_FAILED]) diff --git a/test/modules/aaa/test_008_onetime_nccheck.py b/test/modules/aaa/test_008_onetime_nccheck.py new file mode 100644 index 00000000000..cab4f81cf23 --- /dev/null +++ b/test/modules/aaa/test_008_onetime_nccheck.py @@ -0,0 +1,194 @@ +"""One-time nonces (AuthDigestNonceLifetime 0), alone and with AuthDigestNcCheck. + +With a lifetime of 0 the server hands the client a nextnonce on every +successful response, and a nonce may be used once: it is accepted only if +it is newer than the last nonce that client used. The client counts from 1 +again for each new nonce, so with AuthDigestNcCheck also on, every request +legitimately carries nc=00000001. + +The security property here is the one from test_007_replay.py, applied to +the other piece of per-client state: + + A request which fails to authenticate MUST NOT invalidate the nonce + which the legitimate client is holding. + +It did, when the client's state was the last nonce *issued* to it: +note_digest_auth_failure() generates a fresh nonce and recorded it there, +and any request quoting the client's opaque can provoke a challenge. So an +eavesdropper who had captured one Authorization header could replay it at +will -- the replay itself was correctly rejected, but it moved the stored +nonce on, and the victim's next request was then refused. The opaque is in +the clear in every challenge and every request, and such a captured header +never goes stale for this purpose, since it works by failing. + +This needed no credentials and, despite where it was first noticed, no +AuthDigestNcCheck: the tests below run against both locations to pin that +the defect was in the one-time-nonce path, not in the combination. + +The state is now the last nonce the client actually *used*, which nothing +unauthenticated can move. +""" + +import pytest + +from . import digest_client as dc +from .env import AAATestEnv + +BOTH = ["onetime", "onetime-nccheck"] + +NC_FAILED = "AH01774" +NONCE_HASH_INVALID = "AH01776" +PASSWORD_MISMATCH = "AH01794" +CLIENT_UNKNOWN = "AH10618" + + +class TestOneTimeNonce: + + def url(self, env, location): + return env.mkurl("http", "aaa", f"/digest/{location}/secret.txt") + + def challenge(self, env, location): + r = env.curl_get(self.url(env, location)) + assert r.response["status"] == 401 + challenge = dc.DigestChallenge.parse( + r.response["header"]["www-authenticate"]) + assert challenge.opaque is not None, \ + "one-time nonces are tracked per client, so an opaque is required" + return challenge + + def header(self, location, challenge, nc="00000001", cnonce="onetime-cnonce", + response=None): + """A correct Authorization header, unless response= overrides the + digest -- an attacker can build that from an observed request + without knowing the password.""" + return dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri=f"/digest/{location}/secret.txt", nc=nc, + cnonce=cnonce, response=response) + + def send(self, env, location, auth): + return env.curl_get(self.url(env, location), + options=["-H", f"Authorization: {auth}"]) + + def follow_nextnonce(self, r, challenge): + """Advance the client to the nextnonce it was just handed.""" + ai = dc.parse_params(r.response["header"]["authentication-info"]) + assert "nextnonce" in ai + assert ai["nextnonce"] != challenge.nonce + challenge.nonce = ai["nextnonce"] + + def test_digest_080_nccheck_does_not_break_the_onetime_chain(self, env): + # Each nonce is new, so the client's count restarts at 1 every time + # and the nonce-count check must not object. (Before the nonce-count + # was tracked per-nonce this alternated 200, 401, 200, 401, ...) + challenge = self.challenge(env, "onetime-nccheck") + for _ in range(4): + r = self.send(env, "onetime-nccheck", self.header( + "onetime-nccheck", challenge, nc="00000001")) + assert r.response["status"] == 200 + self.follow_nextnonce(r, challenge) + + @pytest.mark.parametrize("location", BOTH) + def test_digest_081_onetime_nonce_rejects_immediate_replay(self, env, location): + challenge = self.challenge(env, location) + captured = self.header(location, challenge) + assert self.send(env, location, captured).response["status"] == 200 + + r = self.send(env, location, captured) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED]) + assert r.response["status"] == 401 + assert dc.DigestChallenge.parse( + r.response["header"]["www-authenticate"]).stale is True + + @pytest.mark.parametrize("location", BOTH) + def test_digest_082_onetime_nonce_rejects_replay_after_rotation(self, env, location): + # The captured header stays rejected once the client has moved on + # through the nextnonce chain. + challenge = self.challenge(env, location) + captured = self.header(location, challenge) + r = self.send(env, location, captured) + assert r.response["status"] == 200 + self.follow_nextnonce(r, challenge) + + r = self.send(env, location, self.header(location, challenge)) + assert r.response["status"] == 200 + self.follow_nextnonce(r, challenge) + + r = self.send(env, location, captured) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED]) + assert r.response["status"] == 401 + + @pytest.mark.parametrize("location", BOTH) + def test_digest_083_replay_does_not_invalidate_the_clients_nonce(self, env, location): + # The eavesdropper's version: no credentials, no forgery, just one + # captured Authorization header replayed after the client has moved + # on. Rejecting it is correct; denying the client's next request is + # not. + challenge = self.challenge(env, location) + captured = self.header(location, challenge) + r = self.send(env, location, captured) + assert r.response["status"] == 200 + self.follow_nextnonce(r, challenge) + + replay_status = self.send(env, location, captured).response["status"] + + r = self.send(env, location, self.header(location, challenge)) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED]) + assert replay_status == 401 + assert r.response["status"] == 200, \ + "the replay moved the client's one-time nonce on and locked it out" + + @pytest.mark.parametrize("location", BOTH) + def test_digest_084_bogus_request_does_not_invalidate_the_clients_nonce( + self, env, location): + # Same property with a forged digest rather than a captured one, so + # it holds however the attacker's request comes to fail. + challenge = self.challenge(env, location) + r = self.send(env, location, self.header(location, challenge)) + assert r.response["status"] == 200 + self.follow_nextnonce(r, challenge) + + bogus = self.header(location, challenge, cnonce="bogus", + response="0" * 32) + bogus_status = self.send(env, location, bogus).response["status"] + + r = self.send(env, location, self.header(location, challenge)) + env.httpd_error_log.ignore_recent( + lognos=[NC_FAILED, NONCE_HASH_INVALID, PASSWORD_MISMATCH]) + assert bogus_status == 401 + assert r.response["status"] == 200, \ + "the bogus request moved the client's one-time nonce on and locked it out" + + @pytest.mark.parametrize("location", BOTH) + def test_digest_085_replay_rejected_when_the_client_entry_is_gone(self, env, + location): + # The client table is small here -- conftest pins AuthDigestShmemSize + # to 1000 bytes, "~ 12 entries" -- so filling it makes gc() discard + # entries. The pressure has to come from clients which have + # authenticated: gc() discards the entries of clients which never + # did first, so bare requests no longer evict anybody who matters. + # + # A captured request must still not be replayable once the client's + # entry has gone. It used to be: check_nonce() skipped the one-time + # comparison entirely when the client was unknown, so the nonce was + # taken on trust and the replay served the protected resource. + challenge = self.challenge(env, location) + captured = self.header(location, challenge) + assert self.send(env, location, captured).response["status"] == 200 + assert self.send(env, location, captured).response["status"] == 401 + + # Note that the victim is left alone while the table fills: looking + # its entry up would move it to the front of its bucket, which is + # exactly what saves an entry from gc(). + for _ in range(30): + other = self.challenge(env, location) + assert self.send(env, location, + self.header(location, other)).response["status"] == 200 + + r = self.send(env, location, captured) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]) + assert r.response["status"] == 401, \ + "captured request replayed once the client entry was evicted" + gone = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + assert gone.opaque != challenge.opaque, \ + "the entry was not evicted, so the gc'd-client path is untested" diff --git a/test/modules/aaa/test_009_restart.py b/test/modules/aaa/test_009_restart.py new file mode 100644 index 00000000000..d09a3c23b57 --- /dev/null +++ b/test/modules/aaa/test_009_restart.py @@ -0,0 +1,200 @@ +"""Per-client state across a server restart. + +The client table lives in a shared memory segment created by post_config, +and that segment does not survive a restart: the restart loop in +server/main.c clears pconf, which destroys the segment, and post_config +then builds a new one, empty. + +The nonce does survive, because the secret it is hashed with is kept in +retained data across restarts. So a client returning after a restart +presents a nonce which still verifies, naming per-client state which no +longer exists. + +The right answer for such a client is a stale=true challenge: its +credentials were never in doubt, only the server-side state backing its +nonce is gone, so it should retry silently against the fresh nonce rather +than being told its authentication failed. mod_auth_digest does exactly +that when it finds the client unknown (AH10618). + +It stopped doing it once the id had been handed to somebody else, which the +ids restarting from 1 made routine rather than exotic: the first clients +seen after a restart took exactly the ids the clients from before it were +still quoting. A returning client was then checked against a *different* +client's entry and reported as a nonce-count failure -- stale=false, which +a browser shows as a failed password. + +With one-time nonces it was a replay hole rather than a wrong answer to the +user. Single use is enforced by remembering the last nonce each client +used, and that memory dies with the segment while the nonce does not; the +one-time counter restarts at 0, so a nonce from before the restart outranks +anything the new holder of its id has reached, and was accepted. An +eavesdropper who had captured one used request only had to wait for a +restart. + +The ids are now seeded randomly for each segment, so a returning client's +opaque no longer names anybody, and it takes the unknown-client path above. +""" + +from . import digest_client as dc +from .env import AAATestEnv + +NC_FAILED = "AH01774" +CLIENT_UNKNOWN = "AH10618" +ONETIME_REUSED = "AH01779" + + +class TestDigestRestart: + + def url(self, env, location, path="secret.txt"): + return env.mkurl("http", "aaa", f"/digest/{location}/{path}") + + def uri(self, location): + return f"/digest/{location}/secret.txt" + + def challenge(self, env, location): + r = env.curl_get(self.url(env, location)) + assert r.response["status"] == 401 + return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + + def header(self, location, challenge, nc="00000001", cnonce="restart-cnonce"): + return dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri=self.uri(location), nc=nc, cnonce=cnonce) + + def send(self, env, location, auth): + return env.curl_get(self.url(env, location), + options=["-H", f"Authorization: {auth}"]) + + def reload(self, env): + assert env.apache_reload() == 0, "graceful restart failed" + + def challenge_of(self, r): + """The challenge carried by a 401 response.""" + return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + + def follow_nextnonce(self, r, challenge): + ai = dc.parse_params(r.response["header"]["authentication-info"]) + assert "nextnonce" in ai + challenge.nonce = ai["nextnonce"] + + def test_digest_090_returning_client_is_challenged_as_stale(self, env): + # A client which authenticated before a restart comes back afterwards + # with the nonce it was holding. The state naming its opaque is gone, + # so the request cannot be accepted -- but the client did nothing + # wrong, and must be told to retry rather than that it failed. + location = "nccheck" + self.reload(env) + challenge = self.challenge(env, location) + assert self.send(env, location, + self.header(location, challenge)).response["status"] == 200 + + self.reload(env) + + r = self.send(env, location, self.header(location, challenge, "00000002")) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]) + assert r.response["status"] == 401 + fresh = self.challenge_of(r) + assert fresh.stale, \ + "a client returning after a restart was told its authentication " \ + "failed, rather than being asked to retry with a fresh nonce" + + # ...and the retry against that fresh challenge succeeds. + assert self.send(env, location, + self.header(location, fresh)).response["status"] == 200 + + def test_digest_091_returning_client_is_stale_even_if_its_id_was_reused(self, env): + # Same property, but now the id space has caught up: after the restart + # a new client is handed the id the returning client still quotes. + # That is not an unlikely coincidence -- the counter restarts from 1, + # so the first clients seen after a restart take exactly the ids the + # clients from before it are holding. + location = "nccheck" + self.reload(env) + challenge = self.challenge(env, location) + assert self.send(env, location, + self.header(location, challenge)).response["status"] == 200 + + self.reload(env) + + # A different client arrives first and is given the recycled id. + # Today that newcomer is handed the returning client's id, because + # the counter restarts at 1. The assertions below deliberately do not + # require it: a server which stops recycling ids satisfies this + # property by making the returning client simply unknown, which is + # the outcome under test either way. + newcomer = self.challenge(env, location) + assert self.send(env, location, + self.header(location, newcomer)).response["status"] == 200 + + r = self.send(env, location, self.header(location, challenge, "00000002")) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]) + assert r.response["status"] == 401 + assert self.challenge_of(r).stale, \ + "a returning client was reported as a possible replay attack " \ + "because its id had been given to somebody else" + + def test_digest_092_onetime_nonce_from_before_a_restart_is_not_accepted(self, env): + # One-time nonces are ordered by a counter which restarts at 0 with + # the segment, while the nonce itself stays verifiable. A client + # holding an unused nonce from before the restart therefore presents + # a counter value the new server has not reached yet. + location = "onetime" + self.reload(env) + challenge = self.challenge(env, location) + for _ in range(5): + r = self.send(env, location, self.header(location, challenge)) + assert r.response["status"] == 200 + self.follow_nextnonce(r, challenge) + # challenge.nonce is now an unused nonce, well ahead of the counter + # that a restart will reset to 0. + + self.reload(env) + + newcomer = self.challenge(env, location) + r = self.send(env, location, self.header(location, newcomer)) + assert r.response["status"] == 200 + self.follow_nextnonce(r, newcomer) + + # The nonce from the previous server generation must not be usable. + stale_use = self.send(env, location, self.header(location, challenge)) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN, + ONETIME_REUSED]) + assert stale_use.response["status"] == 401, \ + "a one-time nonce issued before the restart was accepted after it" + + # ...and the client which legitimately owns that entry still works. + assert self.send(env, location, + self.header(location, newcomer)).response["status"] == 200, \ + "the stale nonce locked out the client holding that id" + + def test_digest_093_onetime_request_cannot_be_replayed_across_a_restart(self, env): + # The severity case. A one-time nonce is single-use because the server + # remembers the last nonce each client used -- and that memory does + # not survive a restart, while the nonce does. So an eavesdropper who + # captured one *successfully used* request needs only to wait for a + # restart: the counter it outranks has gone back to zero, and the id + # it names is handed straight back out. + location = "onetime" + self.reload(env) + challenge = self.challenge(env, location) + for _ in range(5): + r = self.send(env, location, self.header(location, challenge)) + assert r.response["status"] == 200 + captured = self.header(location, challenge) # a used request + self.follow_nextnonce(r, challenge) + + assert self.send(env, location, captured).response["status"] == 401, \ + "the captured request was not single-use before the restart" + + self.reload(env) + + # Somebody authenticates, so the recycled id names a live entry. + newcomer = self.challenge(env, location) + assert self.send(env, location, + self.header(location, newcomer)).response["status"] == 200 + + replay = self.send(env, location, captured) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN, + ONETIME_REUSED]) + assert replay.response["status"] == 401, \ + "a captured one-time request was replayed successfully after a restart" diff --git a/test/modules/aaa/test_010_eviction.py b/test/modules/aaa/test_010_eviction.py new file mode 100644 index 00000000000..ed6af5bb69d --- /dev/null +++ b/test/modules/aaa/test_010_eviction.py @@ -0,0 +1,120 @@ +"""Which client entries are discarded when the table is full. + +The client table is a fixed-size shared memory segment (AuthDigestShmemSize, +pinned small by conftest here), and gc() makes room by discarding one entry +from each bucket. An entry is allocated for any request which needs a +challenge, including one carrying no credentials at all, since the challenge +has to carry the identifier the client will be tracked by. An attacker can +therefore fill the table with bare requests as fast as it can send them. + +What that costs the clients already using the server depends on which entry +gc() picks. Discarding a client which has authenticated costs it a request: +its nonce-count and last-used nonce go with the entry, so its next request +is answered with a fresh challenge (stale=true) and has to be retried. +Discarding a client which has never authenticated costs nothing at all -- +the entry records nothing yet, and the client simply gets a new identifier +with its next challenge. + +So gc() prefers the entries which have never been used to authenticate, +which are exactly the ones a flood of unauthenticated requests creates. + +"Never used to authenticate" is last_nonce_time == 0, which is exact in both +of the configurations that track clients: an accepted request stores the +time its nonce was generated at, which is a wall-clock time for a nonce with +a lifetime and a counter which starts at 1 for a one-time nonce. Neither is +ever 0. The tests below run against both to pin that. +""" + +import pytest + +from . import digest_client as dc +from .env import AAATestEnv + +NC_FAILED = "AH01774" +CLIENT_UNKNOWN = "AH10618" + +# A location tracking clients for the nonce-count, and one tracking them for +# one-time nonces: the two put different kinds of value in last_nonce_time. +BOTH = ["nccheck", "onetime"] + + +class TestDigestEviction: + + def url(self, env, location): + return env.mkurl("http", "aaa", f"/digest/{location}/secret.txt") + + def uri(self, location): + return f"/digest/{location}/secret.txt" + + def challenge(self, env, location): + r = env.curl_get(self.url(env, location)) + assert r.response["status"] == 401 + return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + + def header(self, location, challenge, nc="00000001", cnonce="eviction-cnonce"): + return dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri=self.uri(location), nc=nc, cnonce=cnonce) + + def send(self, env, location, auth): + return env.curl_get(self.url(env, location), + options=["-H", f"Authorization: {auth}"]) + + def advance(self, r, challenge): + """Prepare the client's next request, and return the nc to send with + it. A one-time nonce is replaced by the nextnonce just handed out, + and the count restarts at 1 for it; otherwise the nonce stays and the + count goes up.""" + ai = r.response["header"].get("authentication-info") + if ai: + params = dc.parse_params(ai) + if "nextnonce" in params: + challenge.nonce = params["nextnonce"] + return "00000001" + return "00000002" + + def flood(self, env, location, count): + """Bare requests, each of which allocates a client entry.""" + for _ in range(count): + env.curl_get(self.url(env, location)) + + @pytest.mark.parametrize("location", BOTH) + def test_digest_100_authenticated_client_survives_a_flood(self, env, location): + # The legitimate client authenticates, so its entry now records the + # nonce it used. + challenge = self.challenge(env, location) + r = self.send(env, location, self.header(location, challenge)) + assert r.response["status"] == 200 + nc = self.advance(r, challenge) + + # An attacker fills the table several times over with requests which + # carry no credentials at all. + self.flood(env, location, 60) + + # The victim carries on. Its entry must still be there: it is the + # only one in the table which is worth keeping. + r = self.send(env, location, self.header(location, challenge, nc)) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]) + assert r.response["status"] == 200, \ + "a flood of unauthenticated requests evicted an authenticated client" + + @pytest.mark.parametrize("location", BOTH) + def test_digest_101_unused_entries_are_the_ones_discarded(self, env, location): + # The counterpart: the entries a flood creates are themselves the + # ones discarded, so a flood cannot fill the table permanently. The + # opaque handed out at the start of one no longer names an entry by + # the end, which the server reports by minting a new one rather than + # echoing it back -- and as a stale challenge, since nothing was + # wrong with the client's credentials. + first = self.challenge(env, location) + self.flood(env, location, 60) + + r = self.send(env, location, self.header(location, first)) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN]) + assert r.response["status"] == 401 + again = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + assert again.opaque != first.opaque, \ + "the unused entry should have been discarded by the flood" + assert again.stale, \ + "a client whose unused entry was discarded should be re-challenged " \ + "as stale, not told its authentication failed"