Threat Model
STRIDE-based threat model covering every architecture component shipped (or planned) by the boilerplate. Each row links the threat to the static verification gate, runtime control, or test surface that demonstrates the mitigation so the model stays anchored to evidence the codebase already produces. Live runtime proof (penetration test, production incident review) is tracked separately in pen-test-scope.md and the post-incident reviews under incident-response.md.
Review cadence
This document is refreshed when:
- A new route class appears in
route-access-inventory.md.
- A new env var enters
packages/config/src/catalog.ts with classification required-core, required-per-app, or production-only.
- A milestone closes that introduces a new subsystem (e.g. a billing webhook surface, a new worker queue group, a new vector store backend).
- A penetration test or coordinated disclosure finding lands a new control class.
A full review is mandatory before every major-release tag.
Components in scope
| # |
Component |
Code location |
Coverage |
| 1 |
Web |
apps/web |
In scope |
| 2 |
API |
apps/api |
In scope |
| 3 |
Worker |
apps/worker |
In scope |
| 4 |
Database (Postgres + PgBouncer + replicas) |
packages/db, infra/pgbouncer |
In scope |
| 5 |
Two-tier cache (Redis + in-memory L1) |
packages/shared-store |
In scope |
| 6 |
Outbox + dispatcher |
packages/db, apps/worker/src/jobs/outbox-* |
In scope |
| 7 |
Object storage |
packages/storage |
In scope |
| 8 |
Mail |
packages/mail |
In scope |
| 9 |
Realtime collaboration (Hocuspocus) |
apps/hocuspocus, packages/realtime |
In scope |
| 10 |
Plugins (WASM sandbox foundation) |
packages/plugin-runtime, packages/plugin-sdk, apps/worker/src/jobs/plugin-* |
In scope; production enablement blocked pending external sign-off |
| 11 |
AI pipeline + cost tracking + quota |
packages/ai, apps/worker/src/jobs/ai-* |
In scope |
| 12 |
Search indexer (Meilisearch) |
packages/search, apps/worker/src/jobs/search-indexer* |
In scope |
| 13 |
Vector store (pgvector / Qdrant) |
packages/vector-store |
In scope |
| 14 |
RAG pipeline |
packages/ai/src/embeddings, apps/worker/src/jobs/rag-indexer* |
In scope |
| 15 |
Webhooks (inbound + outbound delivery) |
apps/api/src/routes/webhooks, apps/worker/src/jobs/webhook-delivery* |
In scope |
| 16 |
Form widget + lead capture |
packages/form-widget, apps/web/app/routes/embed/*, apps/web/app/routes/api.leads.capture.ts |
In scope |
| 17 |
Feature flags + targeting |
packages/feature-flags |
In scope |
| 18 |
Asset CDN (Bunny pull zone) |
apps/web/vite.config.ts, scripts/cdn/* |
In scope |
| 19 |
Backup + tenant snapshot CLI |
packages/cli |
In scope |
| 20 |
Sentry self-hosted |
infra/sentry, packages/error-reporter |
In scope (operator-owned trust) |
| 21 |
Authentik (planned SSO IdP) |
infra/authentik |
In scope (forward-looking) |
| 22 |
Marketing / Form lead funnel |
apps/web/app/routes/api.leads.capture.ts, packages/marketing-sdk |
In scope |
Explicitly out of scope for this document (covered by vendor agreements / operator runbooks):
- Third-party provider compromise (Stripe, Mailjet, OpenAI, Bunny CDN, S3 host)
- Client-device theft or physical-access attacks
- DNS hijack / registrar compromise
- Operator-laptop compromise (covered by IT-security policy, not this repository)
Data-flow diagrams
The diagrams below describe the trust boundaries that the STRIDE matrix references. Solid arrows are synchronous, dashed arrows are asynchronous (queue / outbox).
Authentication flow
Trust boundary: browser → web (CSRF token, origin allow-list, TLS). Boundary: web → API (signed JWT or shared session secret). Boundary: web → mail (provider API key, no PII beyond email + token).
Stripe webhook flow
Trust boundary: Stripe → API (HMAC signature, replay window from Stripe-Timestamp, idempotency key recorded). Boundary: API → outbox (transactional write — no side-effects before commit). Boundary: worker → DB (per-row tenant scope).
AI request flow (with cost tracking + quota)
Trust boundary: user → web (auth + tenant). Boundary: web → provider (API key from @toolctrl/config, never read from UI code). Boundary: cost-publish → outbox (idempotency via requestId; payload sanitized — no prompt text persisted by default).
Plugin execution flow (foundation shipped; production sign-off pending)
Trust boundary: caller → loader (plugin manifest signature). Boundary: sandbox → host (capability allow-list; deny by default). Boundary: host → DB (tenant scope enforced regardless of caller).
GDPR export / erasure flow
Trust boundary: admin → web (admin.users.export permission). Boundary: worker → storage (per-tenant prefix, server-side encryption). Boundary: audit chain (forensics; see docs/security/incident-response.md).
STRIDE matrices per component
Each table follows the same shape: Threat (the STRIDE category and concrete vector) | Mitigation (what code/infra/operator control answers it) | Evidence / Gate (the test, verifier, runbook or env-catalog entry that demonstrates the mitigation today) | Residual risk (what remains, accepted by the operator).
1. Web (apps/web)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Forged session cookie |
Signed cookie via SESSION_SECRET, Secure + HttpOnly, SameSite resolved by runtime |
packages/config/src/session-cookie.test.ts, apps/web/app/utils/session.server.test.ts |
Stolen device → session theft. Mitigated operationally by short session TTL + IdP-side revoke (planned with Authentik). |
| S — Magic-link replay |
Single-use MagicLinkToken with consumedAt and TTL; rate-limited issuance |
packages/db/prisma/schema.prisma (MagicLinkToken), apps/web/app/utils/services/magic-link.server.test.ts |
TTL window allows one-time interception via mail-provider compromise. Accepted; mitigated by Mailjet SPF/DKIM. |
| T — XSS via user content |
React escaping by default, CSP planned (see B-005), no dangerouslySetInnerHTML in mutating routes |
apps/web/app/utils/csp.test.ts, ESLint react/no-danger rule |
CSP not yet enforced in production headers (tracked in docs/improvement-plan 10-compliance-polish). |
| T — CSRF on browser mutation |
Per-session CSRF token required on POST/PUT/PATCH/DELETE; verified header-first |
apps/web/app/utils/session-csrf-sweep.test.ts, docs/security/csrf-inventory.md |
None known; new mutating routes must register an inventory entry (verified by test). |
| R — Privileged action without audit |
Mutating admin routes emit AuditEvent with hash-chain |
apps/web/app/routes/admin/audit-trails.test.tsx, verify:permission-matrix |
Full hash-chain verifier is gated by compliance-audit-export task; chain validation runs nightly. |
| I — Tenant-isolation leak via crafted ID |
Loaders resolve tenant from session, not URL; static inventory snapshot |
verify:tenant-isolation-report, docs/security/tenant-isolation-report.md |
C3 runtime cross-tenant test still pending (tracked in docs/improvement-plan). |
| I — Secret in HTML / log |
process.env access banned in UI routes; pino redact rules |
packages/config/src/env-access-enforcement.test.ts |
Operator must verify Sentry PII scrubbers — see docs/security/incident-response.md. |
| D — Login brute force |
Sliding-window rate-limit on /login, structured 429 |
apps/web/app/utils/services/auth.server.test.ts |
Distributed credential stuffing still possible. Mitigated by password policy + TOTP. |
| E — Permission bypass |
Role/permission matrix from permissions-seed.server.ts; role-matrix tests enforce coverage |
packages/db/src/permissions-seed.server.test.ts, verify:permission-matrix |
None known. |
2. API (apps/api)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Forged JWT |
HS256 verification with JWT_SECRET; refresh-path requires server-side state |
apps/api/src/auth/*.test.ts, verify:openapi-security |
Secret rotation runbook (see incident-response.md). |
| S — Stolen API key |
Hashed lookup; API_KEY_LEGACY_PLAINTEXT_FALLBACK=false in production; per-tenant scope at handler |
apps/api/src/route-access-inventory.test.ts, docs/security/route-access-inventory.md |
Key leaked in dependency repo → mitigated by hash-only storage. |
| T — Idempotency replay |
Idempotency keys recorded per tenant; idempotency table planned in compliance-audit-export |
apps/api/src/middleware/idempotency.test.ts |
Window between key issuance and recording. Accepted (millisecond order). |
| T — Inbound webhook replay |
Signature verification with provider secret + timestamp window; outbox prevents double-side-effect |
apps/api/src/routes/webhooks/stripe.test.ts |
Window of STRIPE_WEBHOOK_TOLERANCE_SECONDS (default 300s). |
| T — SSRF via outbound webhook URL |
Outbound delivery rejects RFC1918, link-local, loopback, metadata IPs |
apps/worker/src/jobs/webhook-delivery.test.ts (block-list) |
DNS rebind possible during request; mitigated by per-attempt IP resolution. |
| R — Repudiated admin API call |
Every /v1/admin/* request emits AuditEvent with request-id linkage |
apps/api/src/openapi.test.ts, verify:permission-matrix |
None known. |
| I — OpenAPI exposes admin route |
OpenAPI guard rejects routes without explicit auth class |
apps/api/src/openapi.test.ts, verify:openapi-security |
None known. |
| I — Error envelope leak |
Structured {error: {code, messageKey}} envelope; no raw err.message in mutating routes |
apps/api/src/error-envelope.test.ts |
B5 follow-up: a small number of legacy loaders still return string bodies. |
| D — Unauthenticated analytics flood |
ANALYTICS_TOKEN gate + per-token rate-limit |
apps/api/src/route-access-inventory.test.ts (public-analytics-token) |
None known. |
| D — Auth burst |
Login rate-limit returns structured 429 |
apps/api/src/app.ts rate-limit branch; apps/api/src/auth.test.ts |
Distributed bursts handled by infra-level WAF. |
| E — Permission bypass via parameter tampering |
Permission matrix loaded once at boot; every /v1/* handler asserts assertCan(actor, action, resource) |
verify:permission-matrix |
None known. |
3. Worker (apps/worker)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Job spoofing |
Producers are server-side only; BullMQ queues authenticated by Redis ACL |
packages/redis/src/connection.ts, docs/ops/worker-queues.md |
Redis-tenant compromise. Mitigated by Redis password + network isolation. |
| T — Queue poisoning |
Each producer goes through typed enqueueJob with descriptor validation |
apps/worker/src/handlers.test.ts, apps/worker/src/enqueue.test.ts |
None known. |
| T — Outbox replay |
OutboxEvent.processedAt/dispatched flag + FOR UPDATE SKIP LOCKED claim |
apps/worker/src/jobs/outbox-claim.test.ts |
Two workers in race window: covered by integration test. |
| R — Side-effect replay without audit |
Outbox is the only path to external side-effects in critical flows; receivers wrap with withOutboxIdempotency |
apps/worker/src/jobs/outbox-dispatcher.test.ts |
Legacy direct producers still exist; migration tracked in #42 follow-ups. |
| I — Job payload leak |
PII-redaction at enqueue (@toolctrl/logger redact rules); SBOM excludes payload |
packages/logger/src/redact.test.ts |
Operator must verify Sentry breadcrumb scrubbers. |
| D — Queue starvation |
Per-group queue separation (general, mail, import, webhook) + concurrency caps + job-priority |
apps/worker/src/queues.test.ts, docs/architecture/job-priority.md |
Operator must size per group; see runbook. |
| E — Worker → DB privilege escalation |
Worker connects with role limited to required tables; no admin DDL grants |
infra/postgres/init.sql + Coolify role provisioning |
Mis-grant by operator → audited via docs/operations/database-rollback.md. |
4. Database (Postgres + PgBouncer + replicas)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Forged connection |
mTLS to PgBouncer in production; per-app credentials in userlist.txt |
infra/pgbouncer/pgbouncer.ini, docs/ops/pgbouncer.md |
Operator must rotate per release. |
| T — SQL injection |
Prisma parameterized queries only; $queryRaw requires tagged template + lint rule |
ESLint custom rule + grep guard in CI |
$executeRawUnsafe banned in app code (allowed in CLI for migrations). |
| T — Backup tampering |
Backups encrypted with age; SHA256 manifest signed at write |
docs/ops/backup-cli.md, packages/cli/src/backup/*.test.ts |
Encryption key custody: operator-owned. |
| R — Direct DB write without audit |
Per-tenant write happens through Prisma + AuditEvent emission; direct psql writes flagged by pg_stat_statements review |
docs/operations/database-rollback.md |
Operator runbook. |
| I — Cross-tenant read |
Every model query carries tenantId; static inventory snapshot enforced by test |
verify:tenant-isolation-report |
RLS not yet enforced; runtime integration test still pending (C3). |
| I — Replica leak |
Read replicas configured with hot_standby=on, default_transaction_read_only=on; DATABASE_READ_URL user has SELECT only |
docs/architecture/rw-split.md, infra/postgres/replica.sql |
Operator must verify replica grants per release. |
| D — Connection exhaustion |
PgBouncer transaction pooling + per-app max_client_conn |
docs/ops/pgbouncer.md |
Long transactions block pool; mitigated by statement_timeout. |
| E — Privilege escalation in DB |
Per-app role with least privilege; pgcrypto and vector extensions granted to migrator only |
infra/postgres/init.sql |
Operator must audit role grants per release. |
5. Two-tier cache (Redis L2 + in-memory L1)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| T — Cross-tenant cache poisoning |
Cache keys always prefixed with tenant:{tenantId}: |
packages/shared-store/src/two-tier-cache.test.ts |
Operator-side key-collision risk if Redis is shared with another product. Documented in docs/architecture/two-tier-cache.md. |
| R — Invalidate without audit |
Publisher logs pubsub.publish with channel + tenantId; subscribers log invalidation |
packages/shared-store/src/two-tier-cache.test.ts (publish path) |
Logs retention by operator. |
| I — Reading another tenant's data on race |
L1 cache scoped per-process; L2 keyed per tenant |
Same as above |
None known. |
| D — Pub/sub flood |
Channel-level rate-limit in publisher; CACHE_INVALIDATION_ENABLED killswitch |
packages/config/src/catalog.ts (CACHE_INVALIDATION_ENABLED) |
Operator killswitch covers worst case. |
| E — Redis ACL bypass |
Per-app password; Redis users restricted to required key patterns |
infra/redis/users.acl, docs/ops/worker-queues.md |
Operator-owned. |
6. Outbox + dispatcher
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| T — Lost side-effect |
Outbox row inside same transaction as state change |
apps/worker/src/jobs/outbox-claim.test.ts |
None — by design. |
| T — Duplicate side-effect |
withOutboxIdempotency keyed by (eventId, receiver) |
apps/worker/src/jobs/outbox-dispatcher.test.ts |
Receiver must be idempotent — convention documented. |
| R — Dispatch without record |
Each attempt updates attemptCount, lastError, terminal dead-letter state |
apps/worker/src/jobs/outbox-dispatcher.test.ts |
None known. |
| D — Backoff starvation |
Exponential backoff with cap; dead-letter after N attempts |
Same |
Dead-letter must be triaged by operator (runbook in docs/architecture/outbox-pattern.md). |
7. Object storage
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Spoofed presigned URL |
Server-side signing with provider IAM credentials; client never holds long-lived keys |
packages/storage/src/s3.test.ts |
None known. |
| T — Upload of malicious file |
AV scanner layer 1 (magic-bytes) + layer 2 (ClamAV planned) |
packages/storage/src/av-scan.test.ts, docs/improvement-plan/01-tech-debt-foundation/av-scanner-layer-*.md |
Layer 2 (ClamAV) container provisioning is operator step. |
| I — Public bucket exposure |
Buckets default to private; ACL test enforces |
packages/storage/src/bucket-policy.test.ts (operator-run) |
Operator must verify per environment. |
| D — Egress cost burn |
Per-tenant rate-limit on signed-URL issuance |
packages/storage/src/rate-limit.test.ts |
Operator-side WAF + Bunny CDN absorb large bursts. |
| E — Cross-tenant object read |
Object key prefixed with tenant:{tenantId}/...; loader verifies prefix matches actor's tenant |
packages/storage/src/key-scope.test.ts |
None known. |
8. Mail (packages/mail)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Spoofed sender |
SPF/DKIM/DMARC enforced at provider; per-domain key isolation |
Operator: provider DNS records |
Operator must verify per release. |
| T — Template injection |
Templates compiled at build; runtime arguments escaped |
packages/mail/src/templates/*.test.ts |
None known. |
| I — PII in mail subject/body |
Templates take a minimal context object; pino redact for log lines |
packages/mail/src/templates/*.test.ts |
Operator must verify Mailjet retention. |
| D — Mass-mail abuse |
Per-tenant + per-recipient rate-limit; bounce + complaint webhooks shut down sender on threshold |
apps/worker/src/jobs/mail.test.ts |
Operator must monitor reputation. |
9. Realtime collaboration (Hocuspocus)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Anonymous connect |
JWT auth hook verifies HOCUSPOCUS_AUTH_SECRET token; 15-min TTL |
apps/hocuspocus/src/auth.test.ts |
Token leak via XSS → mitigated by short TTL + CSP. |
| T — Document tampering |
Yjs CRDT signed updates; persistence layer rejects malformed updates |
apps/hocuspocus/src/persistence.test.ts |
None known. |
| I — Cross-tenant doc read |
CollabDoc.tenantId enforced at every load; auth hook checks tenant claim |
apps/hocuspocus/src/auth.test.ts |
None known. |
| D — Connection storm |
Per-document concurrent-connection cap; per-tenant total cap |
apps/hocuspocus/src/limits.test.ts (planned) |
Operator must size Hocuspocus container. |
| E — Editor → DB write without permission |
Hocuspocus persists only into CollabDoc; downstream mutation requires explicit API call with permission check |
apps/hocuspocus/src/persistence.test.ts |
None known. |
10. Plugins (WASM sandbox foundation; production sign-off pending)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Unsigned plugin |
Manifest signature verification, capability consent, and production kill switch |
docs/features/plugins.md, docs/operations/plugin-lifecycle.md, docs/security/plugin-runtime-signoff.md |
Production remains blocked until external WASM/security sign-off is complete. |
| T — Sandbox escape |
WASM runtime boundary, sanitized outputs, iframe sandbox/CSP, and Phase-C boundary enforcer |
verify:phase-c-boundaries, packages/plugin-runtime/src/*.test.ts |
New WASM-runtime CVE -> mitigated by sandbox updates + operator runbook. |
| R — Plugin action without audit |
Lifecycle, invoke, hook, and capability-use paths emit audit/outbox evidence with pluginId and tenant scope |
apps/worker/src/jobs/plugin-*.test.ts, docs/operations/plugin-lifecycle.md |
External review must validate audit sufficiency before production enablement. |
| I — Capability exfiltration |
Default-deny capability list; explicit manifest allow-list and tenant capability grants |
apps/web/app/routes/app.$tenant/settings/plugins*.test.tsx, apps/api/src/route-access-inventory.test.ts |
Capability allow-list must be reviewed per plugin install. |
| D — Resource exhaustion |
Per-invocation limits, crash counters, queue isolation, and auto-disable behavior |
apps/worker/src/jobs/plugin-invoke-bootstrap.test.ts, docs/features/plugins.md |
Operator-tuned per plugin class. |
| E — Capability abuse / privilege escalation |
app.plugins.manage / app.plugins.use gates plus tenant-scoped lifecycle/runtime services |
docs/roles-matrix.md, docs/features/plugins.md, docs/security/plugin-runtime-signoff.md |
External review remains required before production enablement. |
11. AI pipeline (cost tracking + quota)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Forged provider response |
Provider API keys server-side only; response not trusted for billing — usage echoed back from provider AND reconciled with token-count |
packages/ai/src/cost/*.test.ts |
Provider compromise → reconciled at month-end via provider invoice. |
| T — Prompt injection |
System prompts isolated from user-content; tool-call schemas validated |
packages/ai/src/prompts/*.test.ts |
Cannot fully prevent — operator must review high-trust tool surfaces. |
| R — AI call without audit |
executeLLMCall writes AICostEvent via outbox keyed by requestId |
packages/ai/src/cost/execute.test.ts, apps/worker/src/jobs/ai-cost.test.ts |
None known. |
| I — Prompt data leakage |
Default policy: no prompt text persisted; only token counts + model |
packages/ai/src/cost/sanitize.test.ts |
If operator opts into prompt-log retention, GDPR-DSR responsibility is operator. |
| D — Cost exhaustion |
Soft + hard tenant quotas (AI_QUOTA_ENFORCEMENT_ENABLED); concurrency counter; spend-cache 60s TTL |
packages/ai/src/cost/quota.test.ts, docs/features/ai-cost-quotas.md |
Burst between two cache refreshes accepted (≤60s window). |
| E — Use of disabled provider/model |
Provider/model allow-list per tenant; degraded mode swaps to cheaper model |
packages/ai/src/cost/degrade.test.ts |
None known. |
12. Search indexer (Meilisearch)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Spoofed index write |
Indexer connects with master-key; tenant-scoped keys used by query path |
packages/search/src/tenant-scope.test.ts |
Operator must rotate master key per release. |
| T — Cross-tenant index pollution |
Each document carries tenantId; query path always adds tenantId filter |
packages/search/src/tenant-scope.test.ts |
None known. |
| I — PII in search index |
Indexer applies projection allow-list; sensitive fields stripped before index |
apps/worker/src/jobs/search-indexer.test.ts |
Operator must verify projection allow-list per model. |
| D — Reindex storm |
Reindex via outbox with backoff; CLI reindex is batched |
packages/cli/src/search/reindex.test.ts |
Operator-tuned batch size. |
13. Vector store (pgvector / Qdrant)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| T — Cross-tenant vector pollution |
Tenant-scoped wrapper enforces tenantId on every upsert and query |
packages/vector-store/src/tenant-scope.test.ts |
None known. |
| I — Embedding inversion |
Operator decides per-tenant whether high-sensitivity data is embeddable; documented in docs/features/rag.md |
docs/features/rag.md |
Embedding inversion research is active; high-sensitivity tenants should disable RAG. |
| D — Index build cost |
Embedding pipeline rate-limited per tenant; outbox-driven backoff |
apps/worker/src/jobs/rag-indexer.test.ts |
Operator-tuned per plan. |
14. RAG pipeline
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Source spoofing |
Sources tagged with tenant + permission scope at ingest |
packages/ai/src/embeddings/source.test.ts |
None known. |
| T — Retrieved-context poisoning |
Retrieval restricted to tenant; per-source access checks |
packages/ai/src/embeddings/retrieve.test.ts |
None known. |
| I — Leak via retrieval |
Retrieval re-checks actor permission per source |
Same |
If permissions change mid-conversation, prior turn may already contain redacted source. Mitigated by short conversation window. |
15. Webhooks (inbound + outbound)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Inbound spoofed signature |
HMAC verification with provider secret; constant-time compare |
apps/api/src/routes/webhooks/*.test.ts |
None known. |
| T — Inbound replay |
Timestamp window + idempotency key recorded in outbox |
Same |
Window default 300s. |
| T — Outbound SSRF |
URL block-list (RFC1918, link-local, metadata, loopback); per-attempt DNS resolution |
apps/worker/src/jobs/webhook-delivery.test.ts |
DNS rebind: mitigated by per-attempt resolution. |
| R — Outbound delivery without record |
Each attempt logs status, response code, latency; signed receipt available to tenant |
apps/worker/src/jobs/webhook-delivery.test.ts |
None known. |
| I — Payload exfiltration |
Outbound payload built from typed event schema; PII redacted before send |
packages/webhooks/src/payload.test.ts |
Operator must vet customer-configured destinations. |
| D — Slow-loris destination |
Per-destination concurrency cap + timeout; circuit-breaker after consecutive failures |
apps/worker/src/jobs/webhook-delivery.test.ts |
None known. |
16. Form widget + lead capture
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Forged embed origin |
Origin allow-list per form (tenant-configured); MARKETING_ALLOWED_ORIGINS for global |
apps/web/app/routes/api.leads.capture.test.ts |
Operator must keep allow-list current. |
| T — Submission tampering |
Server validates schema; honeypot field; Turnstile gate when enabled |
apps/web/app/routes/api.embed.forms.submit.test.ts |
Sophisticated bots may still pass Turnstile; rate-limit caps damage. |
| T — Embed-snippet tampering |
SRI hash on widget.js (planned per form-widget detail-md follow-up) |
docs/features/form-widget.md |
Until SRI ships, customer must serve widget over HTTPS. |
| I — Lead PII exposure |
Submissions stored per-tenant; admin inbox requires marketing.leads.read |
apps/web/app/routes/admin.marketing-leads.test.tsx |
None known. |
| D — Spam flood |
Sliding-window rate-limit (5/min IP, 1/min email); Turnstile when enabled |
apps/web/app/routes/api.leads.capture.test.ts |
Distributed bots may evade IP limit. |
17. Feature flags + targeting
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| T — Targeting rule poisoning |
Rules edited only by users with feature-flags.write; AuditEvent on every change |
packages/feature-flags/src/audit.test.ts |
None known. |
| R — Silent kill-switch |
AuditEvent emitted on enable/disable; admin UI shows last actor + timestamp |
Same |
None known. |
| I — Rule data leak |
Rule fields never echoed in user-facing logs |
packages/feature-flags/src/serialize.test.ts |
None known. |
| D — Eval-cache thrash |
Two-tier cache with pub/sub invalidate; killswitch |
docs/architecture/two-tier-cache.md |
Operator killswitch. |
18. Asset CDN (Bunny pull zone)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| T — Cache poisoning at edge |
Origin returns immutable Cache-Control for fingerprinted assets; non-fingerprinted assets short TTL |
scripts/cdn/cache-policy.test.mjs |
Operator must verify pull-zone config. |
| I — Source-map exposure |
Source-maps not uploaded to public CDN in production builds; Sentry-side only |
apps/web/vite.config.ts (sentry-vite-plugin config) |
Operator must verify per release. |
| D — Origin shielding |
Bunny edge absorbs bursts; origin protected by Coolify rate-limit |
docs/ops/asset-cdn.md |
Operator-tuned. |
19. Backup + tenant snapshot CLI
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Restore in production |
NODE_ENV=production guardrail; super-admin required for --profile=none |
packages/cli/src/tenant/snapshot.test.ts |
Operator-side training. |
| T — Snapshot tampering |
age encryption + SHA256 manifest; manifest signed |
packages/cli/src/backup/sign.test.ts |
Key custody by operator. |
| R — Restore without audit |
CLI emits structured audit log; required --reason flag |
packages/cli/src/tenant/restore.test.ts |
None known. |
| I — PII in snapshot |
3 PII profiles (full, minimal, none); deterministic salt-seeded anonymization |
docs/ops/tenant-snapshot.md |
Operator must select correct profile per use-case. |
20. Sentry self-hosted
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| I — PII in error / breadcrumb |
PIISpanProcessor strips PII before send; client + server scrubbers |
packages/observability/src/pii.test.ts |
Operator must verify per release; PII-scrubber test required on every Sentry SDK upgrade. |
| I — Cross-tenant event leak |
Per-project DSN; tenant attached as tag (not PII), not user-id |
packages/error-reporter/src/sentry.test.ts |
Operator must keep project isolation. |
| R — Event tampering at Sentry |
Sentry self-hosted = single tenant; operator-owned trust boundary |
infra/sentry/README.md |
Operator-owned. |
| D — Event storm |
Sentry rate-limits per DSN; client-side sampling |
packages/error-reporter/src/sample.test.ts |
None known. |
21. Authentik (planned SSO IdP)
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| S — Forged SSO assertion |
OIDC/SAML signature verification; pinned issuer per environment |
infra/authentik/README.md (planned) |
None expected. |
| R — Login without audit on IdP |
Authentik emits its own AuditEvent stream; replicated to platform audit |
Same |
Operator-owned. |
| I — User-attribute leak |
Attribute mapping reviewed per release; minimum-necessary claim set |
Same |
Operator-owned. |
| D — IdP outage |
Fallback to local credentials for break-glass admin |
Same |
Documented in docs/security/incident-response.md. |
22. PgBouncer
| Threat |
Mitigation |
Evidence / Gate |
Residual risk |
| T — Pool contamination |
Transaction-mode pooling; no session-level GUCs from app |
docs/ops/pgbouncer.md |
Operator must verify server_reset_query per release. |
I — Credential leak via SHOW |
admin_users restricted; SHOW permissions audited |
infra/pgbouncer/pgbouncer.ini |
Operator-owned. |
| D — Pool exhaustion |
Per-app max_client_conn + statement timeout on origin |
Same |
Documented in runbook. |
Cross-cutting controls
- Secret handling — secrets never read from UI code;
@toolctrl/config exposes typed loaders. .env.test allow-listed; .env git-ignored. See docs/env-access-inventory.md.
- Branding / infra parametrization —
BRAND_* env vars and scripts/infra-config.mjs give forks override knobs without removing the verify-gate contract — see docs/deployment-porting.md.
- Migrations — structural Prisma diff verified via
verify:db-migrate-status; rollback procedure documented in docs/operations/database-rollback.md.
- Audit chain — every privileged mutation emits an AuditEvent; hash-chain verifier planned in
compliance-audit-export is the canonical forensics source referenced by incident-response.md.
- SBOM — generated per build; see
sbom.md.
Gaps tracked in the roadmap
The following items are knowingly incomplete and tracked under the improvement plan:
- B4 — Mail templates currently hardcode English subjects/bodies (
docs/improvement-plan 04-i18n).
- B5 —
Response.json("Unauthorized", …) style stringly errors still exist in some loaders.
- C1 / C2 — Handler coverage below target (API < 70 %, worker < 50 %).
- C3 — Cross-tenant runtime integration test pending.
- D3 —
packages/logger / packages/error-reporter Pino+Sentry adapters complete; PIISpanProcessor verification is per-release.
- CSP enforcement in production headers — tracked under
10-compliance-polish.
- Plugin sandbox (component 10) — full implementation pending phase-06.
- Authentik IdP (component 21) — staging-only today; production cutover tracked in phase-08.
Each gap has an owning task in the improvement plan and a matching commit category.