fix(auth): proactive hardening — cross-worker revocation, file perms, cookie policy

Follow-up hardening beyond the explicit review findings:

- Propagate session revocation across uvicorn workers: token
  validation now syncs issuance AND revocation from sessions.json
  (mtime-gated), _save_sessions merges on-disk state under an
  inter-process flock so concurrent workers can't lose each other's
  sessions, and revocation tombstones prevent a just-revoked token
  from being re-merged.
- Restrict sessions.json and auth.json to 0600 (bearer tokens and
  password hashes; same policy as data/app.db, #4420), applied
  atomically at write time and retroactively at load.
- Password-login session cookie: SECURE_COOKIES=false can no longer
  downgrade the cookie when the request arrived over HTTPS (spoofable
  X-Forwarded-Proto still requires TRUST_PROXY_HEADERS opt-in).
- Document why OIDC state tokens are deliberately not single-use and
  which mechanisms bound the replay window.
- Warn once per process (not twice per login) when
  OIDC_ALLOW_INSECURE_COOKIES is enabled; pass the variable through
  the Compose files so the documented dev override actually reaches
  containers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GRiLb12nnLnBnYsg14oSWd
This commit is contained in:
holden093 2026-07-18 22:49:30 +02:00
parent c8e537a07c
commit e3e1694dfc
10 changed files with 297 additions and 42 deletions

View file

@ -18,15 +18,27 @@ import os
from typing import Any, Optional
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
def atomic_write_json(
path: str, data: Any, *, indent: Optional[int] = None, mode: Optional[int] = None
) -> None:
"""Atomically persist `data` as JSON at `path`.
The temp file uses the live PID as a suffix so two processes saving the
same file (e.g. unit tests) don't collide on the rename target.
When *mode* is given (e.g. ``0o600`` for files holding secrets), the
temp file is chmod'ed before the rename so the restricted permissions
are in place atomically with the content — there is no window where
the target exists with default-umask permissions.
"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{os.getpid()}"
with open(tmp, "w", encoding="utf-8") as f:
if mode is not None:
try:
os.fchmod(f.fileno(), mode)
except AttributeError: # Windows has no fchmod
os.chmod(tmp, mode)
json.dump(data, f, indent=indent)
f.flush()
os.fsync(f.fileno())

View file

@ -140,6 +140,15 @@ class AuthManager:
# detect sessions written by other uvicorn workers (see
# _reload_sessions_if_changed).
self._sessions_mtime_ns = -1
# Tokens present in sessions.json at the last disk sync. Used to
# distinguish "revoked by another worker" (was on disk, now gone —
# drop it) from "issued locally moments ago, racing its own save"
# (never seen on disk — keep it).
self._disk_tokens: set = set()
# Tokens this worker revoked whose removal may not yet be visible
# on disk. A disk sync must never re-add these; pruned once the
# on-disk file no longer contains them.
self._revoked_tokens: set = set()
self._load()
self._load_sessions()
self._migrate_single_user()
@ -149,6 +158,12 @@ class AuthManager:
def _load(self):
try:
if os.path.exists(self.auth_path):
# Contains password hashes — restrict pre-existing files
# written before the 0600 policy.
try:
os.chmod(self.auth_path, 0o600)
except OSError:
pass
with open(self.auth_path, "r", encoding="utf-8") as f:
self._config = json.load(f)
# Normalize all stored usernames to lowercase so they match
@ -172,11 +187,19 @@ class AuthManager:
"""Load persisted session tokens from disk, pruning expired ones."""
try:
if os.path.exists(self._sessions_path):
# Session tokens are bearer credentials — never leave the
# file readable by other local users (same policy as
# data/app.db, #4420).
try:
os.chmod(self._sessions_path, 0o600)
except OSError:
pass
self._sessions_mtime_ns = os.stat(self._sessions_path).st_mtime_ns
with open(self._sessions_path, "r", encoding="utf-8") as f:
data = json.load(f)
now = time.time()
self._sessions = {k: v for k, v in data.items() if v.get("expiry", 0) > now}
self._disk_tokens = set(data)
pruned = len(data) - len(self._sessions)
if pruned > 0:
self._save_sessions()
@ -186,19 +209,23 @@ class AuthManager:
self._sessions = {}
def _reload_sessions_if_changed(self):
"""Merge sessions written by other uvicorn workers.
"""Sync session state written by other uvicorn workers.
The OIDC callback (or a password login) may run on one worker while
the browser's next request lands on another; each worker loads
sessions.json only at startup, so the new token would be rejected.
Called on a token miss: when the file's mtime has changed since the
last load, re-read it and add unknown unexpired tokens to the
in-memory map. The mtime gate keeps unknown-token spam at one
os.stat per request, not a JSON parse.
The OIDC callback (or a password login/logout) may run on one
worker while the browser's next request lands on another; each
worker loads sessions.json only at startup, so cross-worker
issuance and revocation would otherwise be invisible. Called on
every token validation: when the file's mtime has changed since
the last sync, re-read it and
Additive only — tokens missing from disk are NOT dropped from
memory, so a token issued moments ago on this worker can't be lost
to a reload racing its own _save_sessions.
- add unknown unexpired tokens (issued by another worker), and
- drop in-memory tokens that were on disk at the last sync but
are gone now (revoked by another worker).
A token never yet seen on disk is kept — it was issued locally
moments ago and may be racing its own _save_sessions. The mtime
gate keeps the steady-state cost at one os.stat per validation,
not a JSON parse.
"""
try:
stat = os.stat(self._sessions_path)
@ -216,21 +243,76 @@ class AuthManager:
self._sessions_mtime_ns = stat.st_mtime_ns
if not isinstance(data, dict):
return
now = time.time()
for tok, sess in data.items():
if (
tok not in self._sessions
and isinstance(sess, dict)
and sess.get("expiry", 0) > now
):
self._sessions[tok] = sess
self._apply_disk_sessions(data)
def _apply_disk_sessions(self, data: Dict[str, Any]) -> None:
"""Merge parsed sessions.json content into memory.
Caller must hold ``_sessions_lock``. Adds unknown unexpired
tokens (unless this worker revoked them and the removal hasn't
reached disk yet), drops tokens revoked by other workers, and
refreshes the disk-snapshot bookkeeping.
"""
now = time.time()
for tok, sess in data.items():
if (
tok not in self._sessions
and tok not in self._revoked_tokens
and isinstance(sess, dict)
and sess.get("expiry", 0) > now
):
self._sessions[tok] = sess
revoked_elsewhere = [
tok for tok in self._sessions
if tok not in data and tok in self._disk_tokens
]
for tok in revoked_elsewhere:
self._sessions.pop(tok, None)
self._disk_tokens = set(data)
# A tombstone is only needed while the token is still on disk.
self._revoked_tokens &= self._disk_tokens
@contextmanager
def _interprocess_sessions_lock(self):
"""Serialise sessions.json read-merge-write cycles across uvicorn
workers. Separate lock file from the auth.json IPC lock so a
session save can never deadlock a caller already holding the auth
lock (flock is not re-entrant across file descriptors)."""
if not HAS_FCNTL:
yield
return
fd = os.open(self._sessions_path + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def _save_sessions(self):
"""Persist session tokens to disk (atomic, lock-guarded)."""
"""Persist session tokens to disk (atomic, merge-on-write).
Merges the current on-disk state before writing, under an
inter-process flock — a plain overwrite would clobber sessions
issued by other workers since this worker's last sync (lost
update). Tombstones in ``_revoked_tokens`` keep just-revoked
tokens from being re-merged and resurrected.
"""
try:
with self._sessions_lock:
with self._interprocess_sessions_lock(), self._sessions_lock:
try:
with open(self._sessions_path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
self._apply_disk_sessions(data)
except OSError:
pass # first save — no file yet
except Exception as e:
logger.error(f"Failed to merge sessions before save: {e}")
snapshot = dict(self._sessions)
_atomic_write_json(self._sessions_path, snapshot)
_atomic_write_json(self._sessions_path, snapshot, mode=0o600)
self._disk_tokens = set(snapshot)
self._revoked_tokens &= self._disk_tokens
except Exception as e:
logger.error(f"Failed to save sessions: {e}")
@ -295,7 +377,8 @@ class AuthManager:
self._save()
def _save(self):
_atomic_write_json(self.auth_path, self._config, indent=2)
# Password hashes — owner-only, same policy as sessions.json.
_atomic_write_json(self.auth_path, self._config, indent=2, mode=0o600)
@property
def users(self) -> Dict[str, Any]:
@ -622,6 +705,7 @@ class AuthManager:
if (sess or {}).get("username") == username]
for tok in to_drop:
self._sessions.pop(tok, None)
self._revoked_tokens.add(tok)
revoked += 1
if revoked:
self._save_sessions()
@ -923,11 +1007,8 @@ class AuthManager:
def validate_token(self, token: Optional[str]) -> bool:
if not token:
return False
with self._sessions_lock:
known = token in self._sessions
if not known:
# May have been issued by another worker — read through to disk.
self._reload_sessions_if_changed()
# Sync issuance/revocation from other workers (mtime-gated).
self._reload_sessions_if_changed()
expired = False
deleted_user = False
with self._sessions_lock:
@ -944,6 +1025,7 @@ class AuthManager:
# silently authenticating against a non-existent account.
if session.get("username") not in self.users:
self._sessions.pop(token, None)
self._revoked_tokens.add(token)
deleted_user = True
if expired or deleted_user:
self._save_sessions()
@ -954,11 +1036,8 @@ class AuthManager:
"""Return the username associated with a valid token."""
if not token:
return None
with self._sessions_lock:
known = token in self._sessions
if not known:
# May have been issued by another worker — read through to disk.
self._reload_sessions_if_changed()
# Sync issuance/revocation from other workers (mtime-gated).
self._reload_sessions_if_changed()
expired = False
deleted_user = False
with self._sessions_lock:
@ -973,6 +1052,7 @@ class AuthManager:
# SECURITY: orphan check — same rationale as validate_token.
if _u not in self.users:
self._sessions.pop(token, None)
self._revoked_tokens.add(token)
deleted_user = True
else:
return _u
@ -983,6 +1063,7 @@ class AuthManager:
def revoke_token(self, token: str):
with self._sessions_lock:
self._sessions.pop(token, None)
self._revoked_tokens.add(token)
self._save_sessions()
def revoke_user_sessions(self, username: str, except_token: Optional[str] = None) -> int:
@ -996,9 +1077,13 @@ class AuthManager:
]
for token in to_drop:
self._sessions.pop(token, None)
self._revoked_tokens.add(token)
revoked += 1
if revoked:
self._save_sessions()
# Save outside _sessions_lock: _save_sessions acquires the
# inter-process flock before _sessions_lock, and taking them in
# the opposite order here could deadlock two threads.
if revoked:
self._save_sessions()
return revoked
def status(self, token: Optional[str]) -> Dict[str, Any]:

View file

@ -51,6 +51,16 @@ logger = logging.getLogger(__name__)
_STATE_TTL = 600 # 10 minutes
# DESIGN NOTE — state tokens are deliberately NOT single-use. Enforcing
# one-time consumption would require shared server-side storage, which
# this stateless design intentionally avoids (multi-worker support with
# no session store). Replay of a state within its TTL is mitigated by:
# - the authorization code being single-use at the IdP (a replayed
# callback fails the token exchange),
# - the nonce being bound into the signed id_token and verified,
# - the PKCE verifier being bound to the same encrypted state, and
# - the CSRF cookie requiring the completing browser to hold the state.
_state_fernet_lock = threading.Lock()
_state_fernet = None

View file

@ -58,6 +58,8 @@ services:
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

View file

@ -57,6 +57,8 @@ services:
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

View file

@ -46,6 +46,8 @@ services:
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
- EMBEDDING_URL=${EMBEDDING_URL:-}
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

View file

@ -84,6 +84,26 @@ class SetOpenRegistrationRequest(BaseModel):
SESSION_COOKIE = "odysseus_session"
def _session_cookie_secure(request: Request) -> bool:
"""Secure flag for the password-login session cookie.
SECURE_COOKIES=true always wins. Unlike the historical behaviour,
SECURE_COOKIES=false (the bundled Compose default) can no longer
downgrade the cookie when the request itself arrived over HTTPS —
a stock TLS deployment must not issue a bearer cookie eligible for
cleartext transmission. X-Forwarded-Proto is honoured only when the
deployment explicitly opts in via TRUST_PROXY_HEADERS, so a client
cannot influence cookie policy with a spoofed header.
"""
if os.getenv("SECURE_COOKIES", "").strip().lower() in ("true", "1", "yes"):
return True
forwarded = ""
if os.getenv("TRUST_PROXY_HEADERS", "").strip().lower() in ("true", "1", "yes"):
forwarded = getattr(request, "headers", {}).get("x-forwarded-proto", "")
scheme = forwarded or getattr(getattr(request, "url", None), "scheme", "") or "http"
return scheme == "https"
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
router = APIRouter(prefix="/api/auth", tags=["auth"])
@ -157,7 +177,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
value=token,
httponly=True,
samesite="lax",
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
secure=_session_cookie_secure(request),
path="/",
)
if body.remember:

