diff --git a/docs/log-message-tags/next-number b/docs/log-message-tags/next-number index 3f7624fa412..6c6f5d54af5 100644 --- a/docs/log-message-tags/next-number +++ b/docs/log-message-tags/next-number @@ -1 +1 @@ -10618 +10619 diff --git a/modules/aaa/mod_auth_digest.c b/modules/aaa/mod_auth_digest.c index cef5574d285..a8725c5bef3 100644 --- a/modules/aaa/mod_auth_digest.c +++ b/modules/aaa/mod_auth_digest.c @@ -18,7 +18,7 @@ * mod_auth_digest: MD5 digest authentication * * Originally by Alexei Kosut - * Updated to RFC-2617 by Ronald Tschalär + * 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 @@ -81,6 +81,12 @@ #include #endif +/* configure declines to build this module without both of these, so this + * only catches builds which don't use it. */ +#if !APR_HAS_RANDOM || !APR_HAS_SHARED_MEMORY +#error mod_auth_digest requires APR with random and shared memory support +#endif + /* struct to hold the configuration info */ typedef struct digest_config_struct { @@ -115,11 +121,20 @@ typedef struct digest_config_struct { /* client list definitions */ +/* Identifies a client entry. This is the value sent to the client in the + * opaque field of the challenge, and echoed back in its Authorization + * header; zero is never a valid id, and means "no client". Ids are counted + * out by client_id_counter, so this must remain the type which the atomics + * used on it take, and the "%u"/"%x" formats below must match it. */ +typedef apr_uint32_t client_id_t; + typedef struct hash_entry { - unsigned long key; /* the key for this entry */ + client_id_t key; /* the key for this entry */ struct hash_entry *next; /* next entry in the bucket */ - unsigned long nonce_count; /* for nonce-count checking */ - char last_nonce[NONCE_LEN+1]; /* for one-time nonce's */ + unsigned long nonce_count; /* highest nonce-count seen + * for last_nonce_time */ + apr_time_t last_nonce_time; /* nonce of the last request + * accepted for this client */ } client_entry; static struct hash_table { @@ -136,6 +151,14 @@ static struct hash_table { enum hdr_sts { NO_HEADER, NOT_DIGEST, INVALID, VALID }; +/* Outcome of checking a request's nonce and nonce-count against the state + * tracked for its client. */ +enum nonce_state { + NONCE_ACCEPTED, /* recorded as the latest used by this client */ + NONCE_STALE, /* already used, or the client is unknown */ + NONCE_BAD_COUNT /* nonce-count did not increase: possible replay */ +}; + typedef struct digest_header_struct { const char *scheme; const char *realm; @@ -147,7 +170,7 @@ typedef struct digest_header_struct { const char *algorithm; const char *cnonce; const char *opaque; - unsigned long opaque_num; + client_id_t opaque_num; const char *message_qop; const char *nonce_count; /* the following fields are not (directly) from the header */ @@ -157,7 +180,6 @@ typedef struct digest_header_struct { enum hdr_sts auth_hdr_sts; int needed_auth; const char *ha1; - client_entry *client; } digest_header_rec; @@ -174,7 +196,7 @@ static unsigned char *secret; static apr_shm_t *client_shm = NULL; static apr_rmm_t *client_rmm = NULL; -static volatile apr_uint32_t *opaque_counter; +static volatile client_id_t *client_id_counter; static volatile apr_uint32_t *otn_counter; /* one-time-nonce counter */ static apr_global_mutex_t *client_lock = NULL; static const char *client_mutex_type = "authdigest-client"; @@ -321,12 +343,12 @@ static int initialize_tables(server_rec *s, apr_pool_t *ctx) /* setup opaque */ - opaque_counter = rmm_malloc(client_rmm, sizeof *opaque_counter); - if (opaque_counter == NULL) { + client_id_counter = rmm_malloc(client_rmm, sizeof *client_id_counter); + if (client_id_counter == NULL) { log_error_and_cleanup("failed to allocate shared memory", -1, s); return !OK; } - *opaque_counter = 1; + *client_id_counter = 1; /* setup one-time-nonce counter */ @@ -348,12 +370,6 @@ static int pre_init(apr_pool_t *pconf, apr_pool_t *plog, apr_pool_t *ptemp) apr_status_t rv; void *retained; - if (!APR_HAS_SHARED_MEMORY) { - ap_log_error(APLOG_MARK, APLOG_CRIT, rv, NULL, APLOGNO(10590) - "mod_auth_digest cannot be used on platforms without shared memory support"); - return !OK; - } - rv = ap_mutex_register(pconf, client_mutex_type, NULL, APR_LOCK_DEFAULT, 0); if (rv != APR_SUCCESS) return !OK; @@ -363,11 +379,7 @@ static int pre_init(apr_pool_t *pconf, apr_pool_t *plog, apr_pool_t *ptemp) retained = ap_retained_data_create(RETAINED_DATA_ID, SECRET_LEN); ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, NULL, APLOGNO(01757) "generating secret for digest authentication"); -#if APR_HAS_RANDOM rv = apr_generate_random_bytes(retained, SECRET_LEN); -#else -#error APR random number support is missing -#endif if (rv != APR_SUCCESS) { ap_log_error(APLOG_MARK, APLOG_CRIT, rv, NULL, APLOGNO(01758) "error generating secret"); @@ -634,38 +646,41 @@ static const command_rec digest_cmds[] = * above algorithm is really sufficient) a set of counters is kept * indicating the number of clients held, the number of garbage collected * clients, and the number of erroneously purged clients. These are printed - * out at each garbage collection run. Note that access to the counters is - * not synchronized because they are just indicaters, and whether they are - * off by a few doesn't matter; and for the same reason no attempt is made - * to guarantee the num_renewed is correct in the face of clients spoofing - * the opaque field. + * out at each garbage collection run. Note that no attempt is made to + * guarantee that num_renewed is correct in the face of clients spoofing + * the opaque field; it is just an indicator, and whether it is off by a + * few doesn't matter. */ /* - * Get the client given its client number (the key). Returns the entry, - * or NULL if it's not found. + * Find the client given its client number (the key), moving it to the + * front of its bucket. Returns the entry, or NULL if it's not found. * - * Access to the list itself is synchronized via locks. However, access - * to the entry returned by get_client() is NOT synchronized. This means - * that there are potentially problems if a client uses multiple, - * simultaneous connections to access url's within the same protection - * space. However, these problems are not new: when using multiple - * connections you have no guarantee of the order the requests are - * processed anyway, so you have problems with the nonce-count and - * one-time nonces anyway. + * MUST be called with client_lock held, and the entry returned MUST NOT be + * used outside that critical section: it lives in the shared memory + * segment, where gc() can free it at any time on behalf of another + * process. The accessors below are the only supported way to reach a + * client entry; each looks it up afresh, so a client which has since been + * garbage collected is simply reported as unknown and the caller goes on + * to issue a new challenge for it. + * + * Note that this still gives no ordering guarantee for a client using + * multiple simultaneous connections within the same protection space: the + * requests can be processed in any order, so the nonce-count and one-time + * nonce checks may reject some of them. That is not new. */ -static client_entry *get_client(unsigned long key, const request_rec *r) +static client_entry *find_client(client_id_t key) { int bucket; client_entry *entry, *prev = NULL; - if (!key) return NULL; + if (!key) { + return NULL; + } bucket = key % client_list->tbl_len; entry = client_list->table[bucket]; - apr_global_mutex_lock(client_lock); - while (entry && key != entry->key) { prev = entry; entry = entry->next; @@ -677,18 +692,120 @@ static client_entry *get_client(unsigned long key, const request_rec *r) client_list->table[bucket] = entry; } + return entry; +} + + +/* Determine whether the client identified by key is still known. */ +static int client_exists(client_id_t key, const request_rec *r) +{ + int found; + + apr_global_mutex_lock(client_lock); + found = find_client(key) != NULL; apr_global_mutex_unlock(client_lock); - if (entry) { + if (found) { ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, APLOGNO(01764) - "get_client(): client %lu found", key); + "client %u found", key); } else { ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, APLOGNO(01765) - "get_client(): client %lu not found", key); + "client %u not found", key); } - return entry; + return found; +} + + +/* Note that a client entry was created to replace one which had been + * garbage collected. */ +static void client_note_renewed(void) +{ + apr_global_mutex_lock(client_lock); + client_list->num_renewed++; + apr_global_mutex_unlock(client_lock); +} + + +/* Check the nonce generated at nonce_time, and the nonce-count nc sent + * with it, against the state tracked for the client identified by key, and + * record them if acceptable. + * + * Both nonce_time and the count are compared against what the client last + * *used*, never against what was last issued to it: a nonce is issued + * whenever a challenge is generated, and anything quoting the client's + * opaque can provoke a challenge, so tracking what was issued lets an + * unauthenticated request invalidate the nonce which the legitimate client + * is holding. + * + * A one-time nonce (AuthDigestNonceLifetime 0) is therefore accepted iff + * it is newer than the last nonce this client used, which permits it + * exactly once. Otherwise, with AuthDigestNcCheck, a newer nonce starts a + * new count and the same nonce must raise it. + * + * Must only be called for a request which is fully verified - both the + * response digest and the nonce - so that a request which fails to + * authenticate cannot alter the state tracked for the client whose opaque + * it quotes. */ +static enum nonce_state client_update_nonce(const request_rec *r, + client_id_t key, + const digest_config_rec *conf, + apr_time_t nonce_time, + unsigned long nc, + const char *nonce) +{ + client_entry *client; + unsigned long tracked = 0; + enum nonce_state state; + int known; + + apr_global_mutex_lock(client_lock); + client = find_client(key); + known = (client != NULL); + if (!known) { + state = NONCE_STALE; + } + else { + tracked = client->nonce_count; + if (conf->nonce_lifetime == 0) { + /* one-time nonce: usable until it has been used */ + state = (nonce_time > client->last_nonce_time) + ? NONCE_ACCEPTED : NONCE_STALE; + } + else if (nonce_time > client->last_nonce_time + || (nonce_time == client->last_nonce_time && nc > tracked)) { + state = NONCE_ACCEPTED; + } + else { + state = NONCE_BAD_COUNT; + } + + if (state == NONCE_ACCEPTED) { + client->last_nonce_time = nonce_time; + client->nonce_count = nc; + } + } + apr_global_mutex_unlock(client_lock); + + if (!known) { + ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(10618) + "client %u is no longer known - sending new nonce", + key); + } + else if (state == NONCE_STALE) { + ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(01779) + "user %s: one-time-nonce %s already used - sending " + "new nonce", r->user, nonce); + } + else if (state == NONCE_BAD_COUNT) { + ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01774) + "Warning, possible replay attack: nonce-count check " + "failed: %lu is not above %lu for nonce %s", nc, + tracked, nonce); + } + + return state; } @@ -696,7 +813,7 @@ static client_entry *get_client(unsigned long key, const request_rec *r) * last entry in each bucket and updates the counters. Returns the * number of removed entries. */ -static long gc(server_rec *s) +static unsigned long gc(server_rec *s) { client_entry *entry, *prev; unsigned long num_removed = 0, idx; @@ -746,17 +863,17 @@ static long gc(server_rec *s) /* - * Add a new client to the list. Returns the entry if successful, NULL - * otherwise. This triggers the garbage collection if memory is low. + * Add a new client to the list. Returns non-zero if successful, zero + * otherwise. This triggers the garbage collection if memory is low. (The + * new entry is not returned: see find_client().) */ -static client_entry *add_client(unsigned long key, client_entry *info, - server_rec *s) +static int add_client(client_id_t key, client_entry *info, server_rec *s) { int bucket; client_entry *entry; if (!key) { - return NULL; + return 0; } bucket = key % client_list->tbl_len; @@ -767,19 +884,17 @@ static client_entry *add_client(unsigned long key, client_entry *info, entry = rmm_malloc(client_rmm, sizeof(client_entry)); if (!entry) { - long num_removed = gc(s); + unsigned long num_removed = gc(s); ap_log_error(APLOG_MARK, APLOG_INFO, 0, s, APLOGNO(01766) - "gc'd %ld client entries. Total new clients: " - "%ld; Total removed clients: %ld; Total renewed clients: " - "%ld", num_removed, + "gc'd %lu client entries. Total new clients: " + "%lu; Total removed clients: %lu; Total renewed clients: " + "%lu", num_removed, client_list->num_created - client_list->num_renewed, client_list->num_removed, client_list->num_renewed); entry = rmm_malloc(client_rmm, sizeof(client_entry)); if (!entry) { - ap_log_error(APLOG_MARK, APLOG_ERR, 0, s, APLOGNO(01767) - "unable to allocate new auth_digest client"); apr_global_mutex_unlock(client_lock); - return NULL; /* give up */ + return 0; /* give up; the caller logs this */ } } @@ -795,9 +910,9 @@ static client_entry *add_client(unsigned long key, client_entry *info, apr_global_mutex_unlock(client_lock); ap_log_error(APLOG_MARK, APLOG_DEBUG, 0, s, APLOGNO(01768) - "allocated new client %lu", key); + "allocated new client %u", key); - return entry; + return 1; } @@ -805,8 +920,10 @@ static client_entry *add_client(unsigned long key, client_entry *info, * Authorization header parser code */ -/* Parse the Authorization header, if it exists */ -static int get_digest_rec(request_rec *r, digest_header_rec *resp) +/* Parse the Authorization header, if it exists, into resp; returns the + * status of the header. */ +static enum hdr_sts parse_digest_header(request_rec *r, + digest_header_rec *resp) { const char *auth_line; apr_size_t l; @@ -818,14 +935,12 @@ static int get_digest_rec(request_rec *r, digest_header_rec *resp) ? "Proxy-Authorization" : "Authorization"); if (!auth_line) { - resp->auth_hdr_sts = NO_HEADER; - return !OK; + return NO_HEADER; } resp->scheme = ap_getword_white(r->pool, &auth_line); if (ap_cstr_casecmp(resp->scheme, "Digest")) { - resp->auth_hdr_sts = NOT_DIGEST; - return !OK; + return NOT_DIGEST; } l = strlen(auth_line); @@ -913,8 +1028,7 @@ static int get_digest_rec(request_rec *r, digest_header_rec *resp) || !VALID_NONCE(resp->nonce) || !resp->digest || strlen(resp->digest) != MD5_DIGEST_LEN || (resp->message_qop && (!resp->cnonce || !resp->nonce_count))) { - resp->auth_hdr_sts = INVALID; - return !OK; + return INVALID; } if (resp->opaque) { @@ -923,30 +1037,31 @@ static int get_digest_rec(request_rec *r, digest_header_rec *resp) errno = 0; num = strtol(resp->opaque, &endptr, 16); - if (errno == 0 && *endptr == '\0' && num > 0) - resp->opaque_num = (unsigned long)num; + if (errno == 0 && *endptr == '\0' && num > 0 + && num <= APR_UINT32_MAX) + resp->opaque_num = (client_id_t)num; } - resp->auth_hdr_sts = VALID; - return OK; + return VALID; } -/* Because the browser may preemptively send auth info, incrementing the - * nonce-count when it does, and because the client does not get notified - * if the URI didn't need authentication after all, we need to be sure to - * update the nonce-count each time we receive an Authorization header no - * matter what the final outcome of the request. Furthermore this is a - * convenient place to get the request-uri (before any subrequests etc - * are initiated) and to initialize the request_config. +/* Set up the per-request record: this is the place to get the request-uri + * (before any subrequests etc are initiated), to initialize the + * request_config, and to parse the Authorization header. + * + * Note that the nonce-count tracked for the client is deliberately NOT + * updated here: the state of an authenticated client must not be altered + * by a request which has not (yet) been authenticated, or a replayed or + * bogus request quoting the client's opaque would be able to rewind that + * state. See check_and_update_nc(). * * Note that this must be called after mod_proxy had its go so that * r->proxyreq is set correctly. */ -static int parse_hdr_and_update_nc(request_rec *r) +static int init_digest_request(request_rec *r) { digest_header_rec *resp; - int res; if (!ap_is_initial_req(r)) { return DECLINED; @@ -959,11 +1074,7 @@ static int parse_hdr_and_update_nc(request_rec *r) resp->method = r->method; ap_set_module_config(r->request_config, &auth_digest_module, resp); - res = get_digest_rec(r, resp); - resp->client = get_client(resp->opaque_num, r); - if (res == OK && resp->client) { - resp->client->nonce_count++; - } + resp->auth_hdr_sts = parse_digest_header(r, resp); return DECLINED; } @@ -1008,7 +1119,10 @@ static const char *gen_nonce(apr_pool_t *p, apr_time_t now, const char *opaque, t.time = now; } else { - t.time = apr_atomic_inc32(otn_counter); + /* Nonces are ordered by this counter rather than by time; the +1 + * is because apr_atomic_inc32() returns the previous value, and a + * nonce time of zero means "no nonce used yet" in a client entry. */ + 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); @@ -1022,21 +1136,29 @@ static const char *gen_nonce(apr_pool_t *p, apr_time_t now, const char *opaque, */ /* - * Generate a new client entry, add it to the list, and return the - * entry. Returns NULL if failed. + * Generate a new client entry and add it to the list. Returns the key of + * the new entry, or 0 if it failed. (The entry itself is deliberately not + * returned: see find_client().) */ -static client_entry *gen_client(const request_rec *r) +static client_id_t client_generate(const request_rec *r) { - apr_uint32_t op = apr_atomic_inc32(opaque_counter); - client_entry new_entry = { 0, NULL, 0, "" }, *entry; + client_id_t op = apr_atomic_inc32(client_id_counter); + client_entry new_entry = { 0, NULL, 0, 0 }; + + /* The counter wraps after 2^32 clients: skip an id of zero, which means + * "no client" and which add_client() would refuse. */ + if (op == 0) { + op = apr_atomic_inc32(client_id_counter); + } - if (!(entry = add_client(op, &new_entry, r->server))) { + if (!add_client(op, &new_entry, r->server)) { ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01769) - "failed to allocate client entry - ignoring client"); - return NULL; + "unable to allocate a client entry - failing the " + "request, since this configuration needs one"); + return 0; } - return entry; + return op; } @@ -1044,21 +1166,25 @@ static client_entry *gen_client(const request_rec *r) * Authorization challenge generation code (for WWW-Authenticate) */ -static const char *ltox(apr_pool_t *p, unsigned long num) +/* Format a client id as the opaque sent to the client. Never called with + * zero: the callers check client_generate() for failure first. */ +static const char *ltox(apr_pool_t *p, client_id_t num) { - if (num != 0) { - return apr_psprintf(p, "%lx", num); - } - else { - return ""; - } + return apr_psprintf(p, "%x", num); } -static void note_digest_auth_failure(request_rec *r, - const digest_config_rec *conf, - digest_header_rec *resp, int stale) +/* Generate a challenge for the client, and return the status which the + * caller should return for this request: HTTP_UNAUTHORIZED normally, or + * HTTP_SERVICE_UNAVAILABLE if the per-client state which this configuration + * requires could not be allocated. No challenge is sent in that case: it + * could only carry an opaque which identifies nothing, so the client would + * be unable to authenticate through it however often it retried. */ +static int note_digest_auth_failure(request_rec *r, + const digest_config_rec *conf, + digest_header_rec *resp, int stale) { - const char *qop, *opaque, *opaque_param, *domain, *nonce; + const char *qop, *opaque = NULL, *opaque_param = "", *domain, *nonce; + client_id_t client_key = 0; /* Setup qop */ qop = ", qop=\"auth\""; @@ -1067,45 +1193,39 @@ static void note_digest_auth_failure(request_rec *r, if (resp->opaque == NULL) { /* new client */ - if ((conf->check_nc || conf->nonce_lifetime == 0) - && (resp->client = gen_client(r)) != NULL) { - opaque = ltox(r->pool, resp->client->key); - } - else { - opaque = ""; /* opaque not needed */ + if (conf->check_nc || conf->nonce_lifetime == 0) { + if ((client_key = client_generate(r)) == 0) { + return HTTP_SERVICE_UNAVAILABLE; + } + opaque = ltox(r->pool, client_key); } + /* else no opaque is needed, and none is sent */ } - else if (resp->client == NULL) { + else if (!client_exists(resp->opaque_num, r)) { /* client info was gc'd */ - resp->client = gen_client(r); - if (resp->client != NULL) { - opaque = ltox(r->pool, resp->client->key); - stale = 1; - client_list->num_renewed++; - } - else { - opaque = ""; /* ??? */ + if ((client_key = client_generate(r)) == 0) { + return HTTP_SERVICE_UNAVAILABLE; } + opaque = ltox(r->pool, client_key); + stale = 1; + client_note_renewed(); } else { + /* Note that the nonce-count tracked for this client is left alone + * 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; - /* we're generating a new nonce, so reset the nonce-count */ - resp->client->nonce_count = 0; } - if (opaque[0]) { + if (opaque) { opaque_param = apr_pstrcat(r->pool, ", opaque=\"", opaque, "\"", NULL); } - else { - opaque_param = NULL; - } /* Setup nonce */ nonce = gen_nonce(r->pool, r->request_time, opaque, r->server, conf, ap_auth_name(r)); - if (resp->client && conf->nonce_lifetime == 0) { - memcpy(resp->client->last_nonce, nonce, NONCE_LEN+1); - } /* setup domain attribute. We want to send this attribute wherever * possible so that the client won't send the Authorization header @@ -1130,10 +1250,11 @@ static void note_digest_auth_failure(request_rec *r, apr_psprintf(r->pool, "Digest realm=\"%s\", " "nonce=\"%s\", algorithm=%s%s%s%s%s", ap_auth_name(r), nonce, conf->algorithm, - opaque_param ? opaque_param : "", + opaque_param, domain ? domain : "", stale ? ", stale=true" : "", qop)); + return HTTP_UNAUTHORIZED; } static int hook_note_digest_auth_failure(request_rec *r, const char *auth_type) @@ -1233,37 +1354,49 @@ static authn_status get_hash(request_rec *r, const char *user, return auth_result; } -static int check_nc(const request_rec *r, const digest_header_rec *resp, - const digest_config_rec *conf) +/* Check the nonce and nonce-count of a fully verified request against the + * state tracked for its client, record them, and generate a new challenge + * if they are not acceptable. + * + * The nonce-count is counted by the client per-nonce (RFC 7616 3.4.3), so + * the count tracked here is tied to the nonce it was counted for: a request + * using a newer nonce starts a new count. Within a single nonce the count + * must strictly increase, but it need not increase by exactly one: the + * client also counts the requests it sends to URIs in the protection space + * which turn out not to need authentication, and this server never sees + * those. + */ +static int check_and_record_nonce(request_rec *r, digest_header_rec *resp, + const digest_config_rec *conf) { unsigned long nc; const char *snc = resp->nonce_count; char *endptr; - if (!conf->check_nc) { - return OK; + if (!conf->check_nc && conf->nonce_lifetime != 0) { + return OK; /* nothing is tracked per-client */ } nc = strtol(snc, &endptr, 16); if (endptr < (snc+strlen(snc)) && !apr_isspace(*endptr)) { ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01773) "invalid nc %s received - not a number", snc); - return !OK; + return note_digest_auth_failure(r, conf, resp, 0); } - if (!resp->client) { - return !OK; - } + switch (client_update_nonce(r, resp->opaque_num, conf, resp->nonce_time, + nc, resp->nonce)) { + case NONCE_ACCEPTED: + return OK; - if (nc != resp->client->nonce_count) { - ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01774) - "Warning, possible replay attack: nonce-count " - "check failed: %lu != %lu", nc, - resp->client->nonce_count); - return !OK; - } + case NONCE_STALE: + /* the credentials were good, so the client can silently retry with + * the nonce from this challenge */ + return note_digest_auth_failure(r, conf, resp, 1); - return OK; + default: + return note_digest_auth_failure(r, conf, resp, 0); + } } static int check_nonce(request_rec *r, digest_header_rec *resp, @@ -1284,8 +1417,7 @@ static int check_nonce(request_rec *r, digest_header_rec *resp, ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01776) "invalid nonce %s received - hash is not %s", resp->nonce, hash); - note_digest_auth_failure(r, conf, resp, 1); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 1); } dt = r->request_time - nonce_time.time; @@ -1293,8 +1425,7 @@ static int check_nonce(request_rec *r, digest_header_rec *resp, ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01777) "invalid nonce %s received - user attempted " "time travel", resp->nonce); - note_digest_auth_failure(r, conf, resp, 1); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 1); } if (conf->nonce_lifetime > 0) { @@ -1304,20 +1435,11 @@ static int check_nonce(request_rec *r, digest_header_rec *resp, "- max lifetime %.2f) - sending new nonce", r->user, (double)apr_time_sec(dt), (double)apr_time_sec(conf->nonce_lifetime)); - note_digest_auth_failure(r, conf, resp, 1); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 1); } } - else if (conf->nonce_lifetime == 0 && resp->client) { - if (memcmp(resp->client->last_nonce, resp->nonce, NONCE_LEN)) { - ap_log_rerror(APLOG_MARK, APLOG_INFO, 0, r, APLOGNO(01779) - "user %s: one-time-nonce mismatch - sending " - "new nonce", r->user); - note_digest_auth_failure(r, conf, resp, 1); - return HTTP_UNAUTHORIZED; - } - } - /* else (lifetime < 0) => never expires */ + /* else (lifetime <= 0) => never expires by time; a one-time nonce is + * retired by use, in check_and_record_nonce() */ return OK; } @@ -1459,8 +1581,7 @@ static int authenticate_digest_user(request_rec *r) r->uri); } /* else (resp->auth_hdr_sts == NO_HEADER) */ - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } r->user = (char *) resp->username; @@ -1534,8 +1655,7 @@ static int authenticate_digest_user(request_rec *r) ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01787) "received invalid opaque - got `%s'", resp->opaque); - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } @@ -1544,16 +1664,14 @@ static int authenticate_digest_user(request_rec *r) ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(02533) "realm mismatch - got `%s' but no realm specified", resp->realm); - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } if (!resp->realm || strcmp(resp->realm, realm)) { ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01788) "realm mismatch - got `%s' but expected `%s'", resp->realm, realm); - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } if (resp->algorithm != NULL @@ -1561,8 +1679,7 @@ static int authenticate_digest_user(request_rec *r) ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01789) "unknown algorithm `%s' received: %s", resp->algorithm, r->uri); - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } return_code = get_hash(r, r->user, conf, &resp->ha1); @@ -1571,8 +1688,7 @@ static int authenticate_digest_user(request_rec *r) ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01790) "user `%s' in realm `%s' not found: %s", r->user, realm, r->uri); - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } else if (return_code == AUTH_USER_FOUND) { /* we have a password, so continue */ @@ -1582,8 +1698,7 @@ static int authenticate_digest_user(request_rec *r) ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01791) "user `%s' in realm `%s' denied by provider: %s", r->user, realm, r->uri); - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } else if (return_code == AUTH_HANDLED) { return r->status; @@ -1602,8 +1717,7 @@ static int authenticate_digest_user(request_rec *r) ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(10560) "invalid or missing qop value '%s', RFC 2069 is " "no longer supported: %s", resp->message_qop, r->uri); - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } else { /* RFC 2617 (or 7616)-style Digest hash calculation. */ @@ -1616,23 +1730,20 @@ static int authenticate_digest_user(request_rec *r) ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(01794) "user %s: password mismatch: %s", r->user, r->uri); - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; + return note_digest_auth_failure(r, conf, resp, 0); } } - if (check_nc(r, resp, conf) != OK) { - note_digest_auth_failure(r, conf, resp, 0); - return HTTP_UNAUTHORIZED; - } - - /* Note: this check is done last so that a "stale=true" can be - generated if the nonce is old */ + /* Note: the nonce is checked before the nonce-count so that the + * nonce-count state is only ever updated for a request which is using + * a nonce this server issued, and so that a request using an expired + * nonce gets a "stale=true" challenge (and hence a silent retry with a + * fresh nonce-count) rather than being reported as a replay. */ if ((res = check_nonce(r, resp, conf))) { return res; } - return OK; + return check_and_record_nonce(r, resp, conf); } /* Authentication-Info header code. */ @@ -1665,15 +1776,12 @@ static int add_auth_info(request_rec *r) gen_nonce(r->pool, r->request_time, resp->opaque, r->server, conf, ap_auth_name(r)), "\"", NULL); - if (resp->client) - resp->client->nonce_count = 0; } } - else if (conf->nonce_lifetime == 0 && resp->client) { + else if (conf->nonce_lifetime == 0 && resp->opaque_num) { const char *nonce = gen_nonce(r->pool, 0, resp->opaque, r->server, conf, ap_auth_name(r)); nextnonce = apr_pstrcat(r->pool, ", nextnonce=\"", nonce, "\"", NULL); - memcpy(resp->client->last_nonce, nonce, NONCE_LEN+1); } /* else nonce never expires, hence no nextnonce */ @@ -1733,7 +1841,7 @@ static void register_hooks(apr_pool_t *p) ap_hook_pre_config(pre_init, NULL, NULL, APR_HOOK_MIDDLE); ap_hook_post_config(initialize_module, NULL, cfgPost, APR_HOOK_MIDDLE); ap_hook_child_init(initialize_child, NULL, NULL, APR_HOOK_MIDDLE); - ap_hook_post_read_request(parse_hdr_and_update_nc, parsePre, NULL, APR_HOOK_MIDDLE); + ap_hook_post_read_request(init_digest_request, parsePre, NULL, APR_HOOK_MIDDLE); ap_hook_check_authn(authenticate_digest_user, NULL, NULL, APR_HOOK_MIDDLE, AP_AUTH_INTERNAL_PER_CONF); diff --git a/test/modules/aaa/__init__.py b/test/modules/aaa/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/modules/aaa/conftest.py b/test/modules/aaa/conftest.py new file mode 100644 index 00000000000..eef98e73e51 --- /dev/null +++ b/test/modules/aaa/conftest.py @@ -0,0 +1,99 @@ +import logging +import os +import sys + +import pytest + +from .env import AAATestEnv +from pyhttpd.conf import HttpdConf + +sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) + + +def pytest_report_header(config, start_path): + env = AAATestEnv() + return f"mod_auth_digest [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]" + + +def _digest_dir(docs, path, extra_lines): + lines = [ + f'', + ' AuthType Digest', + f' AuthName "{AAATestEnv.REALM}"', + ] + lines.extend(f" {l}" for l in extra_lines) + lines.append(' Require valid-user') + lines.append('') + return lines + + +@pytest.fixture(scope="package") +def env(pytestconfig) -> AAATestEnv: + level = logging.INFO + console = logging.StreamHandler() + console.setLevel(level) + console.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + logging.getLogger('').addHandler(console) + logging.getLogger('').setLevel(level=level) + env = AAATestEnv(pytestconfig=pytestconfig) + env.setup_httpd() + env.apache_access_log_clear() + env.httpd_error_log.clear_log() + + docs = env.server_docs_dir + pwfile = env.digest_pwfile + conf = HttpdConf(env) + conf.add(_digest_dir(docs, "default", [ + 'AuthDigestProvider file', + f'AuthUserFile "{pwfile}"', + ])) + conf.add(_digest_dir(docs, "nccheck", [ + 'AuthDigestProvider file', + f'AuthUserFile "{pwfile}"', + 'AuthDigestNcCheck On', + ])) + conf.add(_digest_dir(docs, "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..0e8ed377e9c --- /dev/null +++ b/test/modules/aaa/env.py @@ -0,0 +1,79 @@ +import hashlib +import inspect +import logging +import os +from typing import List, Optional + +from pyhttpd.env import HttpdTestEnv, HttpdTestSetup +from pyhttpd.result import ExecResult + +log = logging.getLogger(__name__) + + +class AAATestSetup(HttpdTestSetup): + + def __init__(self, env: 'HttpdTestEnv'): + super().__init__(env=env) + self.add_source_dir(os.path.dirname(inspect.getfile(AAATestSetup))) + self.add_modules(["auth_digest", "authn_file", "authn_core", + "authz_core", "authz_user"]) + + +class AAATestEnv(HttpdTestEnv): + + REALM = "AAA Digest Realm" + DIGEST_USER = "digestuser" + DIGEST_PASSWORD = "digestpass2617" + DIGEST_USER2 = "otheruser" + DIGEST_PASSWORD2 = "otherpass2617" + + def __init__(self, pytestconfig=None): + super().__init__(pytestconfig=pytestconfig) + self.add_httpd_log_modules(["auth_digest", "authn_file", "authz_core"]) + self._digest_pwfile = os.path.join(self.server_dir, "digest.passwd") + + def setup_httpd(self, setup: HttpdTestSetup = None): + super().setup_httpd(setup=AAATestSetup(env=self)) + self._write_digest_pwfile() + + def _write_digest_pwfile(self): + def ha1(user, password): + return hashlib.md5( + f"{user}:{self.REALM}:{password}".encode()).hexdigest() + + with open(self._digest_pwfile, 'w') as fd: + fd.write(f"{self.DIGEST_USER}:{self.REALM}:" + f"{ha1(self.DIGEST_USER, self.DIGEST_PASSWORD)}\n") + fd.write(f"{self.DIGEST_USER2}:{self.REALM}:" + f"{ha1(self.DIGEST_USER2, self.DIGEST_PASSWORD2)}\n") + + @property + def digest_pwfile(self) -> str: + return self._digest_pwfile + + def configtest(self, directory_lines: List[str], extra_top_lines: Optional[List[str]] = None + ) -> ExecResult: + """Run `httpd -t` against a minimal, standalone config built from the + already-generated modules.conf plus `directory_lines` wrapped in a + block over the shared docroot. Used to test directives + that are rejected at config-check time (e.g. AuthDigestQop values + other than 'auth') without touching the package's running server. + """ + conf_path = os.path.join(self.gen_dir, "digest-configtest.conf") + modules_conf = os.path.join(self.server_conf_dir, "modules.conf") + lines = [ + f'ServerRoot "{self.server_dir}"', + f'Include "{modules_conf}"', + f'DocumentRoot "{self.server_docs_dir}"', + f'Listen {self.http_port2}', + ] + if extra_top_lines: + lines.extend(extra_top_lines) + lines.append(f'') + lines.extend(f" {l}" for l in directory_lines) + lines.append('') + with open(conf_path, 'w') as fd: + fd.write('\n'.join(lines)) + fd.write('\n') + httpd_bin = os.path.join(self.bin_dir, 'httpd') + return self.run([httpd_bin, '-t', '-f', conf_path]) diff --git a/test/modules/aaa/htdocs/digest/default/secret.txt b/test/modules/aaa/htdocs/digest/default/secret.txt new file mode 100644 index 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..3c6079def42 --- /dev/null +++ b/test/modules/aaa/test_002_nonce.py @@ -0,0 +1,129 @@ +"""Nonce lifecycle scenarios: tampered nonces, AuthDigestNonceLifetime +expiry/reissue, a never-expiring nonce, and the one-time-nonce +(AuthDigestNonceLifetime 0) case. +""" + +import time + +from . import digest_client as dc +from .env import AAATestEnv + + +class TestDigestNonce: + + def url(self, env, location, path="secret.txt"): + return env.mkurl("http", "aaa", f"/digest/{location}/{path}") + + def challenge(self, env, location): + r = env.curl_get(self.url(env, location)) + assert r.response["status"] == 401 + return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + + def authenticate(self, env, location, challenge, nc="00000001", + cnonce="nonce-test-cnonce", uri=None): + uri = uri or f"/digest/{location}/secret.txt" + auth = dc.build_authorization( + AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD, + method="GET", uri=uri, nc=nc, cnonce=cnonce) + return env.curl_get(self.url(env, location), options=["-H", f"Authorization: {auth}"]) + + def test_digest_020_tampered_nonce_is_stale(self, env): + challenge = self.challenge(env, "default") + # flip a character in the middle of the opaque nonce blob: it stays + # the right length but its embedded hash no longer verifies. + bad = list(challenge.nonce) + mid = len(bad) // 2 + bad[mid] = 'x' if bad[mid] != 'x' else 'y' + challenge.nonce = ''.join(bad) + r = self.authenticate(env, "default", challenge) + assert r.response["status"] == 401 + new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + assert new_challenge.stale is True + env.httpd_error_log.ignore_recent(lognos=["AH01776"]) + + def test_digest_021_garbage_nonce_hash_is_stale(self, env): + # A nonce must still look like "b64(time)+sha1hex(hash)" (VALID_NONCE + # in mod_auth_digest.c checks length and the '=' padding boundary) to + # even be considered for a hash check; something that doesn't match + # that shape is instead rejected as a malformed header (see + # test_digest_010). Here we keep the genuine time-prefix (so the + # shape is valid) but replace the whole hash suffix with garbage, to + # hit check_nonce()'s "hash is not %s" path distinctly from + # test_digest_020's single-flipped-character tamper. + challenge = self.challenge(env, "default") + time_prefix = challenge.nonce[:-40] + challenge.nonce = time_prefix + ("f" * 40) + r = self.authenticate(env, "default", challenge) + assert r.response["status"] == 401 + new_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + assert new_challenge.stale is True + env.httpd_error_log.ignore_recent(lognos=["AH01776"]) + + def test_digest_022_short_lifetime_expires(self, env): + # AuthDigestNonceLifetime 2 for this location. + challenge = self.challenge(env, "shortlife") + r = self.authenticate(env, "shortlife", challenge) + assert r.response["status"] == 200 + + time.sleep(3) + # same nonce, now past its lifetime -> 401 stale=true + r = self.authenticate(env, "shortlife", challenge) + assert r.response["status"] == 401 + stale_challenge = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"]) + assert stale_challenge.stale is True + env.httpd_error_log.ignore_recent(lognos=["AH01776"]) + + # the fresh nonce from the stale challenge works again + r = self.authenticate(env, "shortlife", stale_challenge) + assert r.response["status"] == 200 + + def test_digest_023_never_expiring_nonce(self, env): + # AuthDigestNonceLifetime -1 for this location: no NcCheck is + # configured, so the identical Authorization line can simply be + # replayed after a delay and must still succeed both times. + challenge = self.challenge(env, "neverexpire") + r1 = self.authenticate(env, "neverexpire", challenge) + assert r1.response["status"] == 200 + + time.sleep(3) + r2 = self.authenticate(env, "neverexpire", challenge) + assert r2.response["status"] == 200 + + def test_digest_024_one_time_nonce_rejects_reuse(self, env): + # AuthDigestNonceLifetime 0: a successful request immediately + # supersedes its nonce (the tracked "last_nonce" moves on to the + # nextnonce from Authentication-Info), so replaying the very same + # nonce right afterwards must fail as stale. Each request against + # this client (success OR failure) advances the tracked nonce again, + # so this test does exactly one success followed by exactly one + # reuse -- no longer chain that would need to account for that. + challenge = self.challenge(env, "onetime") + assert challenge.opaque is not None, \ + "one-time-nonce tracking requires an opaque to identify the client" + + r1 = self.authenticate(env, "onetime", challenge) + assert r1.response["status"] == 200 + ai1 = dc.parse_params(r1.response["header"]["authentication-info"]) + assert "nextnonce" in ai1 + assert ai1["nextnonce"] != challenge.nonce + + # reusing the exact same (now superseded) nonce fails as stale + r2 = self.authenticate(env, "onetime", challenge) + assert r2.response["status"] == 401 + stale_challenge = dc.DigestChallenge.parse(r2.response["header"]["www-authenticate"]) + assert stale_challenge.stale is True + env.httpd_error_log.ignore_recent(lognos=["AH01776"]) + + def test_digest_025_one_time_nonce_chain_continues(self, env): + # Following the nextnonce handed out on a successful response lets + # the client keep authenticating, one hop at a time. + challenge = self.challenge(env, "onetime") + r1 = self.authenticate(env, "onetime", challenge) + assert r1.response["status"] == 200 + ai1 = dc.parse_params(r1.response["header"]["authentication-info"]) + + challenge.nonce = ai1["nextnonce"] + r2 = self.authenticate(env, "onetime", challenge) + assert r2.response["status"] == 200 + ai2 = dc.parse_params(r2.response["header"]["authentication-info"]) + assert ai2["nextnonce"] != ai1["nextnonce"] diff --git a/test/modules/aaa/test_003_nccheck.py b/test/modules/aaa/test_003_nccheck.py new file mode 100644 index 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..101abdd549c --- /dev/null +++ b/test/modules/aaa/test_008_onetime_nccheck.py @@ -0,0 +1,185 @@ +"""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" + + +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 -- AuthDigestShmemSize defaults to 1000 + # bytes, "~ 12 entries" -- and a request with no credentials at all + # allocates an entry, since the challenge it gets back has to carry an + # opaque. An attacker can therefore make gc() discard a client's entry + # for the price of a dozen bare requests. + # + # A captured request must still not be replayable once that has + # happened. 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 + + for _ in range(40): + env.curl_get(self.url(env, location)) + + r = self.send(env, location, captured) + env.httpd_error_log.ignore_recent(lognos=[NC_FAILED]) + assert r.response["status"] == 401, \ + "captured request replayed once the client entry was evicted" diff --git a/test/unit/mod_auth_digest.c b/test/unit/mod_auth_digest.c deleted file mode 100644 index 0d55b26269e..00000000000 --- a/test/unit/mod_auth_digest.c +++ /dev/null @@ -1,107 +0,0 @@ -/* Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include "../httpdunit.h" - -/* XXX This'll almost certainly cause headaches... Need a better way to test - * module helper functions. - * - * - What if the user doesn't want to, or can't, build mod_auth_digest? - * - How do we make sure the Makefile rebuilds us when the module changes? - */ -#include "../../modules/aaa/mod_auth_digest.c" - -/* - * Test Fixture -- runs once per test - */ - -static apr_pool_t *g_pool; -static request_rec *g_request; - -/* XXX: duplicated from the authn.c tests; find a way to pull this into a helper - * library */ -static void mod_auth_digest_setup(void) -{ - if (apr_pool_create(&g_pool, NULL) != APR_SUCCESS) { - exit(1); - } - - /* Stub out just enough of a request_req to get the tests working. - * Unfortunately this couples us to implementation details in the code being - * tested, but the logic to get a "real" request_rec requires spinning up - * half of the world. */ - g_request = apr_pcalloc(g_pool, sizeof(*g_request)); - if (!g_request) { - exit(1); - } - - g_request->pool = g_pool; - g_request->headers_in = apr_table_make(g_pool, 1); - - if (!g_request->headers_in) { - exit(1); - } -} - -static void mod_auth_digest_teardown(void) -{ - apr_pool_destroy(g_pool); -} - -/* - * get_digest_rec() - * - * Note that this function is an implementation detail, so the tests might not - * have the longest lifetime. - */ - -/* TODO: more functional tests! */ - -START_TEST(get_digest_rec_uses_empty_string_for_key_without_value) -{ - digest_header_rec resp = { 0 }; - apr_table_set(g_request->headers_in, "Authorization", - "Digest username=user, nc"); - - get_digest_rec(g_request, &resp); - - ck_assert_str_eq(resp.username, "user"); - ck_assert_str_eq(resp.nonce_count, ""); -} -END_TEST - -/* - * Regression test for CVE-2017-9788. Note that it only reliably fails if APR - * fills memory with something other than NULL; otherwise you can get false - * positives. But it's better than nothing. - */ -START_TEST(get_digest_rec_does_not_use_uninitialized_memory_for_key_without_value) -{ - digest_header_rec resp = { 0 }; - apr_table_set(g_request->headers_in, "Authorization", "Digest nc"); - - get_digest_rec(g_request, &resp); - - ck_assert_str_eq(resp.nonce_count, ""); -} -END_TEST - -/* - * Test Case Boilerplate - */ -HTTPD_BEGIN_TEST_CASE_WITH_FIXTURE(mod_auth_digest, mod_auth_digest_setup, mod_auth_digest_teardown) -#include "test/unit/mod_auth_digest.tests" -HTTPD_END_TEST_CASE