Files
rfc-app/backend/app/device_trust.py
Ben Stull bd3ef269d4 Release v0.27.0: security hardening (audit 0026)
Remediates the rfc-app application + deploy-config findings from the
Session 0026 security audit. Cut as the "v0.25.0-security-hardening"
branch (from v0.24.0); reversioned to 0.27.0 on rebase onto main since
v0.26.0 (#28) shipped while this was in flight.

- C1 (Critical): single sanitizeHtml.js chokepoint (DOMPurify) for every
  marked→innerHTML / dangerouslySetInnerHTML sink (MarkdownPreview,
  ProposalView x2, Editor); rel=noopener hook on target=_blank links.
- H1: per-account OTC-verify lockout (migration 023, auto-applied) +
  per-IP throttle via new ratelimit.py; wired on otc verify/request +
  passcode check/verify.
- M1: device_trust.lookup() single indexed-row read — cookie value is now
  "<row_id>.<raw_token>"; bcrypt-checks one row, not a global table scan.
  (Behavior change: existing device-trust cookies re-prompt once.)
- M2: HTTP security headers (CSP/HSTS/XFO/XCTO/Referrer-Policy) at nginx.
- M4: session cookie Secure-by-default (SESSION_COOKIE_SECURE opt-out).
- M5: bounce webhook fails CLOSED (503) when secret unset, instead of open;
  RFC_APP_INSECURE_BOUNCE_WEBHOOK=1 dev opt-in.
- L2/L3: per-IP cooldown + check-endpoint throttle.
- L4: systemd sandbox knobs. L8/I1: nginx server_tokens off + TLS1.0/1.1 out.

VERSION + frontend/package.json → 0.27.0; CHANGELOG documents the upgrade
steps (incl. the out-of-band nginx + systemd apply, which the flotilla
deploy gesture does not perform).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 18:28:10 -07:00

363 lines
13 KiB
Python

"""§6.2 / v0.11.0: trust device for 30 days (roadmap item #9).
After a successful OTC or passcode sign-in, a contributor may check
"trust this device for 30 days." The framework then issues a
server-issued opaque token, hashes it (bcrypt) for storage in the
`device_trust` table, and sets a long-lived cookie carrying the raw
token. On a subsequent visit, the cookie is presented at
`/auth/device-trust/start`; if a non-expired, non-revoked row matches,
the session is re-established without another OTC / passcode round
trip.
The shape:
* `issue(user_id, user_agent)` — mint a fresh CSPRNG token, hash it,
insert a row, and return the raw token + row id so the endpoint
can set the cookie. The 30-day expiry is the only knob; the
`revoked_at` column stays NULL.
* `lookup(raw_token)` — walk the user's active rows (the unique
index keys on the hash, so we read a small candidate set), check
the bcrypt hash in constant time, drop any row whose `expires_at`
has passed or whose `revoked_at` is non-NULL, and return the
matched row or None. On a hit, refresh `last_seen_at`.
* `list_for_user(user_id)` — return the active rows for the
/settings/devices surface. Revoked + expired rows are filtered out
so the surface only shows live trust grants.
* `revoke(user_id, row_id)` — stamp `revoked_at` on the row. The
next lookup refuses the cookie token (the row is dead).
* `revoke_all(user_id)` — bulk-revoke every active row for the user.
The /settings/devices surface's "revoke all" button calls this.
Cookie shape: `rfc_device_trust`. HttpOnly, Secure, SameSite=Lax,
Max-Age=2592000 (30 days), Path=/. The cookie value is the raw token;
server-side storage is the hash. The cookie is "essential" per the
v0.13.0 cookie-consent banner (it is part of authentication, not
analytics), so the framework sets it regardless of analytics /
other-cookies choices.
Constant-time comparison: bcrypt's `checkpw` is already constant-time
over the hash bytes. We walk the candidate set linearly with `_check`
which delegates to `bcrypt.checkpw`; no early-exit shortcut leaks
which row was the match.
The raw token never appears in a log line or an exception message;
the helpers carry the token only as a parameter and forget it after
hashing.
The cookie sits orthogonal to the §6.1 `permission_state` gate: a
revoked or pending user with a valid device-trust cookie still
re-establishes their session (the cookie identifies the user, not
their admission state), and the existing `require_contributor` /
`require_admin` dependencies in `auth.py` continue to refuse the
unrelated write surfaces.
"""
from __future__ import annotations
import logging
import secrets
from dataclasses import dataclass
import bcrypt
from . import db
from .auth import SessionUser
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tunables — hard-coded in v0.11.0 (§19.2 candidate to env-ify later).
# ---------------------------------------------------------------------------
TRUST_DURATION_DAYS = 30
COOKIE_NAME = "rfc_device_trust"
COOKIE_MAX_AGE_SECONDS = TRUST_DURATION_DAYS * 24 * 60 * 60
# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
# URL-safe characters; the bcrypt hash is what's stored, so the raw
# token only ever lives in the cookie.
TOKEN_BYTES = 32
# User-Agent header values seen in the wild can be unbounded; clamp
# to a reasonable ceiling so a hostile UA doesn't bloat the row.
USER_AGENT_MAX_LENGTH = 1024
# ---------------------------------------------------------------------------
# Issue
# ---------------------------------------------------------------------------
@dataclass
class IssueOutcome:
"""The shape returned from `issue`.
`raw_token` is the cookie value to send to the client; it never
appears in storage. `row_id` is the surrogate key for the
/settings/devices UI to address the row by id.
"""
raw_token: str
row_id: int
@property
def cookie_value(self) -> str:
"""The value to put in the `rfc_device_trust` cookie: the row-id
selector joined to the raw token (v0.25.0 / audit 0026 M1). The
selector lets `lookup` read one indexed row instead of scanning."""
return f"{self.row_id}.{self.raw_token}"
def _new_token() -> str:
return secrets.token_urlsafe(TOKEN_BYTES)
def _hash(token: str) -> str:
return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
def _check(token: str, token_hash: str) -> bool:
try:
return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
except (ValueError, TypeError):
return False
def _trim_user_agent(ua: str) -> str:
ua = (ua or "").strip()
if len(ua) > USER_AGENT_MAX_LENGTH:
return ua[:USER_AGENT_MAX_LENGTH]
return ua
def issue(user_id: int, user_agent: str) -> IssueOutcome:
"""Mint a fresh device-trust token + row for `user_id`.
The row's expiry is set 30 days in the future. The hash, not the
raw token, lands in the database. The caller (the endpoint) sets
the cookie with the raw token returned here.
"""
raw = _new_token()
h = _hash(raw)
ua = _trim_user_agent(user_agent)
cur = db.conn().execute(
f"""
INSERT INTO device_trust (user_id, device_token_hash, expires_at, user_agent)
VALUES (?, ?, datetime('now', '+{TRUST_DURATION_DAYS} days'), ?)
""",
(user_id, h, ua),
)
row_id = cur.lastrowid
return IssueOutcome(raw_token=raw, row_id=row_id)
# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------
@dataclass
class LookupOutcome:
"""The result of `lookup`.
`user` is populated only on a hit. `reason` distinguishes the
failure modes so the endpoint can decide whether to clear the
cookie ('expired', 'revoked', 'unknown') or just refuse ('invalid').
"""
ok: bool
user: SessionUser | None
reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'revoked'
row_id: int | None = None
def lookup(raw_token: str) -> LookupOutcome:
"""Resolve a presented cookie token to a user.
A hit refreshes `last_seen_at` on the matched row. A miss returns
a reason so the endpoint can clear the stale cookie if the row
was revoked or expired (vs. simply unknown, which probably means
the cookie was forged or the row was wiped by a /settings/devices
revoke from another browser).
"""
raw = (raw_token or "").strip()
if not raw:
return LookupOutcome(ok=False, user=None, reason="invalid")
# v0.25.0 (audit 0026 M1): the cookie is "<row_id>.<raw_token>". We
# parse the row-id selector and read exactly ONE row by its indexed
# primary key, then bcrypt-check the token against that single row.
#
# The previous shape read EVERY device_trust row (all users, including
# revoked/expired) and bcrypt-checked each — an unauthenticated
# CPU-amplification DoS reachable at /auth/device-trust/start that
# grew without bound as the table accumulated. bcrypt's per-row salt
# is why we can't SELECT by hash; carrying the row-id in the cookie is
# the standard fix (the id is not secret; the token still is).
selector, sep, token = raw.partition(".")
if not sep or not selector.isdigit() or not token:
# Legacy bare-token cookies (pre-v0.25.0) and malformed values land
# here. We refuse rather than fall back to a full-table scan, so
# the amplification path is fully closed; affected users simply
# re-authenticate once via OTC/passcode and get a new cookie.
return LookupOutcome(ok=False, user=None, reason="invalid")
matched = db.conn().execute(
"""
SELECT id, user_id, device_token_hash, expires_at, revoked_at
FROM device_trust
WHERE id = ?
""",
(int(selector),),
).fetchone()
# One bcrypt check, against the selected row only. A wrong/forged token
# for a real id reads as 'unknown' (cookie cleared), same as a missing
# row — a probing client can't distinguish the two.
if matched is None or not _check(token, matched["device_token_hash"]):
return LookupOutcome(ok=False, user=None, reason="unknown")
if matched["revoked_at"] is not None:
return LookupOutcome(ok=False, user=None, reason="revoked", row_id=matched["id"])
expired = db.conn().execute(
"SELECT datetime(?) < datetime('now') AS expired",
(matched["expires_at"],),
).fetchone()["expired"]
if expired:
return LookupOutcome(ok=False, user=None, reason="expired", row_id=matched["id"])
# Refresh last-seen so the /settings/devices surface can show the
# user when each device was last active. This is the only write
# the lookup path does on the hot read.
db.conn().execute(
"UPDATE device_trust SET last_seen_at = datetime('now') WHERE id = ?",
(matched["id"],),
)
user_row = db.conn().execute(
"""
SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state
FROM users
WHERE id = ?
""",
(matched["user_id"],),
).fetchone()
if user_row is None:
# The user row was deleted but the device_trust row hadn't
# cascaded yet (shouldn't happen under the FK ON DELETE
# CASCADE — be defensive anyway). Treat as 'unknown' so the
# endpoint clears the cookie.
return LookupOutcome(ok=False, user=None, reason="unknown", row_id=matched["id"])
# Also stamp last_seen_at on the user row so the user's overall
# activity stamp keeps pace with cookie-only sign-ins.
db.conn().execute(
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
(matched["user_id"],),
)
return LookupOutcome(
ok=True,
user=SessionUser(
user_id=user_row["id"],
gitea_id=user_row["gitea_id"] or 0,
gitea_login=user_row["gitea_login"] or "",
display_name=user_row["display_name"],
email=user_row["email"] or "",
avatar_url=user_row["avatar_url"] or "",
role=user_row["role"],
permission_state=user_row["permission_state"] or "granted",
),
reason="ok",
row_id=matched["id"],
)
# ---------------------------------------------------------------------------
# List / revoke (for the /settings/devices surface)
# ---------------------------------------------------------------------------
@dataclass
class DeviceRow:
"""The shape the /settings/devices endpoint returns.
Note the absence of `device_token_hash` — the hash is structurally
private, and the surface has no use for it.
"""
id: int
created_at: str
expires_at: str
last_seen_at: str
user_agent: str
def list_for_user(user_id: int) -> list[DeviceRow]:
"""Active device-trust rows for the user, freshest first.
Filters out revoked rows and rows whose expiry has passed; the
surface only shows live trust grants. A user wondering "which
devices are signed in" gets the answer that matches what the
framework would actually accept on a presented cookie.
"""
rows = db.conn().execute(
"""
SELECT id, created_at, expires_at, last_seen_at, user_agent
FROM device_trust
WHERE user_id = ?
AND revoked_at IS NULL
AND datetime(expires_at) > datetime('now')
ORDER BY last_seen_at DESC, id DESC
""",
(user_id,),
).fetchall()
return [
DeviceRow(
id=row["id"],
created_at=row["created_at"],
expires_at=row["expires_at"],
last_seen_at=row["last_seen_at"],
user_agent=row["user_agent"] or "",
)
for row in rows
]
def revoke(user_id: int, row_id: int) -> bool:
"""Revoke a single device-trust row for the given user.
Returns True iff a row was matched (still active, belongs to the
user). The user-id scope is enforced in SQL so a hostile client
cannot revoke another user's row by guessing ids.
"""
cur = db.conn().execute(
"""
UPDATE device_trust
SET revoked_at = datetime('now')
WHERE id = ?
AND user_id = ?
AND revoked_at IS NULL
""",
(row_id, user_id),
)
return cur.rowcount > 0
def revoke_all(user_id: int) -> int:
"""Revoke every active device-trust row for the user. Returns the
count of rows touched.
The /settings/devices "revoke all" button calls this. The user's
current request stays authenticated via its session cookie; the
device-trust cookie on the current device is also revoked, but
the session middleware's `rfc_session` cookie keeps the request
flow alive until the user signs out or the session cookie
expires.
"""
cur = db.conn().execute(
"""
UPDATE device_trust
SET revoked_at = datetime('now')
WHERE user_id = ?
AND revoked_at IS NULL
""",
(user_id,),
)
return cur.rowcount