View file

@ -1,6 +1,7 @@
"""OpenID Connect authentication routes — login, callback, config."""
import asyncio
import functools
import logging
import os
import secrets
@ -319,10 +320,17 @@ def _oidc_cookie_secure() -> bool:
downgrade OIDC cookies.
"""
if os.getenv("OIDC_ALLOW_INSECURE_COOKIES", "").strip().lower() in ("true", "1", "yes"):
logger.warning(
"OIDC_ALLOW_INSECURE_COOKIES=true — OIDC session and CSRF "
"cookies are issued without the Secure flag. Never use this "
"outside plain-HTTP local development."
)
_warn_insecure_cookies_once()
return False
return True
@functools.lru_cache(maxsize=1)
def _warn_insecure_cookies_once() -> None:
# Once per process, not once per login — the flag doesn't change at
# runtime and repeating the warning twice per flow is pure log spam.
logger.warning(
"OIDC_ALLOW_INSECURE_COOKIES=true — OIDC session and CSRF "
"cookies are issued without the Secure flag. Never use this "
"outside plain-HTTP local development."
)

View file

@ -0,0 +1,47 @@
"""Regression: the password-login session cookie must be Secure whenever
the request arrived over HTTPS, even with SECURE_COOKIES=false (the
bundled Compose default) — a stock TLS deployment must not issue a bearer
cookie eligible for cleartext transmission."""
from types import SimpleNamespace
def _fake_request(scheme="https", headers=None):
req = SimpleNamespace()
req.url = SimpleNamespace()
req.url.scheme = scheme
req.headers = headers or {}
return req
class TestSessionCookieSecure:
def _secure(self, request):
from routes.auth_routes import _session_cookie_secure
return _session_cookie_secure(request)
def test_https_request_secure_despite_secure_cookies_false(self, monkeypatch):
monkeypatch.setenv("SECURE_COOKIES", "false")
monkeypatch.delenv("TRUST_PROXY_HEADERS", raising=False)
assert self._secure(_fake_request("https")) is True
def test_explicit_true_always_secure(self, monkeypatch):
monkeypatch.setenv("SECURE_COOKIES", "true")
assert self._secure(_fake_request("http")) is True
def test_plain_http_not_secure(self, monkeypatch):
monkeypatch.setenv("SECURE_COOKIES", "false")
monkeypatch.delenv("TRUST_PROXY_HEADERS", raising=False)
assert self._secure(_fake_request("http")) is False
def test_forwarded_proto_ignored_without_trust_optin(self, monkeypatch):
"""A client-spoofed X-Forwarded-Proto must not influence policy."""
monkeypatch.setenv("SECURE_COOKIES", "false")
monkeypatch.delenv("TRUST_PROXY_HEADERS", raising=False)
req = _fake_request("http", {"x-forwarded-proto": "https"})
assert self._secure(req) is False
def test_forwarded_proto_honoured_with_trust_optin(self, monkeypatch):
monkeypatch.setenv("SECURE_COOKIES", "false")
monkeypatch.setenv("TRUST_PROXY_HEADERS", "true")
req = _fake_request("http", {"x-forwarded-proto": "https"})
assert self._secure(req) is True

View file

@ -82,3 +82,70 @@ class TestCrossWorkerSessions:
# B validating A's token triggers a reload; B's own token survives.
assert worker_b.validate_token(token_a) is True
assert worker_b.validate_token(token_b) is True
class TestCrossWorkerRevocation:
def test_revocation_propagates_to_other_worker(self, tmp_path):
"""Logout on worker A must invalidate the token on worker B even
though B holds it in its in-memory map."""
worker_a, worker_b = _two_workers(tmp_path)
token = worker_a.create_session_trusted("alice")
assert worker_b.validate_token(token) is True # B now caches it
worker_a.revoke_token(token)
assert worker_b.validate_token(token) is False
assert worker_b.get_username_for_token(token) is None
def test_revoke_user_sessions_propagates(self, tmp_path):
"""Admin-driven revocation (password change, user deletion) on one
worker must take effect on the others."""
worker_a, worker_b = _two_workers(tmp_path)
token = worker_a.create_session_trusted("alice")
assert worker_b.validate_token(token) is True
assert worker_a.revoke_user_sessions("alice") == 1
assert worker_b.validate_token(token) is False
def test_never_persisted_token_survives_reload(self, tmp_path):
"""A token in memory that was never written to disk (racing its own
save) must not be dropped when a reload observes another worker's
write that lacks it."""
worker_a, worker_b = _two_workers(tmp_path)
import time as _time
phantom = "e" * 64
with worker_b._sessions_lock:
worker_b._sessions[phantom] = {
"username": "alice", "expiry": _time.time() + 3600,
}
token_a = worker_a.create_session_trusted("alice") # bumps mtime
assert worker_b.validate_token(token_a) is True # triggers reload
assert worker_b.validate_token(phantom) is True # survived
class TestSecretFilePermissions:
def test_sessions_file_owner_only(self, tmp_path):
import stat
worker_a, _ = _two_workers(tmp_path)
worker_a.create_session_trusted("alice")
mode = stat.S_IMODE((tmp_path / "sessions.json").stat().st_mode)
assert mode == 0o600
def test_auth_file_owner_only(self, tmp_path):
import stat
_two_workers(tmp_path)
mode = stat.S_IMODE((tmp_path / "auth.json").stat().st_mode)
assert mode == 0o600
def test_preexisting_world_readable_files_restricted_on_load(self, tmp_path):
"""Files written before the 0600 policy get restricted at startup."""
import stat
auth_mod = _auth_module()
auth_mod._hash_password = lambda password: f"hash:{password}"
auth_mod._verify_password = lambda password, hashed: hashed == f"hash:{password}"
auth_path = str(tmp_path / "auth.json")
mgr = auth_mod.AuthManager(auth_path)
assert mgr.create_user("alice", "password-1", is_admin=False)
mgr.create_session_trusted("alice")
(tmp_path / "auth.json").chmod(0o644)
(tmp_path / "sessions.json").chmod(0o644)
auth_mod.AuthManager(auth_path) # fresh load restricts both
assert stat.S_IMODE((tmp_path / "auth.json").stat().st_mode) == 0o600
assert stat.S_IMODE((tmp_path / "sessions.json").stat().st_mode) == 0o600