Files
Tom FluxandClaude Opus 5 96f8d89442 Fix silent login loop caused by strict cookie parsing
The admin UI accepted the password, issued a valid session cookie, then
bounced straight back to the login page with no error shown.

http.cookies.SimpleCookie.load() silently discards the remainder of a Cookie
header the moment it meets a value it considers illegal, rather than raising
or skipping just that entry. A neighbouring cookie with a JSON-ish value such
as prefs={"a":1} is enough. Everything after it — including our session —
becomes invisible, so a perfectly good login looked like a failed one.

curl never showed it because curl only sends the one cookie. A browser sends
every cookie on the domain, and tube.jihakuz.xyz previously served
TubeArchivist alongside several sibling services on jihakuz.xyz.

Replaced with a tolerant hand-rolled parser and covered it with regression
tests for nine hostile neighbour values.

Also log successful logins at INFO. Only failures were logged, which made
"password rejected" and "session did not stick" indistinguishable from the
journal and sent the diagnosis down the wrong path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:52:38 +01:00

192 lines
5.8 KiB
Python

"""Password hashing, session cookies and CSRF tokens.
The admin UI is publicly reachable over HTTPS, so this has to be real. The design
goal from specs.md §11 is "log in once per device, effectively never again",
which means a long-lived signed cookie rather than HTTP basic auth.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
SCRYPT_N = 2**14
SCRYPT_R = 8
SCRYPT_P = 1
DKLEN = 32
SESSION_MAX_AGE = 365 * 24 * 3600 # one year
COOKIE_NAME = "yta_session"
# Login throttling: after this many consecutive failures from one address, refuse
# for LOCKOUT_SECONDS regardless of whether the password is right.
MAX_FAILURES = 5
LOCKOUT_SECONDS = 60
def _b64(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _unb64(text: str) -> bytes:
padding = "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(text + padding)
# --------------------------------------------------------------------------
# passwords
def hash_password(password: str, *, salt: bytes | None = None) -> str:
salt = salt if salt is not None else secrets.token_bytes(16)
derived = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=SCRYPT_N,
r=SCRYPT_R,
p=SCRYPT_P,
dklen=DKLEN,
)
return f"scrypt${SCRYPT_N}${SCRYPT_R}${SCRYPT_P}${_b64(salt)}${_b64(derived)}"
def verify_password(stored: str, password: str) -> bool:
if not stored:
return False
try:
scheme, n, r, p, salt_b64, hash_b64 = stored.split("$")
if scheme != "scrypt":
return False
derived = hashlib.scrypt(
password.encode("utf-8"),
salt=_unb64(salt_b64),
n=int(n),
r=int(r),
p=int(p),
dklen=len(_unb64(hash_b64)),
)
except (ValueError, TypeError):
return False
return hmac.compare_digest(derived, _unb64(hash_b64))
# --------------------------------------------------------------------------
# sessions
def new_secret() -> str:
return _b64(secrets.token_bytes(32))
def _sign(secret: str, payload: bytes) -> str:
return _b64(hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest())
def issue_session(secret: str, *, issued_at: float | None = None) -> str:
payload = json.dumps(
{"iat": int(issued_at if issued_at is not None else time.time())},
separators=(",", ":"),
).encode("utf-8")
return f"{_b64(payload)}.{_sign(secret, payload)}"
def verify_session(secret: str, token: str, *, now: float | None = None) -> bool:
if not token or not secret:
return False
try:
payload_b64, signature = token.split(".", 1)
payload = _unb64(payload_b64)
except (ValueError, TypeError):
return False
if not hmac.compare_digest(_sign(secret, payload), signature):
return False
try:
issued_at = int(json.loads(payload)["iat"])
except (ValueError, KeyError, TypeError):
return False
age = (now if now is not None else time.time()) - issued_at
return 0 <= age <= SESSION_MAX_AGE
def cookie_header(token: str, *, secure: bool = True) -> str:
parts = [
f"{COOKIE_NAME}={token}",
"Path=/",
"HttpOnly",
"SameSite=Lax",
f"Max-Age={SESSION_MAX_AGE}",
]
if secure:
parts.insert(2, "Secure")
return "; ".join(parts)
def clear_cookie_header() -> str:
return f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"
def cookie_value(header: str, name: str = COOKIE_NAME) -> str:
"""Pull one cookie out of a request's Cookie header.
Deliberately hand-rolled rather than using http.cookies.SimpleCookie. That
parser silently discards the remainder of the header the moment it meets a
value it considers illegal — a JSON-ish value such as `prefs={"a":1}` is
enough — so any cookie appearing after it becomes invisible. A browser sends
us every cookie on the domain, including ones set by unrelated services, so
one stray value would otherwise make a perfectly valid session vanish and
bounce the user back to the login page with no error shown.
"""
for part in (header or "").split(";"):
candidate, separator, value = part.strip().partition("=")
if separator and candidate.strip() == name:
return value.strip().strip('"')
return ""
# --------------------------------------------------------------------------
# CSRF
def csrf_token(secret: str, session_token: str) -> str:
return _sign(secret, b"csrf:" + session_token.encode("utf-8"))
def verify_csrf(secret: str, session_token: str, submitted: str) -> bool:
if not submitted:
return False
return hmac.compare_digest(csrf_token(secret, session_token), submitted)
# --------------------------------------------------------------------------
# throttling
class LoginThrottle:
"""In-memory consecutive-failure tracker keyed by remote address."""
def __init__(self, max_failures: int = MAX_FAILURES, lockout: int = LOCKOUT_SECONDS):
self.max_failures = max_failures
self.lockout = lockout
self._state: dict[str, tuple[int, float]] = {}
def locked(self, key: str, *, now: float | None = None) -> bool:
failures, last = self._state.get(key, (0, 0.0))
if failures < self.max_failures:
return False
elapsed = (now if now is not None else time.time()) - last
if elapsed >= self.lockout:
self._state.pop(key, None)
return False
return True
def record_failure(self, key: str, *, now: float | None = None) -> None:
failures, _ = self._state.get(key, (0, 0.0))
self._state[key] = (failures + 1, now if now is not None else time.time())
def record_success(self, key: str) -> None:
self._state.pop(key, None)