Skip to content

Commit 988b86b

Browse files
holden093claude
andcommitted
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
1 parent ad808d2 commit 988b86b

10 files changed

Lines changed: 299 additions & 44 deletions

core/atomic_io.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,27 @@
1818
from typing import Any, Optional
1919

2020

21-
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
21+
def atomic_write_json(
22+
path: str, data: Any, *, indent: Optional[int] = None, mode: Optional[int] = None
23+
) -> None:
2224
"""Atomically persist `data` as JSON at `path`.
2325
2426
The temp file uses the live PID as a suffix so two processes saving the
2527
same file (e.g. unit tests) don't collide on the rename target.
28+
29+
When *mode* is given (e.g. ``0o600`` for files holding secrets), the
30+
temp file is chmod'ed before the rename so the restricted permissions
31+
are in place atomically with the content — there is no window where
32+
the target exists with default-umask permissions.
2633
"""
2734
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
2835
tmp = f"{path}.tmp.{os.getpid()}"
2936
with open(tmp, "w", encoding="utf-8") as f:
37+
if mode is not None:
38+
try:
39+
os.fchmod(f.fileno(), mode)
40+
except AttributeError: # Windows has no fchmod
41+
os.chmod(tmp, mode)
3042
json.dump(data, f, indent=indent)
3143
f.flush()
3244
os.fsync(f.fileno())

core/auth.py

Lines changed: 122 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,15 @@ def __init__(self, auth_path: str = DEFAULT_AUTH_PATH):
140140
# detect sessions written by other uvicorn workers (see
141141
# _reload_sessions_if_changed).
142142
self._sessions_mtime_ns = -1
143+
# Tokens present in sessions.json at the last disk sync. Used to
144+
# distinguish "revoked by another worker" (was on disk, now gone —
145+
# drop it) from "issued locally moments ago, racing its own save"
146+
# (never seen on disk — keep it).
147+
self._disk_tokens: set = set()
148+
# Tokens this worker revoked whose removal may not yet be visible
149+
# on disk. A disk sync must never re-add these; pruned once the
150+
# on-disk file no longer contains them.
151+
self._revoked_tokens: set = set()
143152
self._load()
144153
self._load_sessions()
145154
self._migrate_single_user()
@@ -149,6 +158,12 @@ def __init__(self, auth_path: str = DEFAULT_AUTH_PATH):
149158
def _load(self):
150159
try:
151160
if os.path.exists(self.auth_path):
161+
# Contains password hashes — restrict pre-existing files
162+
# written before the 0600 policy.
163+
try:
164+
os.chmod(self.auth_path, 0o600)
165+
except OSError:
166+
pass
152167
with open(self.auth_path, "r", encoding="utf-8") as f:
153168
self._config = json.load(f)
154169
# Normalize all stored usernames to lowercase so they match
@@ -172,11 +187,19 @@ def _load_sessions(self):
172187
"""Load persisted session tokens from disk, pruning expired ones."""
173188
try:
174189
if os.path.exists(self._sessions_path):
190+
# Session tokens are bearer credentials — never leave the
191+
# file readable by other local users (same policy as
192+
# data/app.db, #4420).
193+
try:
194+
os.chmod(self._sessions_path, 0o600)
195+
except OSError:
196+
pass
175197
self._sessions_mtime_ns = os.stat(self._sessions_path).st_mtime_ns
176198
with open(self._sessions_path, "r", encoding="utf-8") as f:
177199
data = json.load(f)
178200
now = time.time()
179201
self._sessions = {k: v for k, v in data.items() if v.get("expiry", 0) > now}
202+
self._disk_tokens = set(data)
180203
pruned = len(data) - len(self._sessions)
181204
if pruned > 0:
182205
self._save_sessions()
@@ -186,19 +209,23 @@ def _load_sessions(self):
186209
self._sessions = {}
187210

