🔒 session-token credential binding + admin-guard/CSRF hardening (adversarial review)

Fable flagged that stateless session tokens bound only to uid+expiry — so an
admin action couldn't actually invalidate a live 30-day cookie. Reproduced 3
facets, all one root cause, all fixed by binding the token to the account's
credential state:
- make_token/read_token/_bind: token = uid:exp:bind:sig where
  bind = hmac(secret, 'uid:created:salt')[:16]; read_token re-derives it from the
  user's CURRENT row and rejects on mismatch/missing. So a token dies on account
  DELETE (no ghost session/writes), password RESET (salt rotates → admin can truly
  lock someone out), and SQLite id-REUSE (different created → no identity takeover).
  Old 3-part tokens fail to parse → a one-time re-login. No schema change.

A follow-on adversarial review (4 security lenses × find→refute×2, GO gate) then
surfaced 3 low-severity issues, all fixed here too:
- self-de-admin guard used  (identity) — {admin:0}
  / '' / 'false' / [] slipped past and could strand a sole db-admin. Now only a
  real JSON bool toggles admin (closes the bypass AND the truthy-string mis-grant).
- signup invite claim was check-then-write (double-spend under concurrency) — now
  an atomic  +
  rowcount check; signup/admin-edit wrap the UNIQUE writes in try/except
  IntegrityError → friendly message instead of a 500.
- login-CSRF (Lax cookie only): hub.py _api now blocks cross-site mutating requests
  by Origin (allowlist godstrument.pro/localhost + Host-match fallback so legit
  traffic always passes; safe GETs never blocked).

Verified: auth.py --selftest (delete/reset/id-reuse kill the token; guard bypass
closed for every falsy value); live curl (CSRF 401/401/401/403, GET never blocked);
real-browser flow (new-format cookie authenticates through a page load, admin table
renders, same-origin admin POST passes CSRF). Console clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-11 09:51:10 +10:00
parent 764e28b1c3
commit 47b51b98ca
2 changed files with 91 additions and 10 deletions

83
auth.py
View File

