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