08 — SECURITY
v3.1.0 — post-audit edition. This document describes the security architecture as shipped after the 2026-08-07 enterprise audit. The findings/fixes evidence lives in
SECURITY_AUDIT_REPORT.mdat the repo root; verification commands are at the bottom of this file.
1. Principles
- Zero trust / defense in depth: every request is authenticated and authorized server-side; client code is never consulted for permissions.
- Secure by default: deny schemes/aliases/inputs unless allow-listed.
- Least privilege: API keys carry enforcement scopes; key-management is session-only so a leaked key cannot self-propagate.
- No secrets in the repo:
.env*ignored; provider keys (Web3Forms) and session salt live only in the environment. - Fail closed, fail quiet: RFC 9457 problem bodies, never stack traces, SQL, or paths.
2. Identity & Access
| Control | Implementation |
|---|---|
| Passwords | bcryptjs 3, cost 12; 72-byte truncation explicitly rejected; breached-password denylist at registration |
| Login throttles | 15/min per IP and 10/5 min per credential; dummy-compare evens timing (anti-enumeration) |
| Sessions | 256-bit opaque tokens, SHA-256 hashed at rest, 30-day absolute expiry, lazy purge of expired rows, destroyed server-side at logout |
| Session cookie | __Host-yas_session in production (HttpOnly, Secure, SameSite=Lax, Path=/, no Domain) / yas_session in dev |
| API keys | yas_live_ + 128-bit entropy, hash-at-rest, shown once, revocable, expirable, scopes enforced (links:read, links:write, analytics:read) |
| Key management | /api/v1/api-keys* require a browser session — keys cannot manage keys |
| CSRF | SameSite=Lax plus Origin≡Host guard on every cookie-authenticated mutation (403 CSRF_BLOCKED) |
| Roles | USER/ADMIN column present; no hidden admin endpoints exist by design |
| Brute force (v3.2.5) | Per-account and per-IP lockout after failed logins: 5 → 60s, 10 → 5 min, 15+ → 15 min (lib/brute-force.ts); successful login resets the counter; returns 429 ACCOUNT_LOCKED |
| reCAPTCHA (v3.2.5, optional) | Google reCAPTCHA v3 on login/register; server-side siteverify with score ≥ 0.5 and action binding; fails closed when enabled (lib/recaptcha.ts) |
| OAuth/OIDC (v3.2.5, optional) | Google, GitHub, Apple Authorization Code flow; random state nonce stored in a SameSite=Lax httpOnly cookie and verified on callback (login-CSRF); accounts linked by verified email (lib/oauth.ts) |
3. Input / Output Safety
- Every write endpoint parses with zod (strict schemas for PATCH), has a
JSON size ceiling (
readJson, 5–100 KB per route) and returns 413/400/409 — never 500 — on bad input. - URLs: http/https only, ≤2048 chars, no control/whitespace chars
(
destinationUrlSchema) — killsjavascript:,data:, CR/LF smuggling. - Aliases:
^[a-z0-9-_]{3,30}$, reserved-namespace denylist; shortCode is immutable so alias edits never break published links. - Markdown → HTML: single sanctioned path
lib/markdown.ts=marked+sanitize-html(strict tag/attr/scheme allow-list,rel=noopener noreferrer+ lazy images,script/iframe/object/formdropped with contents). Regex "sanitizers" are banned. - Emails: CR/LF/control chars stripped from single-line fields (header injection defense) before relaying to the provider.
- CSV export: formula-injection escaping (
', quoting) + 10k row cap.
4. HTTP & Transport
- CSP, HSTS (2y preload),
X-Frame-Options: DENY,nosniff,Referrer-Policy: strict-origin-when-cross-origin,Permissions-Policy— set centrally innext.config.mjsand at Nginx. - CSP:
'unsafe-eval'removed;object-src 'none';upgrade-insecure-requestsin prod.script-src 'self' 'unsafe-inline'is the documented residual (prerendered pages can't carry nonces — see SECURITY_AUDIT_REPORT §12 R-1). /api/*getsCache-Control: no-store; 302/401/410 redirect responses are alsono-store(analytics integrity + no replay).- All 429s carry
Retry-After.
5. Secrets & Config
SESSION_SECRET(32+ random chars required) — salts IP hashes in analytics; rotation does not invalidate sessions.WEB3FORMS_ACCESS_KEY— server-side only (contact form).SEED_ADMIN_PASSWORD— required explicitly; seed refuses defaults.- No git history secrets (verified),
.gitignoreblocks.env*, dev.db, certs; compose has zero hardcoded credentials.
6. Abuse, DoS & Rate Limiting
| Surface | Limit |
|---|---|
| register/login | 20/min + 15/min per IP; +10/5min per credential |
| link create | 60/min user, 120/min IP |
| key create | 10/min user |
| contact | 5/min IP + honeypot |
| newsletter | 10/min IP + honeypot |
| tools / qr | 60/min IP, 64 KiB cap, allow-listed algos, ReDoS guards |
| redirect GET / POST | 240/min and 60/min IP; 10/min per-link password |
| analytics | scope-gated; DB-side aggregation (no row materialization) |
In-memory limiter is per-instance and bounds its own memory (60s sweep, 50k
cap). Multi-node → move buckets to Valkey/Redis (REDIS_URL, see §12 R-2 of
the audit report).
7. Infrastructure (Oracle Linux 9)
TLS 1.2/1.3 + OCSP stapling, HSTS preload, X-Real-IP overwrite (trusted IP
source), client_max_body_size 2m, edge limit_req zones, fail2ban,
firewalld (22/80/443), SELinux enforcing, systemd ProtectSystem=strict +
NoNewPrivileges + PrivateTmp, journald rotation, daily DB backups
(deploy/backup.sh), dnf-automatic security updates. Full snippets:
docs/15_OPERATIONS_ORACLE_LINUX.md, deploy/nginx-yas.conf.
Client-IP trust (v3.2.5): lib/api-helpers.ts#getIp() trusts
X-Real-IP/X-Forwarded-For/CF-Connecting-IP only when TRUST_PROXY=true
(production; Nginx overwrites X-Real-IP with $remote_addr, so callers cannot
spoof it). Outside a trusted proxy (local dev / a directly-exposed standalone
server) the app reports the genuine connection address and ignores forged
headers — preventing IP spoofing for rate limiting and analytics.
8. Verification Battery (run before every release)
pnpm typecheck && pnpm lint && pnpm build # gates 1–3
pnpm audit --prod # gate 4: 0 vulns
bash scripts/grep-gates.sh # gate 5: banned patterns
BASE=$URL pnpm smoke # gate 6: 57 functional checks
BASE=$URL pnpm smoke:security # gate 7: 18 adversarial checks