@ -112,9 +112,27 @@ def verify_pw(pw: str, salt: str, expected: str) -> bool:
# ---- stateless signed session token ----------------------------------------- # ---- stateless signed session token -----------------------------------------
# A token is bound to the account's *credential state* — a tag over its immutable
# `created` time + its current password `salt`. So a token dies the moment the
# account is deleted (no row), its password is reset (salt rotates), or its id is
# reused by a different signup (different created). Without this, a signed uid:exp
# token stays valid for its full 30 days regardless of any of the above.
def _bind(user_id: int, db: str = DEFAULT_DB) -> str | None:
con = _con(db)
try:
row = con.execute("SELECT created, salt FROM users WHERE id=?", (user_id,)).fetchone()
finally:
con.close()
if not row:
return None
return hmac.new(_secret(db), f"{user_id}:{row[0]}:{row[1]}".encode(),
hashlib.sha256).hexdigest()[:16]
def make_token(user_id: int, db: str = DEFAULT_DB, days: int = SESSION_DAYS) -> str: def make_token(user_id: int, db: str = DEFAULT_DB, days: int = SESSION_DAYS) -> str:
exp = int(time.time()) + days * 86400 exp = int(time.time()) + days * 86400
payload = f"{user_id}:{exp}" bind = _bind(user_id, db) or ""
payload = f"{user_id}:{exp}:{bind}"
sig = hmac.new(_secret(db), payload.encode(), hashlib.sha256).hexdigest() sig = hmac.new(_secret(db), payload.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode() return base64.urlsafe_b64encode(f"{payload}:{sig}".encode()).decode()
@ -122,12 +140,15 @@ def make_token(user_id: int, db: str = DEFAULT_DB, days: int = SESSION_DAYS) ->
def read_token(token: str, db: str = DEFAULT_DB) -> int | None: def read_token(token: str, db: str = DEFAULT_DB) -> int | None:
try: try:
raw = base64.urlsafe_b64decode(token.encode()).decode() raw = base64.urlsafe_b64decode(token.encode()).decode()
uid, exp, sig = raw.split(":") uid, exp, bind, sig = raw.split(":") # 4 parts; old 3-part tokens fail here -> re-login
good = hmac.new(_secret(db), f"{uid}:{exp}".encode(), hashlib.sha256).hexdigest() good = hmac.new(_secret(db), f"{uid}:{exp}:{bind}".encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, good): if not hmac.compare_digest(sig, good):
return None return None
if int(exp) < time.time(): if int(exp) < time.time():
return None return None
cur = _bind(int(uid), db) # current credential state
if cur is None or not hmac.compare_digest(bind, cur):
return None # deleted / password-reset / id-reused
return int(uid) return int(uid)
except (ValueError, TypeError, AttributeError): except (ValueError, TypeError, AttributeError):
return None return None
@ -164,10 +185,19 @@ def signup(username: str, email: str, pw: str, invite: str,
if con.execute("SELECT 1 FROM users WHERE username=? COLLATE NOCASE", (username,)).fetchone(): if con.execute("SELECT 1 FROM users WHERE username=? COLLATE NOCASE", (username,)).fetchone():
return False, "username already taken", None return False, "username already taken", None
salt, ph = hash_pw(pw) salt, ph = hash_pw(pw)
cur = con.execute("INSERT INTO users (username,email,pw_hash,salt,created) VALUES (?,?,?,?,?)", try:
(username, email, ph, salt, time.time())) cur = con.execute("INSERT INTO users (username,email,pw_hash,salt,created) VALUES (?,?,?,?,?)",
(username, email, ph, salt, time.time()))
except sqlite3.IntegrityError: # lost a same-instant race on the UNIQUE(email/username)
con.rollback()
return False, "email or username already taken", None
uid = cur.lastrowid uid = cur.lastrowid
con.execute("UPDATE invites SET used_by=? WHERE code=?", (uid, invite)) # atomic single-use claim: only the first concurrent signup flips a NULL used_by
claimed = con.execute("UPDATE invites SET used_by=? WHERE code=? AND used_by IS NULL",
(uid, invite)).rowcount
if not claimed: # another signup claimed this invite first
con.rollback()
return False, "invite code already used", None
con.commit() con.commit()
return True, "ok", uid return True, "ok", uid
finally: finally:
@ -275,8 +305,12 @@ def admin_update_user(user_id: int, db: str = DEFAULT_DB, username: str | None =
if not sets: if not sets:
return False, "nothing to change" return False, "nothing to change"
vals.append(user_id) vals.append(user_id)
con.execute("UPDATE users SET " + ", ".join(sets) + " WHERE id=?", vals) try:
con.commit() con.execute("UPDATE users SET " + ", ".join(sets) + " WHERE id=?", vals)
con.commit()
except sqlite3.IntegrityError: # lost a race on UNIQUE(username/email)
con.rollback()
return False, "username or email already taken"
return True, "ok" return True, "ok"
finally: finally:
con.close() con.close()
@ -447,10 +481,15 @@ def handle_api(method: str, path: str, body: bytes, cookie_header: str,
ok, temp = admin_reset_password(target, db) ok, temp = admin_reset_password(target, db)
return (200, {"ok": True, "password": temp}, None) if ok else (404, {"error": "no such user"}, None) return (200, {"ok": True, "password": temp}, None) if ok else (404, {"error": "no such user"}, None)
if not action and method == "POST": if not action and method == "POST":
if target == uid and payload.get("admin") is False: # only a real JSON boolean toggles admin — a stray 0/""/"false" must not
# silently demote (the self-guard) or promote (truthy string) via coercion
flag = payload.get("admin")
if not isinstance(flag, bool):
flag = None
if target == uid and flag is False:
return 400, {"error": "you can't remove your own admin"}, None return 400, {"error": "you can't remove your own admin"}, None
ok, msg = admin_update_user(target, db, payload.get("username"), ok, msg = admin_update_user(target, db, payload.get("username"),
payload.get("email"), payload.get("admin")) payload.get("email"), flag)
return (200, {"ok": True}, None) if ok else (400, {"error": msg}, None) return (200, {"ok": True}, None) if ok else (400, {"error": msg}, None)
if not action and method == "DELETE": if not action and method == "DELETE":
if target == uid: if target == uid:
@ -553,6 +592,10 @@ def _selftest():
assert handle_api("GET", "/api/admin/users", b"", "", db)[0] == 401, "no session blocked" assert handle_api("GET", "/api/admin/users", b"", "", db)[0] == 401, "no session blocked"
assert handle_api("POST", f"/api/admin/user/{vid}/reset", b"", cookie, db)[1].get("password"), "reset endpoint returns a temp" assert handle_api("POST", f"/api/admin/user/{vid}/reset", b"", cookie, db)[1].get("password"), "reset endpoint returns a temp"
assert handle_api("POST", f"/api/admin/user/{uid}", json.dumps({"admin": False}).encode(), cookie, db)[0] == 400, "can't de-admin self" assert handle_api("POST", f"/api/admin/user/{uid}", json.dumps({"admin": False}).encode(), cookie, db)[0] == 400, "can't de-admin self"
handle_api("POST", f"/api/admin/user/{uid}", json.dumps({"admin": 0}).encode(), cookie, db)
assert user_info(uid, db)["admin"], "self stays admin — a crafted falsy {admin:0} can't bypass the self-guard"
handle_api("POST", f"/api/admin/user/{vid}", json.dumps({"admin": "yes"}).encode(), cookie, db)
assert not user_info(vid, db)["admin"], "a non-boolean truthy admin flag does not grant admin"
assert handle_api("DELETE", f"/api/admin/user/{uid}", b"", cookie, db)[0] == 400, "can't delete self" assert handle_api("DELETE", f"/api/admin/user/{uid}", b"", cookie, db)[0] == 400, "can't delete self"
assert handle_api("POST", f"/api/admin/user/{vid}", json.dumps({"username": "Vic2"}).encode(), cookie, db)[0] == 200, "edit endpoint works" assert handle_api("POST", f"/api/admin/user/{vid}", json.dumps({"username": "Vic2"}).encode(), cookie, db)[0] == 200, "edit endpoint works"
# a non-admin can't reach any of it # a non-admin can't reach any of it
@ -564,6 +607,26 @@ def _selftest():
assert save_preset(vid, "gone", {"x": 1}, db) and list_presets(vid, db) assert save_preset(vid, "gone", {"x": 1}, db) and list_presets(vid, db)
assert admin_delete_user(vid, db) and user_info(vid, db) is None, "delete removes the user" assert admin_delete_user(vid, db) and user_info(vid, db) is None, "delete removes the user"
assert not list_presets(vid, db), "delete cascades presets" assert not list_presets(vid, db), "delete cascades presets"
# --- session tokens die on delete / password-reset / id-reuse (credential binding) ---
_, _, sa = signup("Sessa", "sessa@x.co", "sessapass1", mint_invite(db), db)
stok = make_token(sa, db)
assert read_token(stok, db) == sa, "a fresh token authenticates"
admin_reset_password(sa, db)
assert read_token(stok, db) is None, "reset-password invalidates the old session (admin can lock out)"
stok2 = make_token(sa, db)
assert read_token(stok2, db) == sa, "a token minted after reset works"
admin_delete_user(sa, db)
assert read_token(stok2, db) is None, "delete invalidates the session — no ghost cookie, no ghost writes"
# id reuse (fresh db → deterministic): a deleted user's token must not become the reused occupant
import tempfile as _tf
rdb = os.path.join(_tf.mkdtemp(), "r.db"); init_db(rdb)
_, _, x = signup("Xavier", "x@x.co", "xavierpass1", mint_invite(rdb), rdb)
xtok = make_token(x, rdb)
admin_delete_user(x, rdb)
_, _, y = signup("Yara", "y@x.co", "yarapass111", mint_invite(rdb), rdb)
assert y == x, "fresh db: sqlite reuses the freed id"
assert read_token(xtok, rdb) is None, "a reused id must reject the deleted user's token (no identity takeover)"
assert read_token(make_token(y, rdb), rdb) == y, "the reused id's real owner still logs in fine"
print("auth self-test: all checks passed.") print("auth self-test: all checks passed.")

18
hub.py
View File

@ -610,6 +610,24 @@ def _serve_http(directory: str, port: int):
path = self.path.split("?", 1)[0] path = self.path.split("?", 1)[0]
if auth is None: if auth is None:
return self.send_error(503, "accounts unavailable") return self.send_error(503, "accounts unavailable")
# CSRF: a browser always sends Origin on a cross-site state-changing request.
# Block those (incl. login-CSRF); a same-origin request or a non-browser
# client (no Origin) passes. Allowlist the canonical host + localhost so a
# legitimate request always passes even if the tunnel rewrites Host.
if method in ("POST", "PUT", "DELETE"):
origin = self.headers.get("Origin", "")
if origin:
from urllib.parse import urlparse
o = urlparse(origin)
ok = (o.hostname in ("godstrument.pro", "localhost", "127.0.0.1")
or o.netloc == self.headers.get("Host", ""))
if not ok:
blob = json.dumps({"error": "cross-site request blocked"}).encode()
self.send_response(403)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(blob)))
self.end_headers(); self.wfile.write(blob)
return
length = int(self.headers.get("Content-Length", 0) or 0) length = int(self.headers.get("Content-Length", 0) or 0)
if length > 512 * 1024: if length > 512 * 1024:
return self.send_error(413, "too large") return self.send_error(413, "too large")