Security
How Spillway is built to be run on the open internet, what each role can do, and what to set in production. Vulnerability reports go to the address in SECURITY.md; the findings this page grew out of are in dev/security-review-2026-11-05.md.
Trust model
Spillway separates content (what editors type) from code (what runs in the server). The roles are, from least to most powerful:
| Role | Can | Cannot |
|---|---|---|
viewer | read drafts and previews | change anything |
author | create and edit own content, upload media, use AI assistance | publish, see other authors' drafts in the builder, touch settings |
editor | everything an author can on anyone's content, publish and unpublish, read form submissions | settings, types, forms, modules |
manager | everything an editor can, plus site settings, content types, forms and connectors, kits, AI settings, menus, sandboxed modules | user management, trusted modules, restarts |
admin | everything |
Who can execute code on the server. Only an admin, and only with the operator's consent: trusted-tier modules (plain JavaScript loaded into the Node process) cannot be installed or enabled from the web unless the server was started with SPILLWAY_TRUSTED_MODULE_INSTALL=1, and modules an operator placed on disk still need an admin to enable them. A manager can install sandboxed extensions (WebAssembly with declared capabilities, no filesystem, no process, HTTP only to declared public hosts). Every trusted install and every restart is audited. Details: modules.md, Trust model.
Connectors (form webhooks, HubSpot, notification webhooks, Slack, AI providers) are configured by managers and run on the server, but every outbound request they make goes through the SSRF guard below, so a manager cannot turn the server into a proxy into the private network, and AI base URLs must be https:// on a public host so a manager cannot redirect the provider key. Changing a provider's base URL also clears the key stored for it (audited as ai.settings.key_cleared): a key entered for one host is never sent to another unless an admin re-enters it, and keys from the environment (OPENAI_API_KEY and friends) are the operator's to point.
Supply chain. The module registry is a signed, static index: with REGISTRY_PUBLIC_KEY set, an index without a valid ed25519 signature over its canonical JSON is refused, every entry must declare the tier the admin consents to, the tarball's manifest must agree with it, tarballs are verified by sha256 before extraction, and plain http:// registries are refused unless REGISTRY_ALLOW_HTTP=1. See modules.md.
SCIM tokens (scim_…, one per identity provider, sha256 at rest, rotated from a browser session only) act only on /scim/v2, carry no user permissions, and are rate limited to 600 requests a minute each; a deactivated account (active: false) loses its sessions and API tokens and is refused by every sign-in path. API tokens carry a subset of their owner's permissions, intersected with the owner's current roles on every request; MCP tool results and AI prompts delimit site content as untrusted data (the model is told not to follow instructions inside it), but an agent that acts on what it reads is still bound only by its token's permissions, so mint narrow tokens.
Author HTML
Rich text fields, the Text block in HTML mode and the Embed block are the only places an editor can type markup. All three go through the same allow-list sanitizer on save and again before every render (public page, /_preview, personalization islands): scripts, styles, event handlers, javascript:/data: URLs and unknown elements are removed. The Embed block keeps <iframe> only for an https:// source on a host in SiteSettings.embedAllowedHosts (Site settings → Integrations; default: YouTube, Vimeo, Google Maps, Spotify, Apple Podcasts, Facebook, X, Instagram), and every kept frame gets sandbox="allow-scripts allow-same-origin allow-popups allow-presentation" and loading="lazy". The block library applies a copy of the same rules in its render functions, so the admin's page-builder preview never executes author markup either.
Headers
Every response carries X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, X-Frame-Options: SAMEORIGIN and a Content-Security-Policy with frame-ancestors 'self'. On top of that:
- The admin (
/admin, a Vite build with no inline scripts) getsdefault-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://rsms.me; font-src 'self' data: https://rsms.me; img-src 'self' data: blob: https:; connect-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'self'. - Public pages, the search page and previews get a nonce-based policy:
script-src 'self' 'nonce-…'plus the tag hosts the site uses (Google Tag Manager and Analytics when configured, Plausible when configured, the origins of external scripts in the custom head markup),frame-srcfor the embed hosts above,object-src 'none',base-uri 'self'. The shell renders every inline script with a placeholder nonce; the route swaps in a fresh value per response, so the cached HTML stays shared while injected scripts have no nonce. A CDN caching a page serves one nonce to several visitors; that weakens the nonce only against an attacker who can already read responses. Strict-Transport-Security: max-age=31536000; includeSubDomainswhenever the site is https:BASE_URLstarts withhttps://, the request itself is, or a proxy named inTRUSTED_PROXIESsentX-Forwarded-Proto: https(the header is ignored from anyone else).- Uploaded SVG is served with
Content-Security-Policy: sandbox; uploaded HTML is never served as HTML.
Outbound requests (SSRF policy)
Webhooks, Slack and HubSpot deliveries, CAPTCHA verification (Turnstile, hCaptcha), the registry client, the module HTTP capability (trusted api.http and sandboxed sw_http) and AI base URLs share one guard (packages/core/src/forms/http.ts):
http:andhttps:only;localhost,*.localhost,*.local,*.internal,*.home.arpaandmetadata.google.internalare refused by name.- Every address is normalized to 16 bytes before classification, so
127.1,2130706433,0177.0.0.1,0x7f000001,[::ffff:7f00:1],[0:0:0:0:0:ffff:127.0.0.1], 6to4 (2002:7f00:1::), NAT64 (64:ff9b::7f00:1), Teredo and the dotted forms all count as what they are. Refused ranges:0/8,10/8,100.64/10,127/8,169.254/16,172.16/12,192.0.0/24,192.0.2/24,192.168/16,198.18/15, multicast, reserved,::,::1,fc00::/7,fe80::/10,fec0::/10,ff00::/8. - Host names are resolved once; if any answer is private the request is refused; otherwise the connection is pinned to the vetted address through the HTTP agent's
lookup, so a rebinding DNS answer cannot steer the socket after the check. Redirects are followed by hand (at most 3) and each hop is checked again; module requests see 3xx responses instead of following them. Should the pinned agent be unavailable on an unusual Node build, the guard re-resolves immediately before connecting and refuses when the answers differ (a window of well under a second remains in that mode). - Module manifests may not declare
http:<host>capabilities for internal names or private literals. SPILLWAY_ALLOW_PRIVATE_URLS=1turns the private-range check off for operators who need an internal CRM;NODE_ENV=testdoes the same for the test suite.
Proxies and client addresses
Login attempts (10 per 15 minutes), public form submissions (20 per 10 minutes), the analytics beacon and derivative cache misses (30 per minute) are limited per client address. Behind a reverse proxy the socket address is the proxy, so set TRUSTED_PROXIES to the proxy's addresses or ranges (loopback, private, 10.0.0.0/8, 172.16.0.0/12, fly for Fly.io's Fly-Client-IP, or * when the proxy is the only route to the port). Only then is X-Forwarded-For honored, taking the rightmost hop that is not itself a trusted proxy. Details and per-host values: install.md, Reverse proxy.
Body limits and resource caps
| Request | Limit |
|---|---|
Any JSON body (/api/*, /mcp, /scim/v2, SSO callbacks) | 1 MB |
POST /api/media | 25 MB (plus multipart framing) |
POST /api/modules/install | 64 MB |
POST /api/forms/:key/submit (JSON or form-encoded), POST /api/forms/:key/validate, POST /_beacon | 256 KB |
POST /api/forms/:key/submit as multipart/form-data (file fields) | 25 MB (plus multipart framing); each file is further bounded by the field's maxSize and maxCount |
Form uploads are typed from their bytes (a renamed file is refused when the field names types; executables, scripts, HTML and SVG are refused when it does not), stored under DATA_DIR/form-uploads/ outside the media library, never served publicly, and handed to submission viewers only as Content-Disposition: attachment with nosniff and a sandbox CSP; scanUpload() in forms/uploads.ts is the antivirus hook point (a no-op by default). Larger bodies are refused with 413 payload_too_large before the route reads them (by Content-Length when present, otherwise while streaming). Pages that read the query string (exposed filters, calendars, term listings, the search page) are cached per query for a minute, but only the parameters the page declares (its filter fields, from/to, month, page) form the key and reach the render; at most 50 variants per path are kept (least recently used first) and query-dependent entries may take at most 40% of the cache, so a crawler's random query strings cannot evict the plain pages or force unbounded renders. Kit media seeds are capped at 2 MB each. Image derivatives are only produced for a fixed ladder (w and h from 200, 240, 320, 480, 768, 1200, 1920; h only with fit=cover; q 60, 75 or 85; fmt webp, avif, jpeg or png), at most 48 cached variants per image (oldest evicted), 30 cache misses per minute per client, and sharp refuses inputs above 40 megapixels. Restarts triggered by module changes happen at most once per five minutes.
Sessions and passwords
Session tokens are 32 random bytes stored only as an HMAC under a secret file (mode 0600); the cookie is HttpOnly, SameSite=Lax and, when the site is https (BASE_URL, or X-Forwarded-Proto from a trusted proxy), Secure with the __Host- prefix (__Host-spillway_session: no Domain, Path=/, so a subdomain or an http origin cannot plant one). Over plain http the unprefixed name is used so local development works; both names are read, the prefixed one first. Sessions slide with use but expire 30 days after sign-in regardless; an admin changing a user's password or roles ends that user's other sessions, deleting a user ends them all, and expired rows are swept hourly. Passwords are hashed with scrypt (N=2^15, r=8, p=1, 16-byte salt, 64-byte key; prefix scrypt2$); hashes from the previous parameters (scrypt$, N=2^14) still verify and are upgraded on the next successful login. The dummy hash the login route verifies against for unknown emails keeps timing constant.
Instance keys and backups
Credentials the admin stores (SSO client secrets, AI provider keys, DAM, TMS and CDN credentials) are encrypted with AES-256-GCM under key files in DATA_DIR (sso-secret, and ai-secret for the rest), and session tokens are kept as an HMAC under session-secret; all three are written with mode 0600, so a copy of the database alone reveals none of them. A backup has to restore, so every backup run carries these key files (keys.json; see install.md, What a backup contains): a backup target therefore holds the database and the keys that decrypt what it stores. Set BACKUP_ENCRYPTION_KEY (openssl rand -hex 32) on every instance that backs up, and keep that key outside the instance and outside the backup target (a password manager or the platform's secret store); with it, every member of a run is AES-256-GCM encrypted (media files excepted: they are public once published). Without it the run is marked unencrypted_secrets, the System tab says so, /status.json reports backup_unencrypted and an s3:// target logs a warning at boot. Give the backup credentials write and list access to their prefix only, and treat read access to the bucket like access to the instance. spillway restore writes the key files back with mode 0600 and refuses to replace different existing ones without --force.
Data retention
The ticker prunes, hourly: expired sessions; audit_log rows older than 365 days, except security-relevant actions (auth.*, user.*, module.*, sso.*, scim.*, api_token.*, mcp.*, setup) which are kept 730 days; path_miss (the 404 log) rows not seen for 90 days; analytics rollups past their own window. Bearer requests are audited as one api_token.request row per token per minute carrying a count, the first and last path and the methods, not one row per request; the 404 log never holds more than 10,000 paths (the least recently seen are evicted). Notification outbox rows never hold webhook secrets (read from the rule at delivery) or form submission data (loaded from the submission at delivery); when no SMTP_URL is configured, mail bodies are logged at debug level only, with the recipient, subject and submission id at info.
The domain importer
The importer (docs/importer.md) fetches a site an imports.manage holder names, and Brand → Import brand (site.settings) fetches one page and its stylesheets the same way; these are the places the server fetches arbitrary pages on a person's behalf. Every request (pages, robots.txt, sitemaps, platform APIs, stylesheets, assets, the logo) goes through the SSRF guard above with the user agent SpillwayImporter/<version> (+https://spillwaycms.com/docs/importer), redirects are followed by hand and re-vetted per hop, bodies are capped (10 MB per page, 2 MB per stylesheet, 50 MB per asset, 2 GB per job) and a job runs for at most an hour. Requests to one host are never started closer together than IMPORT_MIN_RATE_LIMIT_MS (100 ms, so at most 10 a second) whatever a job's rateLimitMs says, one job runs at a time per instance with 4 workers, and a Crawl-delay in robots.txt is honored up to 30 s. Platform adapters (Drupal JSON:API, WordPress REST) only follow API links that stay on the source's own hosts, so a source cannot steer the importer at a third party. Brand import reads uploaded PDFs with its own text extractor (no external service; object, operator and decompressed-size caps keep a hostile file bounded), keeps logo uploads outside the public media tree until a person applies them, and hands page copy and PDF text to the model only as delimited data it is told never to follow; whatever the model answers is validated field by field and lands as rows a person reviews, never in the published brand.
What the source sends is hostile input: the HTML parser, selector engine, sitemap reader, robots matcher, CSS tokenizer and the include/exclude glob matcher are all linear in their input (no backtracking regexes; nesting is capped at 512 elements and 32 CSS blocks, a selector at 8 compounds), and re: include/exclude patterns are refused when they use nested quantifiers, backreferences or more than 12 quantifiers. Extracted main content is run through the rich-text sanitizer before it is stored, converted blocks go through the same sanitizer as editor content, redirect targets must be site paths or absolute http(s) URLs, menu links keep only /, http(s), mailto:, tel: and # targets, and theme token values that would make visitors fetch or evaluate (url(), image-set(), expression(), @import, escapes) are dropped before they reach the site theme. Fetched originals are served back only under Content-Security-Policy: sandbox; default-src 'none'; script-src 'none' and viewed in sandboxed iframes. Every job, page, asset, redirect and menu is scoped to the request's site.
The package registry
A Spillway instance with REGISTRY_HOST=1 (docs/registry.md) accepts tarballs from any signed-in user with registry.publish (30 an hour per user, 20 MB each). The tar reader refuses links, absolute paths, .., NUL and duplicate entries (case-insensitively), so the manifest and Wasm the automated checks examine are the files that are stored and later installed. Trusted modules are listed only after a reviewer (users.manage) approves them; a yanked or rejected version can never be re-listed and a version can never be republished. READMEs are rendered by an escaping Markdown converter (raw HTML shown as text, javascript:/data: links dropped, quote and list nesting capped) and publisher, homepage, repository and advisory links must be http(s). The files endpoint shares the per-address download limiter (120 a minute). Download-count rows are kept for 400 days.
On the consuming side, with REGISTRY_PUBLIC_KEY set, the client also checks the signed index's generatedAt: an index older than REGISTRY_MAX_INDEX_AGE_DAYS (30; 0 turns it off) is refused as registry_stale and one older than the last index the process accepted as registry_rollback, so a captured index cannot be replayed for ever to hide a yank, an advisory or a fixed release. Tarball and index redirects are followed by hand (3 hops, each re-vetted; never to file: or a plain-http downgrade), and a module whose on-disk manifest changes tier after installation is disabled at boot rather than loaded. spillway publish refuses to send its token over plain http to anything but the local machine unless REGISTRY_ALLOW_HTTP=1.
What to set in production
BASE_URL=https://www.example.org # Secure cookies, HSTS, absolute URLs
SESSION_SECRET=<48 random bytes> # or let the server generate one into DATA_DIR
BACKUP_ENCRYPTION_KEY=<openssl rand -hex 32> # backups carry the instance key files; keep this key outside the instance
TRUSTED_PROXIES=loopback # or the proxy's range, `fly`, `*`; see install.md
SETUP_TOKEN=<random> # until the setup wizard has run; the fleet sets one per site
REGISTRY_URL=https://… # keep the module registry on https (http:// is refused)
REGISTRY_PUBLIC_KEY=<base64> # refuse unsigned or tampered registry indexes
# REGISTRY_MAX_INDEX_AGE_DAYS=30 # with a key: refuse a signed index older than this (0 turns the check off)
# leave unset unless you mean it:
# SPILLWAY_TRUSTED_MODULE_INSTALL=1 # lets an admin install in-process modules from the admin
# SPILLWAY_ALLOW_PRIVATE_URLS=1 # lets connectors reach the private network
Run the container as the unprivileged node user (the image does), mount /data on a volume with a backup, put a TLS-terminating proxy in front, keep pnpm audit --prod --audit-level=high green in CI (it is part of .github/workflows/ci.yml), and read a trusted module before you enable it.
Advisories deliberately ignored in package.json (pnpm.auditConfig.ignoreGhsas), each with the reason it does not apply or is mitigated; remove the entry when the dependency is upgraded:
GHSA-mrhx-c7xc-m3w5(zod 3.x: memory exhaustion on oversized input): every request body is capped at 1 MB before zod sees it (F-04), and zod 4 is a breaking upgrade for the schemas incoreandfleet.
Spillway 0.1.0 · Edit this page on GitHub