bd3ef269d4
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>
363 lines
13 KiB
Python
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
|