phase: 11_admin_login

**Verified (final pass, phase 11 — all 7 tasks already in `complete/`)**
- Gate from the wire on a restarted dev server: `/system`→`303 /login?next=/system` (`/llm?range=1h`→`/login?next=/llm%3Frange%3D1h`), `/api/summary`+`/api/config`→`401 {"ok":false,…}`, `/api/health`→`200`; signed-in `GET` of all six pages `200`, `/login`→`303 /`.
- Auth flows on a throwaway server (`:8199`, `/tmp` DB — dev `auth.verifier` untouched): wrong pw `401`, `testpass` `200`, password change `200`, **old cookie `401` / new cookie `200`**, old pw `401` / new pw `200`, short new `422`, wrong current `403`, cookie write without CSRF `403`, logout `200`; 5 failures then `429 retry-after: 60`; `login-denied`/`password-changed`/`password-denied` events; `meta` = the two auth keys.
- Fail-closed boot (no seed, empty DB): **exit 3**, message names `DASH_ADMIN_PASSWORD`, `auth.verifier`, and the `DELETE FROM meta …` reset. `dev-server.sh status` + the Containerfile `HEALTHCHECK` python one-liner both `200`/exit 0 unauthenticated.
- Live payloads unchanged (phases 01–10 walk): `/api/summary` keys + 8 alerts + hosts 3/2 live; UPS `simulated:false` with charge/load/runtime severity; deskwork `nvme0n1` 51 °C/19 159 h/11 % wear; gateway `smartctl_available:false`; driveripper `unreachable` stub.

**Tests/lint (all green)**: `bash .agents/validate.sh` → exit 0 — `1140 passed, 11 skipped`, coverage `98.62%` (floor 94), ruff `All checks passed!`, pyright `0 errors`, `node --check` clean, repo hygiene clean. `uv run pytest -m live --live` → `8 passed, 3 failed`, all `driveripper: 10.1.0.126:9120 … ConnectError` (pre-existing, auth-free; probe confirms nothing listening).

**Completion criteria**: all met except the live-suite caveat above — installing the driveripper agent needs root on that host (no `ops` grant), so per the live-infra rule I made **no live change**; task 07's five screenshots (`auth-login/-rejected/-locked-out/-signed-in-overview/-settings.png`) remain the browser evidence, and the login/rotation/lockout flows were re-proved over HTTP this pass.

**Docs**: README — fixed "only place the CSRF token is published" (login/password responses mint one), added the undocumented `DASH_RELOAD` row, stated that Sign out ends only this browser's cookie. `AGENTS.md` — new known gap 8 (stateless logout cannot revoke a copied token), gap-4 dev-store sizes dated, fleet section notes `data/` is gitignored. `api.py` — same `status` docstring imprecision fixed. Verified the 4 `DASH_*` rows, reset command, seed-wins rule and invariant 12 against `app.py`/`config.py`.

**Deviations**: no dependency bump (`uv pip list --outdated` advisory: certifi/fastapi/starlette — phase added none); verification notes appended to `data/logs/NOTES-auth.md` §10.

**Next pending phase**: `12_nut_self_test_verdict`.
This commit is contained in:
2026-10-19 07:45:44 -04:00
parent eb4788cba1
commit 1734d81e48
75 changed files with 6941 additions and 121 deletions
+629
View File
@@ -0,0 +1,629 @@
"""`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