# Security ## Threat model Assets: the public catalogue (integrity), the owner's private inventory (confidentiality: use states, notes, subscriptions, deployment URLs, projects), credentials (owner account, DB roles, AI keys, backup passphrase), the node itself (SSRF pivot into the cluster LAN / WireGuard overlay). Adversaries: anonymous web users, automated scanners, a compromised vendor page processed by the link checker or AI suggestions, a stolen session cookie. ## Identity and admin isolation - Single owner account (optional `editor` role). Password: argon2id (64 MiB, t=3), policy ≥ 14 chars, mixed case + digit, numeric-only rejected. Initial password must be changed at first login (`must_change_password`). - MFA: TOTP (otplib) and/or **passkeys/WebAuthn** (`@simplewebauthn`, `userVerification: required`). A passkey login satisfies MFA; TOTP-enabled accounts cannot use admin APIs until `mfa_passed`. - Sessions: 32-byte random token, SHA-256 stored, idle 14 d / absolute 30 d, rotated after MFA, after password change and every 12 h; cookie `__Host-tools_session` (HttpOnly, Secure, SameSite=Strict, Path=/). Revocation list in Settings; password change and TOTP enable revoke other sessions. - Brute force: DB-backed limiter (5 failures / 15 min per IP or e-mail, exponential lockout to 60 min), constant-time dummy verify for unknown e-mails, in-memory limiters on TOTP/passkey/report endpoints. - CSRF: `Sec-Fetch-Site`/`Origin` check on every mutating request **and** a double-submit token (HMAC of a per-session secret, delivered as ``, required in `x-csrf-token`). Login itself is protected by the origin check. - Authorization is enforced server-side on every `/api/admin/*` handler (`requireUser`) and every admin page (`requireUserPage`); owner-only operations (`owner_notes`, projects, full export) check `role`. Client-side hiding is never relied upon. ## Public/private segregation (defence in depth) 1. Database role `tools_reader` (used by all public routes) cannot read `tools`, `owner_notes`, `projects`, `users`, `sessions`, `audit_log`, `jobs`; it only sees the `public_tools` view (published + public + not merged) and reference tables. Writes are impossible by privilege except INSERT on `error_reports` and `search_events`; 5 s statement timeout. 2. Application: public query module imports only the public client; admin modules are `server-only`. 3. HTTP: `/admin`, `/login`, `/api/admin` send `X-Robots-Tag: noindex` and `Cache-Control: no-store`; `robots.txt` disallows them; sitemap lists public pages only. 4. Tests: `tests/integration/db.test.ts` asserts the role boundary; `scripts/security-audit.ts` asserts unauthenticated access, IDOR by slug/id, cross-site login, payload field sets and leakage patterns. ## Web security headers (next.config.ts) CSP `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests` (no `unsafe-eval` in production; no third-party origins at all), HSTS 2 years incl. subdomains, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` (camera/mic/geo/topics off), COOP same-origin, `poweredByHeader: false`. CORS: no cross-origin API access is granted (same-origin only). ## Input handling - Zod validation on every API body; search input sanitized and length-capped; all SQL through parameterized tagged templates (postgres-js) or Drizzle; Markdown rendered through `sanitize-html` allow-list; React escapes output; JSON-LD serialized with `<` escaped. - Error report form: honeypot field, size limits, 5/h/IP, IP stored only as a salted hash. ## Outbound requests (SSRF) `safeFetch` resolves the hostname and refuses RFC1918/loopback/link-local/CGNAT/multicast/ULA/IPv4-mapped addresses, `localhost`, `*.local`, `*.internal`, `*.maclustr.io`, bare hostnames and the cloud metadata address; every redirect hop is re-validated; https→http downgrades are rejected; 15 s timeout, 1 MiB body cap, credentials omitted. The worker is the only component that makes outbound requests (plus the admin AI-suggest endpoint, owner-triggered). ## AI features Optional, off by default (`AI_PROVIDER=none`). Keys are read from the node `.env` only (never shipped to the browser); monthly budget (`AI_MONTHLY_BUDGET_USD`) enforced before every call; usage telemetry in `settings.ai_usage:`. Fetched text is passed as `` with explicit instructions to ignore embedded instructions; output must parse as JSON matching a zod schema; evidence quotes are verified against the page; nothing is persisted without the editor clicking Save (which writes a revision and audit entry). ## Data protection and operations - Secrets only in `~/apps/tools-spboucher-ai/.env` (mode 600) on the node and an offline copy under `~/Desktop/Cluster/secrets/tools-spboucher-ai/`; `.env*` is git-ignored; the deploy hook refuses to run without it; the manifest contains no secrets. - PostgreSQL listens on localhost; the app listens on 0.0.0.0 only so that the WireGuard peer (10.67.0.x) can reach it — the LAN is private and the gateway is the only public entry. - Backups: `pg_dump` custom format encrypted with AES-256-CBC (PBKDF2 200k) → local dir + rsync to M3U96b; restore drill script creates a temporary database and compares counts/hashes. - Audit log and revisions are append-only (DB trigger). Dependency scanning: `pnpm audit` in CI (`scripts/ci.sh`). ## Known limitations / follow-ups - In-memory rate limiters are per process (single PM2 instance — acceptable; move to Postgres if the web tier is scaled). - CSP keeps `'unsafe-inline'` for scripts because pages are statically cached (no per-request nonce); no third-party scripts exist, which limits the exposure. - No WAF/fail2ban on the gateway for this host (cluster-wide item in the MacLustr backlog).