{"data":{"items":[{"id":"xss","threat":"Cross-site scripting","vector":"Script injected through content/config (e.g. a javascript: link) or through a visitor's message.","mitigations":["React escapes all rendered text; the only raw HTML is JSON-LD, serialised with <, >, & and line separators escaped.","Every href passes an allow-list: #anchor, /path, https://, mailto:, tel: — javascript:, data: and //host are rejected by the schema.","Nonce-based Content-Security-Policy with 'strict-dynamic': injected markup cannot execute script.","Visitor messages are never rendered on the site; the admin API returns them as JSON."],"where":["src/server/content/schemas.ts","src/proxy.ts","src/app/layout.tsx"]},{"id":"csrf","threat":"Cross-site request forgery","vector":"Another site makes a visitor's browser POST to the contact endpoint.","mitigations":["Origin must exactly match the configured site URL or the request's own host; Sec-Fetch-Site: cross-site is rejected.","Only application/json is accepted, which forces a CORS preflight this app never approves.","A signed form token issued by this server is required."],"where":["src/server/security/origin.ts","src/server/http/body.ts","src/server/security/form-token.ts"]},{"id":"spam","threat":"Spam and automated submissions","vector":"Bots posting the contact form, replaying captured requests, or resubmitting the same message.","mitigations":["Off-screen honeypot field; bots that fill it get a convincing 201 and nothing is stored.","HMAC-signed form token enforces a minimum fill time and maximum age, using server time only.","Token nonces are single-use (checked inside the write transaction); identical messages within 24h are de-duplicated.","Per-client token-bucket rate limit with RateLimit and Retry-After headers."],"where":["src/server/contact/service.ts","src/server/security/form-token.ts","src/server/security/rate-limit.ts"]},{"id":"brute-force","threat":"Admin token brute force","vector":"Guessing the bearer token for the admin messages endpoint.","mitigations":["Endpoint returns 404 unless ADMIN_TOKEN (≥ 32 chars) is configured.","Constant-time comparison over SHA-256 digests (no length or prefix timing leak).","Failed attempts are rate-limited per client, and even a correct token is refused during lockout."],"where":["src/app/api/v1/admin/messages/route.ts","src/server/security/crypto.ts"]},{"id":"email","threat":"Email abuse and credential exposure","vector":"Using the contact form to inject email headers, send HTML/phishing content to the owner, spoof senders, or leak SMTP credentials and the owner's address.","mitigations":["SMTP host, credentials and recipient exist only in server environment variables; the browser only ever sees \"received\" and a reference id.","Plain-text body only; name and subject reject newlines before they reach headers, and nodemailer encodes headers as well.","The visitor is set as Reply-To, never as From, so nothing is sent pretending to be them.","STARTTLS is required on non-TLS ports; TLS 1.2 minimum; file and URL access disabled in the mailer; hard timeouts.","Provider errors are logged server-side and never returned; health shows only whether email is configured."],"where":["src/server/contact/mailer.ts","src/server/config/env.ts","src/server/contact/service.ts"]},{"id":"resource-exhaustion","threat":"Resource exhaustion (DoS)","vector":"Huge bodies, chunked bodies with a lying Content-Length, many source IPs, or filling the disk.","mitigations":["Body size enforced while streaming; the stream is cancelled as soon as the cap is crossed.","Checks run cheapest-first: rate limit before reading the body, schema before any I/O.","Rate-limit state has a hard key cap with LRU eviction; IPv6 is bucketed per /64.","Mailbox has a configurable maximum size; content files have a size cap."],"where":["src/server/http/body.ts","src/server/security/rate-limit.ts","src/server/security/client-ip.ts"]},{"id":"path-traversal","threat":"Path traversal","vector":"A crafted URL or config value reading files outside /content.","mitigations":["The public API maps collection names through a fixed registry; user input never becomes a path.","Template paths must match pages/<slug>.json, and every resolved path is checked to stay inside the content root.","URL segments must match [a-z0-9-] before routing."],"where":["src/server/content/paths.ts","src/server/content/manifest.ts","src/server/content/store.ts"]},{"id":"data-integrity","threat":"Lost or corrupted data","vector":"Concurrent writes, crashes mid-write, or a damaged storage file.","mitigations":["Read-modify-write is serialized through an in-process queue.","Temp file (O_EXCL, 0600) → fsync → atomic rename → directory fsync.","Data is schema-validated before it is written and when it is read; a corrupt file is quarantined, never deleted."],"where":["src/server/storage/json-file-store.ts"]},{"id":"bad-config","threat":"Broken or unsafe configuration","vector":"A typo in JSON content, or a limit set so high it disables protection.","mitigations":["Strict schemas reject unknown keys; the build runs validate:content and fails with every error listed.","Abuse limits are configurable only within safe bounds (e.g. contact maxRequests ≤ 20).","At runtime, an invalid edit keeps the last known good version live and marks health as degraded."],"where":["scripts/validate-content.ts","src/server/content/integrity.ts","src/server/content/store.ts"]},{"id":"privacy","threat":"Personal data leakage","vector":"Logs, error messages, API responses or stored data revealing visitor or owner details.","mitigations":["IPs are stored only as an HMAC with a derived key; logs redact email, name, message, IP and credentials by key.","Errors returned to clients are generic and carry a request id; stack traces stay in server logs (and only outside production).","Links marked public: false (e.g. phone) are never rendered or returned by the API.","Admin responses are Cache-Control: no-store, private.","/api/health never includes the SMTP host, user or recipient address."],"where":["src/server/http/logger.ts","src/server/content/manifest.ts","src/server/http/handler.ts"]},{"id":"spoofing","threat":"Header spoofing and log injection","vector":"Forged X-Forwarded-For to dodge rate limits, or a crafted X-Request-Id to inject log lines.","mitigations":["X-Forwarded-For is read from the right, trusting only the configured number of proxy hops.","Incoming request ids are accepted only if they match a short safe pattern; otherwise a UUID is generated."],"where":["src/server/security/client-ip.ts","src/server/http/request-id.ts"]},{"id":"unicode","threat":"Unicode tricks","vector":"Bidi overrides, zero-width or control characters hiding content, or newlines used for header injection.","mitigations":["Input is NFC-normalized; control, zero-width and bidi-override characters are stripped before length checks.","Single-line fields (name, subject) reject newlines."],"where":["src/server/contact/schema.ts"]},{"id":"clickjacking","threat":"Clickjacking and browser-level leaks","vector":"Framing the site, or leaking referrers and capabilities.","mitigations":["frame-ancestors 'none' and X-Frame-Options: DENY.","Referrer-Policy, Permissions-Policy, COOP/CORP, nosniff and HSTS on every response; X-Powered-By removed."],"where":["next.config.ts","src/proxy.ts"]}]},"meta":{"collection":"threat-model"}}