| """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 sys | |
| import pytest | |
| from pyhttpd.backport import not_backported | |
| from . import digest_client as dc | |
| from .env import AAATestEnv | |
| NC_FAILED = "AH01774" | |
| NONCE_INVALID = "AH01776" | |
| CLIENT_UNKNOWN = "AH10618" | |
| _WIN32 = sys.platform == "win32" | |
| # 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"] | |
| # on the tests that fail without it (the others pass there as well) | |
| NOT_BACKPORTED = not_backported("r1937713", "evicting never-authenticated clients first") | |
| 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.xfail(sys.platform == "win32", reason= | |
| "mpm_winnt child is a separate process, " | |
| "ap_retained_data does not survive restart") | |
| @pytest.mark.parametrize("location", BOTH) | |
| @NOT_BACKPORTED | |
| 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] | |
| + ([NONCE_INVALID] if _WIN32 else [])) | |
| 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] | |
| + ([NONCE_INVALID] if _WIN32 else [])) | |
| 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" |