"""`src/dashboards/auth.py` — every authentication decision, tested as a pure function. This is the security boundary of the app, so the negative table is the point of the file: a wrong password, a tampered cookie, a cookie signed with a rotated secret, a CSRF token from another session, a spoofed swarm of source addresses. A branch here that no test reaches is a security decision nobody checked, which is why `auth.py` is covered to ~100%. Nothing sleeps. `now` is an argument on every function that reads a clock, so expiry and backoff are asserted with numbers instead of with `time.sleep(60)`. """ from __future__ import annotations import ast import base64 import hashlib import hmac import sys from pathlib import Path import pytest from dashboards import auth AUTH_SOURCE = Path(__file__).resolve().parents[1] / "src" / "dashboards" / "auth.py" #: Cheap cost for tests that are not about the cost. The default (600k) is pinned once, in #: `test_the_default_cost_is_the_owasp_figure`, so the rest of the suite stays fast. CHEAP = 1_000 SECRET = "0" * 64 NOW = 1_700_000_000.0 SALT = "00" * 16 DIGEST = "a" * 64 def _b64(raw: bytes) -> str: """The wire form of a base64url payload with the padding stripped. Reimplemented here rather than calling the module's own encoder on purpose: a test that builds a tampered token with `_b64e` cannot notice that the format changed. """ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=") def _token(secret: str, payload: str) -> str: """A correctly-signed token for an arbitrary payload string.""" signature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).digest() return f"{_b64(payload.encode())}.{_b64(signature)}" def _tree() -> ast.Module: """`auth.py` parsed, for the structural pins below.""" return ast.parse(AUTH_SOURCE.read_text()) def _imported_modules() -> set[str]: """Every top-level module `auth.py` imports.""" names: set[str] = set() for node in ast.walk(_tree()): if isinstance(node, ast.Import): names.update(alias.name.split(".")[0] for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: names.add(node.module.split(".")[0]) return names # --- the vocabulary ---------------------------------------------------------------------- # def test_the_public_names_are_the_ones_the_middleware_and_pages_share() -> None: """Pinned because task 03 sets the cookie and task 04 reads the header by these names.""" assert auth.ADMIN_USER == "admin" assert auth.SESSION_COOKIE == "dash_session" assert auth.CSRF_HEADER == "X-CSRF-Token" assert frozenset({"GET", "HEAD", "OPTIONS"}) == auth.SAFE_METHODS assert "POST" not in auth.SAFE_METHODS assert auth.PBKDF2_ALGO == "pbkdf2_sha256" assert auth.SESSION_HOURS == 720.0 # The two rows the operator has to be able to name — in the reset command, in `/settings`, # and in every test that seeds a database by hand. assert auth.META_VERIFIER == "auth.verifier" assert auth.META_SESSION_SECRET == "auth.session_secret" def test_the_primitives_import_nothing_third_party() -> None: """No `itsdangerous`, no `bcrypt`, no session store (PLAN.md's locked toolchain). A dependency would not fail a test by itself — it would simply become the way this project authenticates. This fails the day someone adds one. """ outside = {name for name in _imported_modules() if name not in sys.stdlib_module_names} assert outside == set() def test_the_module_cannot_echo_a_credential() -> None: """`AGENTS.md` invariant 6, applied to the admin's own password. The docstring says nothing here can echo a secret; this makes it a fact instead of an intention. `logging` is allowed (task 02 added exactly one startup warning), but the shape of that call is pinned: it must pass a bare module-level **string constant**, never an f-string, a concatenation, or a local — so there is no expression in this file that could put a verifier, a password, or a prefix of either on a log line. """ imported = _imported_modules() assert imported & {"sys", "warnings", "traceback"} == set() called: set[str] = set() for node in ast.walk(_tree()): if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): called.add(node.func.id) assert called & {"print", "pprint", "input", "vars", "locals"} == set() tree = _tree() constants = { node.id for stmt in tree.body if isinstance(stmt, ast.Assign) and isinstance(stmt.value, ast.Constant) for node in stmt.targets if isinstance(node, ast.Name) } for node in ast.walk(tree): if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)): continue if not (isinstance(node.func.value, ast.Name) and node.func.value.id == "_log"): continue assert len(node.args) == 1, "the one log call takes one message" message = node.args[0] assert isinstance(message, ast.Name) and message.id in constants, ( "the logged message must be a module-level literal, not an interpolation") assert isinstance(ast.literal_eval(_constant_value(tree, message.id)), str) def _constant_value(tree: ast.Module, name: str) -> ast.Constant: """The literal behind a module-level constant name.""" for stmt in tree.body: if isinstance(stmt, ast.Assign) and any( isinstance(t, ast.Name) and t.id == name for t in stmt.targets ): assert isinstance(stmt.value, ast.Constant) return stmt.value raise AssertionError(f"{name} is not a module-level constant") def test_the_primitives_never_raise_and_the_bootstrap_raises_exactly_twice() -> None: """Every primitive's failure mode is a `False`/`None` the caller turns into a status code. An exception would carry the reason, and the reason is a credential (`bad salt '3f9a…'`) in a traceback the health record and the event feed both surface. The negative tables below are the behavioural half of this structural pin. The two documented exceptions are the bootstrap (task 02), and they are enumerated rather than merely tolerated: `load_auth_state` refuses to start a dashboard with no password anywhere, and `update_verifier` refuses to store an empty one. Both raise fixed text, and neither may interpolate a password, a verifier, a secret, a salt or a digest into it. """ tree = _tree() allowed = {"load_auth_state": "RuntimeError", "update_verifier": "ValueError"} credential_names = {"password", "verifier", "secret", "token", "salt", "digest", "provided"} per_function: dict[str, list[ast.Raise]] = {} for func in (n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)): found = [n for n in ast.walk(func) if isinstance(n, ast.Raise)] if found: per_function[func.name] = found assert set(per_function) == set(allowed) for name, expected in allowed.items(): for node in per_function[name]: exc = node.exc assert isinstance(exc, ast.Call) and isinstance(exc.func, ast.Name) assert exc.func.id == expected, f"{name} must raise {expected}" assert node.cause is None for arg in exc.args: assert not _mentions(arg, credential_names), f"{name} raises with a credential" # nothing outside those two functions raises at all module_raises = [n for n in ast.walk(tree) if isinstance(n, ast.Raise)] assert len(module_raises) == sum(len(v) for v in per_function.values()) def _mentions(node: ast.AST, names: set[str]) -> bool: """Does this expression name one of `names` anywhere inside it?""" return any(isinstance(child, ast.Name) and child.id in names for child in ast.walk(node)) # --- password verifier ------------------------------------------------------------------- # def test_a_hash_round_trips_and_describes_itself() -> None: encoded = auth.hash_password("testpass", iterations=CHEAP) algo, iterations, salt, digest = encoded.split("$") assert algo == auth.PBKDF2_ALGO assert iterations == str(CHEAP) assert len(salt) == auth.PBKDF2_SALT_BYTES * 2 assert len(digest) == auth.PBKDF2_HASH_BYTES * 2 assert auth.verify_password("testpass", encoded) is True def test_the_default_cost_is_the_owasp_figure() -> None: """The completion criterion for this task, end to end, at the real cost.""" assert auth.PBKDF2_ITERATIONS == 600_000 encoded = auth.hash_password("testpass") assert encoded.startswith(f"{auth.PBKDF2_ALGO}${auth.PBKDF2_ITERATIONS}$") assert auth.verify_password("testpass", encoded) is True assert auth.verify_password("wrong", encoded) is False assert auth.verify_password("testpass", "junk") is False def test_a_wrong_password_is_false_and_nothing_else() -> None: encoded = auth.hash_password("correct horse", iterations=CHEAP) assert auth.verify_password("", encoded) is False assert auth.verify_password("Correct horse", encoded) is False assert auth.verify_password("correct horse ", encoded) is False def test_the_password_is_encoded_as_utf8() -> None: """`pässwörd` round-trips — proof the encode is utf-8 and not a lossy 8-bit one. A latin-1 or ascii encode would either raise or make two distinct passwords verify against one digest; non-ASCII input is the only way to tell them apart. """ for password in ("pässwörd", "пароль", "パスワード", "🔥🔥"): encoded = auth.hash_password(password, iterations=CHEAP) assert auth.verify_password(password, encoded) is True assert auth.verify_password("password", encoded) is False def test_a_lone_surrogate_verifies_instead_of_raising() -> None: """`.encode("utf-8")` raises on a surrogate; the login path must not.""" password = "\ud800" encoded = auth.hash_password(password, iterations=CHEAP) assert auth.verify_password(password, encoded) is True assert auth.verify_password("\ud801", encoded) is False def test_the_salt_is_fresh_per_hash() -> None: """Two hashes of one password share no byte of digest — or one table cracks both.""" first = auth.hash_password("testpass", iterations=CHEAP) second = auth.hash_password("testpass", iterations=CHEAP) assert first != second assert first.split("$")[2] != second.split("$")[2] assert first.split("$")[3] != second.split("$")[3] assert auth.verify_password("testpass", first) is True assert auth.verify_password("testpass", second) is True def test_verification_uses_the_stored_iteration_count() -> None: """Bumping the cost field must not silently accept the password. The iteration count feeds the digest, so rewriting it is corruption, not an upgrade: `needs_rehash` is what asks for a replacement, never `verify_password`. """ encoded = auth.hash_password("testpass", iterations=CHEAP) algo, _iterations, salt, digest = encoded.split("$") assert auth.verify_password("testpass", f"{algo}${CHEAP * 2}${salt}${digest}") is False #: Every malformed verifier shape. `verify_password` answers `False` to all of them — the same #: answer a wrong password gets — because a damaged `auth.verifier` row has to read as a login #: error the operator can act on, not a 500 on the page whose job is to say "wrong password". MALFORMED: list[tuple[str, str]] = [ ("empty", ""), ("junk", "junk"), ("one field", auth.PBKDF2_ALGO), ("three fields", f"{auth.PBKDF2_ALGO}${CHEAP}$00112233"), ("five fields", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}$extra"), ("leading dollar", f"${auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}"), ("unknown algo", f"md5${CHEAP}${SALT}${DIGEST}"), ("plaintext row", "testpass"), ("non-numeric iterations", f"{auth.PBKDF2_ALGO}$six$00112233${DIGEST}"), ("float iterations", f"{auth.PBKDF2_ALGO}$1000.5${SALT}${DIGEST}"), ("zero iterations", f"{auth.PBKDF2_ALGO}$0${SALT}${DIGEST}"), ("negative iterations", f"{auth.PBKDF2_ALGO}$-5${SALT}${DIGEST}"), ("absurd iterations", f"{auth.PBKDF2_ALGO}$999999999999${SALT}${DIGEST}"), ("odd-length salt", f"{auth.PBKDF2_ALGO}${CHEAP}$00112233a${DIGEST}"), ("non-hex salt", f"{auth.PBKDF2_ALGO}${CHEAP}$zzzzzzzz${DIGEST}"), ("empty salt", f"{auth.PBKDF2_ALGO}${CHEAP}$$0123456789abcdef"), ("odd-length hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}${DIGEST}a"), ("non-hex hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "z" * 64), ("short hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "a" * 32), ("long hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$" + "a" * 96), ("empty hash", f"{auth.PBKDF2_ALGO}${CHEAP}${SALT}$"), ] @pytest.mark.parametrize(("label", "encoded"), MALFORMED, ids=[label for label, _ in MALFORMED]) def test_a_malformed_verifier_is_neither_true_nor_an_exception(label: str, encoded: str) -> None: assert auth.verify_password("testpass", encoded) is False, label assert auth.verify_password("", encoded) is False, label def test_a_corrupt_row_does_not_make_the_absurd_cost_free() -> None: """The iteration ceiling bounds the CPU a damaged row can demand of the login page.""" assert auth.PBKDF2_MAX_ITERATIONS > auth.PBKDF2_ITERATIONS absurd = "9" * 30 assert auth.verify_password("testpass", f"{auth.PBKDF2_ALGO}${absurd}${SALT}${DIGEST}") is False def test_needs_rehash_tracks_the_configured_cost() -> None: weak = auth.hash_password("testpass", iterations=1_000) strong = auth.hash_password("testpass", iterations=100_000) # the case from the task: a 1_000-iteration hash against the default cost assert auth.needs_rehash(weak, iterations=auth.PBKDF2_ITERATIONS) is True assert auth.needs_rehash(strong, iterations=100_000) is False # equal is not below assert auth.needs_rehash(strong, iterations=1_000) is False # above is not below assert auth.needs_rehash(weak, iterations=1_000) is False assert auth.needs_rehash(weak, iterations=999) is False assert auth.needs_rehash(auth.hash_password("x"), iterations=auth.PBKDF2_ITERATIONS) is False @pytest.mark.parametrize("encoded", [ "", "junk", f"md5${CHEAP}$00112233${DIGEST}", f"{auth.PBKDF2_ALGO}$many$00112233${DIGEST}", ]) def test_needs_rehash_wants_a_replacement_for_anything_off_shape(encoded: str) -> None: """Unparseable or another algorithm → `True`: that is not the shape we want stored.""" assert auth.needs_rehash(encoded, iterations=auth.PBKDF2_ITERATIONS) is True # --- sessions ---------------------------------------------------------------------------- # def test_a_minted_session_round_trips() -> None: token = auth.mint_session(SECRET, now=NOW) session = auth.read_session(SECRET, token, now=NOW + 60) assert session is not None assert session.username == auth.ADMIN_USER assert session.expires_at == pytest.approx(NOW + auth.SESSION_HOURS * 3600.0, abs=1.0) assert session.token == token def test_the_token_is_two_urlsafe_base64_parts_with_no_padding() -> None: token = auth.mint_session(SECRET, now=NOW) payload_b64, signature_b64 = token.split(".") assert "=" not in token assert set(token) <= set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.") assert token.count(".") == 1 # the separator only: nothing inside the payload or signature assert base64.urlsafe_b64decode(payload_b64 + "==").decode().startswith("admin|") assert len(base64.urlsafe_b64decode(signature_b64 + "==")) == 32 def test_a_session_can_be_minted_for_another_lifetime() -> None: token = auth.mint_session(SECRET, hours=1.0, now=NOW) assert auth.read_session(SECRET, token, now=NOW + 3_599) is not None assert auth.read_session(SECRET, token, now=NOW + 3_600) is None # the expiry is exclusive def test_a_new_secret_is_256_bits_of_hex_and_unique() -> None: minted = {auth.new_secret() for _ in range(8)} assert len(minted) == 8 for secret in minted: assert len(secret) == 64 int(secret, 16) # raises if it is not hex def test_an_expired_session_is_none() -> None: token = auth.mint_session(SECRET, now=NOW) assert auth.read_session(SECRET, token, now=NOW + 90 * 24 * 3600) is None @pytest.mark.parametrize("token", [ "", "no-dot-here", ".YWRtaW58OTk5OTk5OTk5OQ", "YWRtaW58OTk5OTk5OTk5OQ.", "!!!.!!!", "YWRtaW58OTk5OTk5OTk5OQ.!!!", "....", ]) def test_a_token_that_is_not_a_token_is_none(token: str) -> None: assert auth.read_session(SECRET, token, now=NOW) is None def test_a_flipped_signature_byte_is_none() -> None: token = auth.mint_session(SECRET, now=NOW) payload_b64, signature_b64 = token.split(".") first = signature_b64[0] flipped = ("B" if first != "B" else "C") + signature_b64[1:] assert auth.read_session(SECRET, f"{payload_b64}.{flipped}", now=NOW) is None def test_a_truncated_token_is_none() -> None: token = auth.mint_session(SECRET, now=NOW) assert auth.read_session(SECRET, token[:-4], now=NOW) is None assert auth.read_session(SECRET, token[4:], now=NOW) is None def test_a_cookie_signed_by_a_rotated_secret_is_none() -> None: """Rotating `auth.session_secret` is how signing every other browser out works.""" token = auth.mint_session(SECRET, now=NOW) assert auth.read_session("f" * 64, token, now=NOW) is None def test_a_tampered_username_is_none() -> None: token = auth.mint_session(SECRET, now=NOW) payload_b64, signature_b64 = token.split(".") payload = base64.urlsafe_b64decode(payload_b64 + "==").decode() forged = _b64(payload.replace("admin", "root").encode()) assert forged != payload_b64 assert auth.read_session(SECRET, f"{forged}.{signature_b64}", now=NOW) is None def test_a_payload_that_is_not_utf8_is_none() -> None: token = auth.mint_session(SECRET, now=NOW) _signed_payload, signature_b64 = token.split(".") assert auth.read_session(SECRET, f"{_b64(b'\xff\xff')}.{signature_b64}", now=NOW) is None @pytest.mark.parametrize(("label", "payload"), [ ("no bar", "admin"), ("no username", f"|{NOW + 3600:.0f}"), ("non-numeric expiry", "admin|soon"), ("empty expiry", "admin|"), ("nan expiry", "admin|nan"), ("inf expiry", "admin|inf"), ("-inf expiry", "admin|-inf"), ("empty payload", ""), ]) def test_a_signed_payload_that_is_not_a_session_is_none(label: str, payload: str) -> None: """Correctly signed, still rejected: the signature is necessary and not sufficient.""" assert auth.read_session(SECRET, _token(SECRET, payload), now=NOW) is None, label def test_the_signature_covers_the_expiry_too() -> None: """Pushing a session's expiry out is the same class of forgery as renaming the user.""" token = auth.mint_session(SECRET, hours=1.0, now=NOW) session = auth.read_session(SECRET, token, now=NOW) assert session is not None later = f"admin|{session.expires_at + 10 * 3600:.0f}" # without the secret, an extended payload is just an unsigned string assert auth.read_session(SECRET, _token("f" * 64, later), now=NOW) is None # and the real token does not outlive the expiry it was minted with assert auth.read_session(SECRET, token, now=NOW + 2 * 3600) is None # --- CSRF -------------------------------------------------------------------------------- # def test_the_csrf_token_is_bound_to_its_session() -> None: token = auth.mint_session(SECRET, now=NOW) other = auth.mint_session(SECRET, hours=1.0, now=NOW) csrf = auth.mint_csrf(SECRET, token) assert len(csrf) == 64 assert csrf == auth.mint_csrf(SECRET, token) # stateless: no rotation, no storage assert csrf != auth.mint_csrf(SECRET, other) assert auth.verify_csrf(SECRET, token, csrf) is True # a token stolen from one session cannot authorise another's write assert auth.verify_csrf(SECRET, other, csrf) is False @pytest.mark.parametrize("provided", [None, "", " ", "deadbeef", " " * 64]) def test_a_missing_or_wrong_csrf_token_is_false(provided: str | None) -> None: token = auth.mint_session(SECRET, now=NOW) assert auth.verify_csrf(SECRET, token, provided) is False def test_csrf_verification_survives_an_empty_session_and_a_non_ascii_header() -> None: token = auth.mint_session(SECRET, now=NOW) csrf = auth.mint_csrf(SECRET, token) assert auth.verify_csrf(SECRET, "", csrf) is False assert auth.verify_csrf(SECRET, "", "") is False assert auth.verify_csrf(SECRET, token, f"{csrf}päss") is False assert auth.verify_csrf(SECRET, token, "\ud800" + csrf) is False assert auth.verify_csrf(SECRET, token, csrf) is True # the good case still works # --- login throttle ---------------------------------------------------------------------- # def test_the_defaults_are_five_attempts_then_a_minute() -> None: throttle = auth.LoginThrottle() assert throttle.max_attempts == 5 assert throttle.base_seconds == 60.0 assert throttle.max_seconds == 3600.0 def test_five_failures_are_allowed_and_the_sixth_is_blocked() -> None: throttle = auth.LoginThrottle() for _ in range(5): assert throttle.retry_after("10.0.0.1", now=100.0) == 0.0 throttle.failure("10.0.0.1", now=100.0) assert throttle.retry_after("10.0.0.1", now=100.0) == pytest.approx(60.0) assert throttle.retry_after("10.0.0.9", now=100.0) == 0.0 # another address, another bucket def test_an_unknown_key_is_never_blocked() -> None: assert auth.LoginThrottle().retry_after("nobody", now=1.0) == 0.0 def test_the_backoff_doubles_and_caps_at_max_seconds() -> None: throttle = auth.LoginThrottle(max_attempts=3, base_seconds=10.0, max_seconds=100.0) now = 1_000.0 assert throttle.failure("k", now=now) == 0.0 # 1st assert throttle.failure("k", now=now) == 0.0 # 2nd assert throttle.failure("k", now=now) == 10.0 # 3rd trips the block assert throttle.retry_after("k", now=now) == 10.0 now += 10 assert throttle.failure("k", now=now) == 20.0 now += 20 assert throttle.failure("k", now=now) == 40.0 now += 40 assert throttle.failure("k", now=now) == 80.0 now += 80 assert throttle.failure("k", now=now) == 100.0 # capped now += 100 assert throttle.failure("k", now=now) == 100.0 # and stays capped def test_the_block_lapses_on_its_own() -> None: throttle = auth.LoginThrottle(max_attempts=1, base_seconds=30.0) throttle.failure("k", now=500.0) assert throttle.retry_after("k", now=500.0) == 30.0 assert throttle.retry_after("k", now=515.0) == pytest.approx(15.0) assert throttle.retry_after("k", now=530.0) == 0.0 def test_a_failure_during_a_block_never_shortens_it() -> None: throttle = auth.LoginThrottle(max_attempts=2, base_seconds=60.0) throttle.failure("k", now=1_000.0) throttle.failure("k", now=1_000.0) # blocked until 1_060 assert throttle.retry_after("k", now=1_000.0) == 60.0 throttle.failure("k", now=1_010.0) # hammering during the lockout assert throttle.retry_after("k", now=1_010.0) > 60.0 def test_a_successful_login_clears_the_key() -> None: throttle = auth.LoginThrottle(max_attempts=3, base_seconds=60.0) for _ in range(2): throttle.failure("k", now=10.0) throttle.success("k") assert throttle.retry_after("k", now=10.0) == 0.0 # the count is gone too: the next block starts at the base step, not a doubled one throttle.failure("k", now=10.0) throttle.failure("k", now=10.0) assert throttle.failure("k", now=10.0) == 60.0 throttle.success("k") assert throttle.retry_after("k", now=10.0) == 0.0 throttle.success("k") # clearing an unknown key is not an error def test_prune_forgets_only_keys_that_can_no_longer_affect_a_decision() -> None: throttle = auth.LoginThrottle(max_attempts=2, base_seconds=60.0, max_seconds=600.0) throttle.failure("quiet", now=0.0) # under the block threshold, then idle throttle.failure("blocked", now=0.0) throttle.failure("blocked", now=0.0) # blocked until 60 assert throttle.tracked == 2 assert throttle.prune(now=100.0) == 0 # 100 - 0 < max_seconds: too soon to forget assert throttle.retry_after("blocked", now=100.0) == 0.0 assert throttle.prune(now=700.0) == 2 assert throttle.tracked == 0 assert throttle.prune(now=700.0) == 0 def test_the_dict_is_bounded_against_a_swarm_of_source_addresses() -> None: """A spoofed spray must not turn the dashboard's own memory into the outage.""" throttle = auth.LoginThrottle() for _ in range(5): throttle.failure("attacker", now=5_000.0) assert throttle.retry_after("attacker", now=5_000.0) == pytest.approx(60.0) for index in range(auth.THROTTLE_MAX_KEYS * 2): throttle.failure(f"swarm-{index}", now=float(index)) assert throttle.tracked <= auth.THROTTLE_MAX_KEYS # what pressure drops is the least-recently-active key, never the one still attacking assert throttle.retry_after("attacker", now=5_000.0) == pytest.approx(60.0) def test_the_length_guard_prunes_before_it_evicts() -> None: throttle = auth.LoginThrottle() for index in range(auth.THROTTLE_MAX_KEYS): throttle.failure(f"k{index}", now=0.0) assert throttle.tracked == auth.THROTTLE_MAX_KEYS # every entry has been idle past max_seconds, so pruning alone makes the room throttle.failure("newest", now=10_000.0) assert throttle.tracked == 1 def test_silly_limits_are_clamped_instead_of_disabling_the_throttle() -> None: throttle = auth.LoginThrottle(max_attempts=0, base_seconds=30.0, max_seconds=5.0) assert throttle.max_attempts == 1 assert throttle.base_seconds == 30.0 assert throttle.max_seconds == 30.0 assert throttle.failure("k", now=0.0) == 30.0 # --- origin / Sec-Fetch ------------------------------------------------------------------ # def test_the_origin_gate_accepts_this_app_and_the_allow_list() -> None: own = "https://dash.example" assert auth.origin_matches(own, own) is True assert auth.origin_matches("https://dash.example/", own) is True assert auth.origin_matches("HTTPS://DASH.EXAMPLE", own) is True assert auth.origin_matches(" https://dash.example ", own) is True extra = ["https://lab.internal:8090"] assert auth.origin_matches("https://lab.internal:8090", own, extra) is True @pytest.mark.parametrize("origin", [ "http://evil.example", "https://evil.example", "https://dash.example:9999", "http://dash.example", # a different scheme is not the same origin "https://dash.example.evil.net", "null", ]) def test_the_origin_gate_rejects_everything_else(origin: str) -> None: assert auth.origin_matches(origin, "https://dash.example") is False def test_an_empty_origin_is_not_a_match_and_the_caller_gates_on_presence() -> None: """The empty string is not this app's origin; `if origin and not origin_matches(…)` is the caller's check, so curl (no Origin header at all) is not rejected here.""" assert auth.origin_matches("", "https://dash.example") is False assert auth.origin_matches(" ", "https://dash.example") is False assert auth.origin_matches("", "") is False def test_the_sec_fetch_gate_allows_same_origin_and_non_browsers_only() -> None: assert auth.sec_fetch_site_allowed(None) is True assert auth.sec_fetch_site_allowed("same-origin") is True assert auth.sec_fetch_site_allowed("none") is True assert auth.sec_fetch_site_allowed(" Same-Origin ") is True assert frozenset({"same-origin", "none"}) == auth.ALLOWED_SEC_FETCH_SITE @pytest.mark.parametrize("value", [ "cross-site", "cross-origin", "same-site", "SAME-SITE", "", " ", "sourcerer", "same-site ", ]) def test_an_unrecognised_sec_fetch_site_is_rejected(value: str) -> None: """Unknown → reject: a browser inventing a value is a browser doing something we did not reason about, so the safe answer is the one this returns.""" assert auth.sec_fetch_site_allowed(value) is False