"""Cookie-scoped Owner session — opaque bearer key, no accounts.""" from __future__ import annotations import secrets from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request import imptune.config as cfg from imptune.db.models import Owner COOKIE_NAME = "imptune_owner_key" COOKIE_MAX_AGE = 10 * 365 * 24 * 60 * 60 # 10 years def generate_key() -> str: return secrets.token_urlsafe(32) def is_same_origin(request: Request) -> bool: """Origin/Referer check — the CSRF guard for /session/restore. Restoring a key re-points the cookie at a *different* Owner, so unlike the rest of the app's unprotected POSTs (which only ever mutate the caller's own data), a forged cross-site POST here is a login-CSRF / session-fixation vector: an attacker who knows their own key can force a victim's browser onto the attacker's Owner. Browsers always send Origin (and usually Referer) on form POSTs, same-site or not, so requiring a match — and rejecting when both are absent — blocks a plain auto-submitting HTML form without needing a token. """ expected = f"{request.url.scheme}://{request.url.netloc}" origin = request.headers.get("origin") if origin is not None: return origin == expected referer = request.headers.get("referer") if referer: from urllib.parse import urlparse parsed = urlparse(referer) return f"{parsed.scheme}://{parsed.netloc}" == expected return False class OwnerSessionMiddleware(BaseHTTPMiddleware): """Resolves request.state.owner from a cookie, creating one on first visit.""" async def dispatch(self, request: Request, call_next): if request.url.path == "/health": return await call_next(request) key = request.cookies.get(COOKIE_NAME) owner = Owner.get_or_none(Owner.key == key) if key else None is_new = owner is None if owner is None: owner = Owner.create(key=generate_key(), is_permanent=False) request.state.owner = owner request.state.is_new_owner = is_new response = await call_next(request) # Routes like /session/restore intentionally set this cookie themselves # (to a *different* owner than the one this middleware just minted) — # don't clobber that with the auto-provisioned one. route_already_set_cookie = any( header.lower() == b"set-cookie" and value.startswith(f"{COOKIE_NAME}=".encode()) for header, value in response.raw_headers ) if is_new and not route_already_set_cookie: response.set_cookie( COOKIE_NAME, owner.key, max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=cfg.COOKIE_SECURE, ) return response