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>
413 lines
15 KiB
Python
413 lines
15 KiB
Python
"""§6.2 / v0.7.0 / v0.8.0: email + one-time-code sign-in.
|
|
|
|
Replaces the Gitea OAuth gesture as the primary human-auth path. The
|
|
Gitea bot user + token are still needed for server-side git
|
|
operations (repo reads, PR creation); only the operator-facing
|
|
sign-in surface moves through this module.
|
|
|
|
The shape:
|
|
|
|
* `request_code(email)` generates a 6-digit decimal code,
|
|
hashes it (bcrypt), stores the hash + expiry in `otc_codes`,
|
|
and dispatches a plain-text email via `email_otc.send`. It
|
|
invalidates any prior unused codes for the same email so a
|
|
re-request keeps the surface to one outstanding code per
|
|
address. The TTL comes from `OTC_TTL_MINUTES` (default 10).
|
|
A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default
|
|
60) refuses back-to-back requests inside the window.
|
|
|
|
* `verify_code(email, code)` walks the most recent unconsumed
|
|
non-expired row for the email, checks the bcrypt hash, marks
|
|
the row consumed, and returns the linked or freshly-provisioned
|
|
user row.
|
|
|
|
* `provision_or_link_user(email)` is the migration path: if a
|
|
`users` row already carries `email` (case-insensitive), it is
|
|
reused — `gitea_id` is left alone so a grandfathered OAuth-era
|
|
user keeps the linker intact. Otherwise a fresh contributor
|
|
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`,
|
|
and `permission_state = 'pending'` (v0.8.0 — see below).
|
|
|
|
The endpoints in `main.py` thin-wrap this module.
|
|
|
|
v0.8.0 (roadmap item #6) replaces the v0.3.0 `allowed_emails` gate at
|
|
the request surface. The request handler used to silently drop OTC
|
|
requests for emails not on the allowlist; now any valid email
|
|
receives a code. The admission gate moves to `permission_state` on
|
|
the freshly-provisioned `users` row: a fresh user lands in 'pending'
|
|
and waits for an admin grant before write endpoints accept them.
|
|
Read surfaces stay open (the same blast radius v0.6.0 / item #4
|
|
already audited for anonymous viewers).
|
|
|
|
The `allowed_emails` table itself stays in the schema as a
|
|
fast-path bypass — the admin UI from v0.3.0 continues to manage it,
|
|
and a future release (v0.9.0's admin user-management page) collapses
|
|
the two admission surfaces into one. The OTC request path no
|
|
longer consults the table.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
|
|
import bcrypt
|
|
|
|
from . import db
|
|
from .auth import SessionUser
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tunables — env-driven with defaults so v0.7.0 needs no new secrets.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ttl_minutes() -> int:
|
|
raw = os.environ.get("OTC_TTL_MINUTES", "").strip()
|
|
if not raw:
|
|
return 10
|
|
try:
|
|
return max(1, int(raw))
|
|
except ValueError:
|
|
return 10
|
|
|
|
|
|
def _cooldown_seconds() -> int:
|
|
raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip()
|
|
if not raw:
|
|
return 60
|
|
try:
|
|
return max(0, int(raw))
|
|
except ValueError:
|
|
return 60
|
|
|
|
|
|
# v0.25.0 / security audit 0026 (H1): per-email OTC verify lockout,
|
|
# mirroring the passcode path (passcode.py). Five consecutive wrong codes
|
|
# for an email lock its OTC verify for 15 minutes. The per-IP limiter in
|
|
# ratelimit.py is the primary brute-force brake; this is the durable,
|
|
# passcode-parity layer.
|
|
LOCKOUT_AFTER_FAILED_ATTEMPTS = 5
|
|
LOCKOUT_DURATION_MINUTES = 15
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Code generation + hashing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _new_code() -> str:
|
|
"""Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the
|
|
code resists guessing even at the small (10^6) keyspace. The TTL
|
|
+ rate-limit are what carry the security weight — the entropy of a
|
|
six-digit code by itself is intentionally human-readable."""
|
|
return f"{secrets.randbelow(1_000_000):06d}"
|
|
|
|
|
|
def _hash_code(code: str) -> str:
|
|
"""bcrypt over the code bytes. The hash is stored at rest; the code
|
|
itself only travels in the outbound email and the inbound verify
|
|
body."""
|
|
return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
|
|
|
|
|
def _check_code(code: str, code_hash: str) -> bool:
|
|
try:
|
|
return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii"))
|
|
except (ValueError, TypeError):
|
|
return False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request path
|
|
#
|
|
# v0.8.0: the allowlist gate from v0.7.0 / v0.3.0 is removed here. Any
|
|
# valid email receives a code; the admission gate moved to
|
|
# `permission_state` on the freshly-provisioned `users` row (see
|
|
# `provision_or_link_user`). The `allowed_emails` table stays in the
|
|
# schema (admin UI from v0.3.0 still manages it); v0.9.0's admin
|
|
# user-management page will collapse the two surfaces.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class RequestOutcome:
|
|
"""The outcome of a `request_code` call.
|
|
|
|
`code` is None whenever no code was generated — the cooldown
|
|
window blocked the request or the email was syntactically
|
|
invalid. The caller (the API endpoint) does not surface the
|
|
invalid-email shape to the user; it returns 202 either way.
|
|
The cooldown shape surfaces as a loud 429 per the v0.7.0
|
|
contract.
|
|
"""
|
|
sent: bool
|
|
code: str | None
|
|
reason: str # 'sent' | 'cooldown' | 'invalid'
|
|
|
|
|
|
def request_code(email: str) -> RequestOutcome:
|
|
email = (email or "").strip()
|
|
if not email or "@" not in email:
|
|
return RequestOutcome(sent=False, code=None, reason="invalid")
|
|
|
|
# Cooldown: refuse if a code was issued for this email in the last
|
|
# COOLDOWN_SECONDS. We surface it as a distinct outcome so the
|
|
# endpoint can return 429 — the spec calls this out as a "loud
|
|
# failure" so the abuse path is visible rather than swallowed.
|
|
cooldown = _cooldown_seconds()
|
|
if cooldown > 0:
|
|
row = db.conn().execute(
|
|
f"""
|
|
SELECT 1 FROM otc_codes
|
|
WHERE email = ?
|
|
AND datetime(created_at, '+{cooldown} seconds') > datetime('now')
|
|
LIMIT 1
|
|
""",
|
|
(email,),
|
|
).fetchone()
|
|
if row is not None:
|
|
return RequestOutcome(sent=False, code=None, reason="cooldown")
|
|
|
|
# Invalidate prior unused codes for this email. A re-request is
|
|
# always for the most recent code; older codes are dead.
|
|
db.conn().execute(
|
|
"""
|
|
UPDATE otc_codes
|
|
SET consumed_at = datetime('now')
|
|
WHERE email = ?
|
|
AND consumed_at IS NULL
|
|
""",
|
|
(email,),
|
|
)
|
|
|
|
code = _new_code()
|
|
code_hash = _hash_code(code)
|
|
ttl = _ttl_minutes()
|
|
db.conn().execute(
|
|
f"""
|
|
INSERT INTO otc_codes (email, code_hash, expires_at)
|
|
VALUES (?, ?, datetime('now', '+{ttl} minutes'))
|
|
""",
|
|
(email, code_hash),
|
|
)
|
|
return RequestOutcome(sent=True, code=code, reason="sent")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Verify path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class VerifyOutcome:
|
|
"""Result of a `verify_code` call.
|
|
|
|
`user` is populated only on success. `reason` distinguishes the
|
|
failure modes the UI can render — 'expired', 'consumed', 'wrong',
|
|
'unknown' (no outstanding code at all). The endpoint maps the
|
|
failure modes to a single 400 with a generic message; the reason
|
|
is logged for the operator.
|
|
"""
|
|
ok: bool
|
|
user: SessionUser | None
|
|
reason: str
|
|
# v0.25.0 (H1): ISO-8601 stamp when reason == 'locked'.
|
|
locked_until: str | None = None
|
|
|
|
|
|
def _verify_lockout_until(email: str) -> str | None:
|
|
"""Return the active lockout stamp for `email`, or None if not locked.
|
|
|
|
Clears an elapsed lockout (and resets the counter) as a side effect so
|
|
the next failure starts a fresh budget — mirrors passcode.verify_passcode.
|
|
"""
|
|
row = db.conn().execute(
|
|
"SELECT failed_attempts, locked_until FROM otc_verify_state WHERE email = ?",
|
|
(email,),
|
|
).fetchone()
|
|
if row is None or not row["locked_until"]:
|
|
return None
|
|
still_locked = db.conn().execute(
|
|
"SELECT datetime(?) > datetime('now') AS locked", (row["locked_until"],),
|
|
).fetchone()["locked"]
|
|
if still_locked:
|
|
return row["locked_until"]
|
|
db.conn().execute(
|
|
"UPDATE otc_verify_state SET failed_attempts = 0, locked_until = NULL WHERE email = ?",
|
|
(email,),
|
|
)
|
|
return None
|
|
|
|
|
|
def _record_verify_failure(email: str) -> None:
|
|
"""Increment the per-email failure counter; stamp a lockout once it
|
|
crosses the threshold. Mirrors the passcode lockout shape."""
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO otc_verify_state (email, failed_attempts)
|
|
VALUES (?, 1)
|
|
ON CONFLICT(email) DO UPDATE SET failed_attempts = failed_attempts + 1
|
|
""",
|
|
(email,),
|
|
)
|
|
count = db.conn().execute(
|
|
"SELECT failed_attempts FROM otc_verify_state WHERE email = ?", (email,),
|
|
).fetchone()["failed_attempts"]
|
|
if count >= LOCKOUT_AFTER_FAILED_ATTEMPTS:
|
|
db.conn().execute(
|
|
f"""
|
|
UPDATE otc_verify_state
|
|
SET locked_until = datetime('now', '+{LOCKOUT_DURATION_MINUTES} minutes')
|
|
WHERE email = ?
|
|
""",
|
|
(email,),
|
|
)
|
|
|
|
|
|
def _clear_verify_state(email: str) -> None:
|
|
db.conn().execute("DELETE FROM otc_verify_state WHERE email = ?", (email,))
|
|
|
|
|
|
def verify_code(email: str, code: str) -> VerifyOutcome:
|
|
email = (email or "").strip()
|
|
code = (code or "").strip()
|
|
if not email or not code:
|
|
return VerifyOutcome(ok=False, user=None, reason="invalid")
|
|
|
|
# v0.25.0 (H1): refuse before spending any bcrypt if this email is in
|
|
# its OTC-verify lockout window. The passcode path is unaffected — a
|
|
# locked-out OTC user can still set/use a passcode, and vice versa.
|
|
locked_until = _verify_lockout_until(email)
|
|
if locked_until:
|
|
return VerifyOutcome(ok=False, user=None, reason="locked", locked_until=locked_until)
|
|
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT id, code_hash, expires_at, consumed_at
|
|
FROM otc_codes
|
|
WHERE email = ?
|
|
ORDER BY id DESC
|
|
LIMIT 5
|
|
""",
|
|
(email,),
|
|
).fetchall()
|
|
if not rows:
|
|
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
|
|
|
# Walk the recent rows so a user who pasted an older code still
|
|
# gets a sensible error — without this, the most-recent-row check
|
|
# would mask "you entered yesterday's code" as "wrong code".
|
|
matched = None
|
|
for row in rows:
|
|
if _check_code(code, row["code_hash"]):
|
|
matched = row
|
|
break
|
|
|
|
if matched is None:
|
|
# A genuine wrong guess against this email — the brute-force
|
|
# signal. Count it toward the lockout threshold (H1).
|
|
_record_verify_failure(email)
|
|
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
|
|
|
if matched["consumed_at"] is not None:
|
|
return VerifyOutcome(ok=False, user=None, reason="consumed")
|
|
|
|
expired = db.conn().execute(
|
|
"SELECT datetime(?) < datetime('now') AS expired",
|
|
(matched["expires_at"],),
|
|
).fetchone()["expired"]
|
|
if expired:
|
|
return VerifyOutcome(ok=False, user=None, reason="expired")
|
|
|
|
# Stamp consumed before provisioning so a parallel verify of the
|
|
# same row can't double-sign-in.
|
|
db.conn().execute(
|
|
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
|
|
(matched["id"],),
|
|
)
|
|
# Success wipes the per-email failure counter (H1).
|
|
_clear_verify_state(email)
|
|
user = provision_or_link_user(email)
|
|
return VerifyOutcome(ok=True, user=user, reason="ok")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provisioning — the migration path from OAuth identity to email identity.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def provision_or_link_user(email: str) -> SessionUser:
|
|
"""Link the OTC sign-in to a `users` row.
|
|
|
|
Match order:
|
|
1. An existing row whose email equals (case-insensitive) the
|
|
requested email — the OAuth-era user is grandfathered in via
|
|
this path. `gitea_id` is preserved so a future OAuth round
|
|
trip still resolves the same row. `permission_state` is
|
|
read off the row as-is — grandfathered users come through
|
|
migration with 'granted' (the column default), so their
|
|
contributor capabilities are unaffected.
|
|
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
|
|
`gitea_login = NULL`, and `permission_state = 'pending'`
|
|
(v0.8.0). The display name defaults to the local part of
|
|
the email (everything before the `@`); a separate
|
|
`POST /auth/me/beta-request` call lands first name / last
|
|
name / "why I want access" on the same row.
|
|
|
|
The §6.1 owner-zero bootstrap still applies: if the email matches
|
|
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
|
|
is provisioned with role='owner'. v0.7.0 keeps that field as the
|
|
Gitea login (so existing deployments don't break); a future
|
|
release may add a parallel `OWNER_EMAIL` env if the OAuth route is
|
|
dropped entirely.
|
|
"""
|
|
email = email.strip()
|
|
existing = db.conn().execute(
|
|
"SELECT * FROM users WHERE email = ? COLLATE NOCASE",
|
|
(email,),
|
|
).fetchone()
|
|
if existing is not None:
|
|
db.conn().execute(
|
|
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
|
(existing["id"],),
|
|
)
|
|
return SessionUser(
|
|
user_id=existing["id"],
|
|
gitea_id=existing["gitea_id"] or 0,
|
|
gitea_login=existing["gitea_login"] or "",
|
|
display_name=existing["display_name"],
|
|
email=existing["email"] or email,
|
|
avatar_url=existing["avatar_url"] or "",
|
|
role=existing["role"],
|
|
permission_state=existing["permission_state"] or "granted",
|
|
)
|
|
|
|
display = email.split("@", 1)[0] or email
|
|
# v0.8.0: 'pending' is the explicit insert value; the migration
|
|
# default of 'granted' is what passes grandfathered users
|
|
# through. A fresh OTC user lands in 'pending' regardless of
|
|
# what the migration default says, so the gate engages reliably
|
|
# even if a future migration changes the default.
|
|
cur = db.conn().execute(
|
|
"""
|
|
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
|
VALUES (NULL, NULL, ?, ?, '', 'contributor', 'pending')
|
|
""",
|
|
(email, display),
|
|
)
|
|
user_id = cur.lastrowid
|
|
return SessionUser(
|
|
user_id=user_id,
|
|
gitea_id=0,
|
|
gitea_login="",
|
|
display_name=display,
|
|
email=email,
|
|
avatar_url="",
|
|
role="contributor",
|
|
permission_state="pending",
|
|
)
|