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
editorrole). 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 untilmfa_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/Origincheck on every mutating request and a double-submit token (HMAC of a per-session secret, delivered as<meta name="csrf-token">, required inx-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) checkrole. Client-side hiding is never relied upon.
Public/private segregation (defence in depth)
- Database role
tools_reader(used by all public routes) cannot readtools,owner_notes,projects,users,sessions,audit_log,jobs; it only sees thepublic_toolsview (published + public + not merged) and reference tables. Writes are impossible by privilege except INSERT onerror_reportsandsearch_events; 5 s statement timeout. - Application: public query module imports only the public client; admin modules are
server-only. - HTTP:
/admin,/login,/api/adminsendX-Robots-Tag: noindexandCache-Control: no-store;robots.txtdisallows them; sitemap lists public pages only. - Tests:
tests/integration/db.test.tsasserts the role boundary;scripts/security-audit.tsasserts 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-htmlallow-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:<month>. Fetched text is passed as <untrusted_document> 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_dumpcustom 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 auditin 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).