blob: ed6af5bb69dab3aff0a4bb9c4a3ff13821d5fb31 [file]
"""Which client entries are discarded when the table is full.
The client table is a fixed-size shared memory segment (AuthDigestShmemSize,
pinned small by conftest here), and gc() makes room by discarding one entry
from each bucket. An entry is allocated for any request which needs a
challenge, including one carrying no credentials at all, since the challenge
has to carry the identifier the client will be tracked by. An attacker can
therefore fill the table with bare requests as fast as it can send them.
What that costs the clients already using the server depends on which entry
gc() picks. Discarding a client which has authenticated costs it a request:
its nonce-count and last-used nonce go with the entry, so its next request
is answered with a fresh challenge (stale=true) and has to be retried.
Discarding a client which has never authenticated costs nothing at all --
the entry records nothing yet, and the client simply gets a new identifier
with its next challenge.
So gc() prefers the entries which have never been used to authenticate,
which are exactly the ones a flood of unauthenticated requests creates.
"Never used to authenticate" is last_nonce_time == 0, which is exact in both
of the configurations that track clients: an accepted request stores the
time its nonce was generated at, which is a wall-clock time for a nonce with
a lifetime and a counter which starts at 1 for a one-time nonce. Neither is
ever 0. The tests below run against both to pin that.
"""
import pytest
from . import digest_client as dc
from .env import AAATestEnv
NC_FAILED = "AH01774"
CLIENT_UNKNOWN = "AH10618"
# A location tracking clients for the nonce-count, and one tracking them for
# one-time nonces: the two put different kinds of value in last_nonce_time.
BOTH = ["nccheck", "onetime"]
class TestDigestEviction:
def url(self, env, location):
return env.mkurl("http", "aaa", f"/digest/{location}/secret.txt")
def uri(self, location):
return f"/digest/{location}/secret.txt"
def challenge(self, env, location):
r = env.curl_get(self.url(env, location))
assert r.response["status"] == 401
return dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
def header(self, location, challenge, nc="00000001", cnonce="eviction-cnonce"):
return dc.build_authorization(
AAATestEnv.DIGEST_USER, challenge, AAATestEnv.DIGEST_PASSWORD,
method="GET", uri=self.uri(location), nc=nc, cnonce=cnonce)
def send(self, env, location, auth):
return env.curl_get(self.url(env, location),
options=["-H", f"Authorization: {auth}"])
def advance(self, r, challenge):
"""Prepare the client's next request, and return the nc to send with
it. A one-time nonce is replaced by the nextnonce just handed out,
and the count restarts at 1 for it; otherwise the nonce stays and the
count goes up."""
ai = r.response["header"].get("authentication-info")
if ai:
params = dc.parse_params(ai)
if "nextnonce" in params:
challenge.nonce = params["nextnonce"]
return "00000001"
return "00000002"
def flood(self, env, location, count):
"""Bare requests, each of which allocates a client entry."""
for _ in range(count):
env.curl_get(self.url(env, location))
@pytest.mark.parametrize("location", BOTH)
def test_digest_100_authenticated_client_survives_a_flood(self, env, location):
# The legitimate client authenticates, so its entry now records the
# nonce it used.
challenge = self.challenge(env, location)
r = self.send(env, location, self.header(location, challenge))
assert r.response["status"] == 200
nc = self.advance(r, challenge)
# An attacker fills the table several times over with requests which
# carry no credentials at all.
self.flood(env, location, 60)
# The victim carries on. Its entry must still be there: it is the
# only one in the table which is worth keeping.
r = self.send(env, location, self.header(location, challenge, nc))
env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
assert r.response["status"] == 200, \
"a flood of unauthenticated requests evicted an authenticated client"
@pytest.mark.parametrize("location", BOTH)
def test_digest_101_unused_entries_are_the_ones_discarded(self, env, location):
# The counterpart: the entries a flood creates are themselves the
# ones discarded, so a flood cannot fill the table permanently. The
# opaque handed out at the start of one no longer names an entry by
# the end, which the server reports by minting a new one rather than
# echoing it back -- and as a stale challenge, since nothing was
# wrong with the client's credentials.
first = self.challenge(env, location)
self.flood(env, location, 60)
r = self.send(env, location, self.header(location, first))
env.httpd_error_log.ignore_recent(lognos=[NC_FAILED, CLIENT_UNKNOWN])
assert r.response["status"] == 401
again = dc.DigestChallenge.parse(r.response["header"]["www-authenticate"])
assert again.opaque != first.opaque, \
"the unused entry should have been discarded by the flood"
assert again.stale, \
"a client whose unused entry was discarded should be re-challenged " \
"as stale, not told its authentication failed"