**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`.
630 lines
28 KiB
Python
630 lines
28 KiB
Python
"""`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
|