188211
def _reload_sessions_if_changed(self):
189-
"""Merge sessions written by other uvicorn workers.
190-
191-
The OIDC callback (or a password login) may run on one worker while
192-
the browser's next request lands on another; each worker loads
193-
sessions.json only at startup, so the new token would be rejected.
194-
Called on a token miss: when the file's mtime has changed since the
195-
last load, re-read it and add unknown unexpired tokens to the
196-
in-memory map. The mtime gate keeps unknown-token spam at one
197-
os.stat per request, not a JSON parse.
198-
199-
Additive only — tokens missing from disk are NOT dropped from
200-
memory, so a token issued moments ago on this worker can't be lost
201-
to a reload racing its own _save_sessions.
212+
"""Sync session state written by other uvicorn workers.
213+
214+
The OIDC callback (or a password login/logout) may run on one
215+
worker while the browser's next request lands on another; each
216+
worker loads sessions.json only at startup, so cross-worker
217+
issuance and revocation would otherwise be invisible. Called on
218+
every token validation: when the file's mtime has changed since
219+
the last sync, re-read it and
220+
221+
- add unknown unexpired tokens (issued by another worker), and
222+
- drop in-memory tokens that were on disk at the last sync but
223+
are gone now (revoked by another worker).
224+
225+
A token never yet seen on disk is kept — it was issued locally
226+
moments ago and may be racing its own _save_sessions. The mtime
227+
gate keeps the steady-state cost at one os.stat per validation,
228+
not a JSON parse.
202229
"""
203230
try:
204231
stat = os.stat(self._sessions_path)
@@ -216,21 +243,76 @@ def _reload_sessions_if_changed(self):
216243
self._sessions_mtime_ns = stat.st_mtime_ns
217244
if not isinstance(data, dict):
218245
return
219-
now = time.time()
220-
for tok, sess in data.items():
221-
if (
222-
tok not in self._sessions
223-
and isinstance(sess, dict)
224-
and sess.get("expiry", 0) > now
225-
):
226-
self._sessions[tok] = sess
246+
self._apply_disk_sessions(data)
247+
248+
def _apply_disk_sessions(self, data: Dict[str, Any]) -> None:
249+
"""Merge parsed sessions.json content into memory.
250+
251+
Caller must hold ``_sessions_lock``. Adds unknown unexpired
252+
tokens (unless this worker revoked them and the removal hasn't
253+
reached disk yet), drops tokens revoked by other workers, and
254+
refreshes the disk-snapshot bookkeeping.
255+
"""
256+
now = time.time()
257+
for tok, sess in data.items():
258+
if (
259+
tok not in self._sessions
260+
and tok not in self._revoked_tokens
261+
and isinstance(sess, dict)
262+
and sess.get("expiry", 0) > now
263+
):
264+
self._sessions[tok] = sess
265+
revoked_elsewhere = [
266+
tok for tok in self._sessions
267+
if tok not in data and tok in self._disk_tokens
268+
]
269+
for tok in revoked_elsewhere:
270+
self._sessions.pop(tok, None)
271+
self._disk_tokens = set(data)
272+
# A tombstone is only needed while the token is still on disk.
273+
self._revoked_tokens &= self._disk_tokens
274+
275+
@contextmanager
276+
def _interprocess_sessions_lock(self):
277+
"""Serialise sessions.json read-merge-write cycles across uvicorn
278+
workers. Separate lock file from the auth.json IPC lock so a
279+
session save can never deadlock a caller already holding the auth
280+
lock (flock is not re-entrant across file descriptors)."""
281+
if not HAS_FCNTL:
282+
yield
283+
return
284+
fd = os.open(self._sessions_path + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
285+
try:
286+
fcntl.flock(fd, fcntl.LOCK_EX)
287+
yield
288+
finally:
289+
fcntl.flock(fd, fcntl.LOCK_UN)
290+
os.close(fd)
227291

228292
def _save_sessions(self):
229-
"""Persist session tokens to disk (atomic, lock-guarded)."""
293+
"""Persist session tokens to disk (atomic, merge-on-write).
294+
295+
Merges the current on-disk state before writing, under an
296+
inter-process flock — a plain overwrite would clobber sessions
297+
issued by other workers since this worker's last sync (lost
298+
update). Tombstones in ``_revoked_tokens`` keep just-revoked
299+
tokens from being re-merged and resurrected.
300+
"""
230301
try:
231-
with self._sessions_lock:
302+
with self._interprocess_sessions_lock(), self._sessions_lock:
303+
try:
304+
with open(self._sessions_path, "r", encoding="utf-8") as f:
305+
data = json.load(f)
306+
if isinstance(data, dict):
307+
self._apply_disk_sessions(data)
308+
except OSError:
309+
pass # first save — no file yet
310+
except Exception as e:
311+
logger.error(f"Failed to merge sessions before save: {e}")
232312
snapshot = dict(self._sessions)
233-
_atomic_write_json(self._sessions_path, snapshot)
313+
_atomic_write_json(self._sessions_path, snapshot, mode=0o600)
314+
self._disk_tokens = set(snapshot)
315+
self._revoked_tokens &= self._disk_tokens
234316
except Exception as e:
235317
logger.error(f"Failed to save sessions: {e}")
236318

@@ -295,7 +377,8 @@ def _migrate_legacy_admin_role(self):
295377
self._save()
296378

297379
def _save(self):
298-
_atomic_write_json(self.auth_path, self._config, indent=2)
380+
# Password hashes — owner-only, same policy as sessions.json.
381+
_atomic_write_json(self.auth_path, self._config, indent=2, mode=0o600)
299382

300383
@property
301384
def users(self) -> Dict[str, Any]:
@@ -622,6 +705,7 @@ def delete_user(self, username: str, requesting_user: str) -> bool:
622705
if (sess or {}).get("username") == username]
623706
for tok in to_drop:
624707
self._sessions.pop(tok, None)
708+
self._revoked_tokens.add(tok)
625709
revoked += 1
626710
if revoked:
627711
self._save_sessions()
@@ -923,11 +1007,8 @@ def create_session_trusted(self, username: str) -> Optional[str]:
9231007
def validate_token(self, token: Optional[str]) -> bool:
9241008
if not token:
9251009
return False
926-
with self._sessions_lock:
927-
known = token in self._sessions
928-
if not known:
929-
# May have been issued by another worker — read through to disk.
930-
self._reload_sessions_if_changed()
1010+
# Sync issuance/revocation from other workers (mtime-gated).
1011+
self._reload_sessions_if_changed()
9311012
expired = False
9321013
deleted_user = False
9331014
with self._sessions_lock:
@@ -944,6 +1025,7 @@ def validate_token(self, token: Optional[str]) -> bool:
9441025
# silently authenticating against a non-existent account.
9451026
if session.get("username") not in self.users:
9461027
self._sessions.pop(token, None)
1028+
self._revoked_tokens.add(token)
9471029
deleted_user = True
9481030
if expired or deleted_user:
9491031
self._save_sessions()
@@ -954,11 +1036,8 @@ def get_username_for_token(self, token: Optional[str]) -> Optional[str]:
9541036
"""Return the username associated with a valid token."""
9551037
if not token:
9561038
return None
957-
with self._sessions_lock:
958-
known = token in self._sessions
959-
if not known:
960-
# May have been issued by another worker — read through to disk.
961-
self._reload_sessions_if_changed()
1039+
# Sync issuance/revocation from other workers (mtime-gated).
1040+
self._reload_sessions_if_changed()
9621041
expired = False
9631042
deleted_user = False
9641043
with self._sessions_lock:
@@ -973,6 +1052,7 @@ def get_username_for_token(self, token: Optional[str]) -> Optional[str]:
9731052
# SECURITY: orphan check — same rationale as validate_token.
9741053
if _u not in self.users:
9751054
self._sessions.pop(token, None)
1055+
self._revoked_tokens.add(token)
9761056
deleted_user = True
9771057
else:
9781058
return _u
@@ -983,6 +1063,7 @@ def get_username_for_token(self, token: Optional[str]) -> Optional[str]:
9831063
def revoke_token(self, token: str):
9841064
with self._sessions_lock:
9851065
self._sessions.pop(token, None)
1066+
self._revoked_tokens.add(token)
9861067
self._save_sessions()
9871068

9881069
def revoke_user_sessions(self, username: str, except_token: Optional[str] = None) -> int:
@@ -996,9 +1077,13 @@ def revoke_user_sessions(self, username: str, except_token: Optional[str] = None
9961077
]
9971078
for token in to_drop:
9981079
self._sessions.pop(token, None)
1080+
self._revoked_tokens.add(token)
9991081
revoked += 1
1000-
if revoked:
1001-
self._save_sessions()
1082+
# Save outside _sessions_lock: _save_sessions acquires the
1083+
# inter-process flock before _sessions_lock, and taking them in
1084+
# the opposite order here could deadlock two threads.
1085+
if revoked:
1086+
self._save_sessions()
10021087
return revoked
10031088

10041089
def status(self, token: Optional[str]) -> Dict[str, Any]:

core/oidc.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@
5151

5252
_STATE_TTL = 600 # 10 minutes
5353

54+
# DESIGN NOTE — state tokens are deliberately NOT single-use. Enforcing
55+
# one-time consumption would require shared server-side storage, which
56+
# this stateless design intentionally avoids (multi-worker support with
57+
# no session store). Replay of a state within its TTL is mitigated by:
58+
# - the authorization code being single-use at the IdP (a replayed
59+
# callback fails the token exchange),
60+
# - the nonce being bound into the signed id_token and verified,
61+
# - the PKCE verifier being bound to the same encrypted state, and
62+
# - the CSRF cookie requiring the completing browser to hold the state.
63+
5464
_state_fernet_lock = threading.Lock()
5565
_state_fernet = None
5666

docker-compose.gpu-amd.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ services:
5858
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
5959
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
6060
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
61+
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
62+
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
6163
- EMBEDDING_URL=${EMBEDDING_URL:-}
6264
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
6365
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

docker-compose.gpu-nvidia.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ services:
5757
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
5858
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
5959
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
60+
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
61+
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
6062
- EMBEDDING_URL=${EMBEDDING_URL:-}
6163
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
6264
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ services:
4646
- OIDC_ADMIN_GROUPS=${OIDC_ADMIN_GROUPS:-}
4747
- OIDC_REDIRECT_URI=${OIDC_REDIRECT_URI:-}
4848
- OIDC_FIRST_USER_IS_ADMIN=${OIDC_FIRST_USER_IS_ADMIN:-true}
49+
# Dev-only: allow OIDC cookies without the Secure flag (plain-HTTP testing).
50+
- OIDC_ALLOW_INSECURE_COOKIES=${OIDC_ALLOW_INSECURE_COOKIES:-false}
4951
- EMBEDDING_URL=${EMBEDDING_URL:-}
5052
- EMBEDDING_MODEL=${EMBEDDING_MODEL:-}
5153
- EMBEDDING_API_KEY=${EMBEDDING_API_KEY:-}

routes/auth_routes.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,26 @@ class SetOpenRegistrationRequest(BaseModel):
8484
SESSION_COOKIE = "odysseus_session"
8585

8686

87+
def _session_cookie_secure(request: Request) -> bool:
88+
"""Secure flag for the password-login session cookie.
89+
90+
SECURE_COOKIES=true always wins. Unlike the historical behaviour,
91+
SECURE_COOKIES=false (the bundled Compose default) can no longer
92+
downgrade the cookie when the request itself arrived over HTTPS —
93+
a stock TLS deployment must not issue a bearer cookie eligible for
94+
cleartext transmission. X-Forwarded-Proto is honoured only when the
95+
deployment explicitly opts in via TRUST_PROXY_HEADERS, so a client
96+
cannot influence cookie policy with a spoofed header.
97+
"""
98+
if os.getenv("SECURE_COOKIES", "").strip().lower() in ("true", "1", "yes"):
99+
return True
100+
forwarded = ""
101+
if os.getenv("TRUST_PROXY_HEADERS", "").strip().lower() in ("true", "1", "yes"):
102+
forwarded = getattr(request, "headers", {}).get("x-forwarded-proto", "")
103+
scheme = forwarded or getattr(getattr(request, "url", None), "scheme", "") or "http"
104+
return scheme == "https"
105+
106+
87107
def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
88108
router = APIRouter(prefix="/api/auth", tags=["auth"])
89109

@@ -157,7 +177,7 @@ async def login(body: LoginRequest, request: Request, response: Response):
157177
value=token,
158178
httponly=True,
159179
samesite="lax",
160-
secure=os.getenv("SECURE_COOKIES", "false").lower() == "true",
180+
secure=_session_cookie_secure(request),
161181
path="/",
162182
)
163183
if body.remember:

routes/oidc_routes.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""OpenID Connect authentication routes — login, callback, config."""
22

33
import asyncio
4+
import functools
45
import logging
56
import os
67
import secrets
@@ -319,10 +320,17 @@ def _oidc_cookie_secure() -> bool:
319320
downgrade OIDC cookies.
320321
"""
321322
if os.getenv("OIDC_ALLOW_INSECURE_COOKIES", "").strip().lower() in ("true", "1", "yes"):
322-
logger.warning(
323-
"OIDC_ALLOW_INSECURE_COOKIES=true — OIDC session and CSRF "
324-
"cookies are issued without the Secure flag. Never use this "
325-
"outside plain-HTTP local development."
326-
)
323+
_warn_insecure_cookies_once()
327324
return False
328325
return True
326+
327+
328+
@functools.lru_cache(maxsize=1)
329+
def _warn_insecure_cookies_once() -> None:
330+
# Once per process, not once per login — the flag doesn't change at
331+
# runtime and repeating the warning twice per flow is pure log spam.
332+
logger.warning(
333+
"OIDC_ALLOW_INSECURE_COOKIES=true — OIDC session and CSRF "
334+
"cookies are issued without the Secure flag. Never use this "
335+
"outside plain-HTTP local development."
336+
)

0 commit comments

Comments
 (0)