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

Spillway CMS security review, second pass (2026-11-05): package registry and domain importer

Scope: the working tree of branch phase-4 on the day the two surfaces landed, read file by file. Reviewed: the package registry (packages/core/src/registry/**, routes/registry.ts, modules/tar.ts reader and writer, modules/registry-client.ts, kits/registry.ts, sso/github.ts with the GitHub branch of routes/sso.ts, the registry blocks packages/blocks/src/registry-*.tsx and markdown.ts) and the domain importer (packages/core/src/importer/**, routes/imports.ts, routes/imports-plan.ts, routes/imports-theme.ts, importer/preview.tsx, the admin screens under packages/admin/src/screens/import, the import and publish CLI commands). Out of scope and untouched, because other people were writing them concurrently: packages/astro, packages/next, kits/ (except kits/registry.ts), forms/ (read only, for the SSRF guard), sso/scim* and routes/scim.ts.

Method: the threat model in docs/security.md and the first review were taken as the baseline (SSRF guard with DNS pinning, body limits, CSP nonces, the rich-text sanitizer, permissions per route, audit rows). Every new route was enumerated and checked for its permission and site scoping; every outbound fetch was traced to the guard and through its redirect handling; every parser that reads what a third party serves (tar, gzip, HTML, selectors, sitemaps, robots.txt, CSS, Markdown, JSON:API documents) was fed pathological input in a scratch script against the built dist and timed; every value that reaches a page, a stylesheet, a redirect or a menu was traced to its sanitizer. CONFIRMED findings were reproduced (a timing, a thrown RangeError, or a traced path end to end); PLAUSIBLE ones depend on deployment shape. Line numbers refer to the tree before this pass.

Summary

Everything Medium and above was fixed in the same pass, each with a regression test (packages/core/src/test/registry.test.ts, importer-crawl.test.ts, importer-plan.test.ts, importer-theme.test.ts); docs/security.md, docs/importer.md and docs/registry.md describe the new behavior. pnpm build and the four test files are green; node e2e/smoke-registry.mjs and node e2e/smoke-import.mjs pass. pnpm typecheck fails on src/test/scim.test.ts:547 (a concurrent SCIM change, outside this scope).

IDSeverityAreaLocationTitleStatus
G-01HighSupply chain / RCEpackages/core/src/modules/tar.ts:63-98, registry/checks.ts:85-95, modules/system.ts:142-183,378-410Duplicate tar entries: the manifest and Wasm the registry checks and the installer validate are the first copy, the file written to disk is the last; syncFromDisk then rewrites the module row's tier from the on-disk manifest, so a "sandboxed" archive installs an in-process trusted module on the next bootFixed: readTar refuses a second entry for the same path (case-insensitively, like writeTar); syncFromDisk disables a module whose on-disk tier differs from the one it was installed with instead of promoting it. Tests: registry.test.ts (tar writer, "refuses a module whose manifest on disk changed tier").
G-02MediumDoSpackages/core/src/importer/sitemap.ts:34-36Sitemap parsing is quadratic: 20,000 unclosed <url> tags (20 KB) take 3.8 s of event-loop time, a 10 MB sitemap minutesFixed: linear indexOf scanner with the same output (importer-crawl.test.ts, "reads a hostile sitemap, robots.txt and path pattern in time proportional to their length").
G-03MediumDoSpackages/core/src/importer/robots.ts:61-69robots.txt rules become regexes with one .* per *: Disallow: /*****…z against a 2 KB path does not returnFixed: a two-pointer wildcard matcher (wildcardMatch), O(rule × path). Same test.
G-04MediumDoSpackages/core/src/importer/links.ts:47-60, routes/imports.ts:14-15, importer/service.ts:76-77Include/exclude globs compile to ambiguous regexes (*?*?… hangs on a 3 KB path) and re: patterns are compiled verbatim (re:^/(a+)+$), both run on every discovered path on the server's event loopFixed: table-driven globMatch; isSafeRegexSource refuses nested quantifiers, backreferences, more than 12 quantifiers and sources over 200 characters; the route answers 400 and normaliseOptions drops anything else. Tests: same file ("refuses re: patterns…", "refuses include and exclude patterns the crawler would not run").
G-05MediumDoSpackages/core/src/importer/html.ts:103-190,322-336, importer/sources.ts:49, importer/fields.ts:99The HTML parser opens unbounded nesting (200,000 <b> → RangeError in text()/innerHtml()/clone()), and the descendant-combinator matcher backtracks exponentially (a a a … a × 40 against 400 nested elements does not return); a manager's selector: field rule runs on every page's main contentFixed: nesting capped at 512 (deeper start tags are leaves), chain matching memoised per call, selectors capped at 8 compounds in the engine and in parseSource. Tests: importer-crawl.test.ts ("parses deeply nested markup…"), importer-plan.test.ts ("refuses selector sources…").
G-06MediumDoS / XSS surfacepackages/blocks/src/markdown.ts:155-186,259-264README rendering on the public package page recurses per block-quote level (64 KB of > → RangeError, a 500 for that package page) and rebuilds nested lists with a growing String.replace (quadratic)Fixed: quote depth capped at 8 and list depth at 16, lists built from arrays. Test: registry.test.ts ("renders hostile README markdown in bounded time and depth").
G-07MediumSupply chainpackages/core/src/modules/registry-client.ts:227-245No freshness check on the signed index: a captured index.json verifies for ever, so a CDN or on-path attacker can hide a yank, an advisory or a fixed release indefinitely (generatedAt is signed but never read)Fixed: with REGISTRY_PUBLIC_KEY set, an index whose generatedAt is older than REGISTRY_MAX_INDEX_AGE_DAYS (30; 0 off) is registry_stale, and one older than the newest the process accepted is registry_rollback; an index without generatedAt (hand-signed static) is not aged. Test: registry.test.ts ("refuses a signed index older than the freshness window…").
G-08MediumSSRFpackages/core/src/modules/registry-client.ts:118-150Index and tarball fetches let undici follow redirects: the pinned agent's lookup is skipped for IP-literal targets, so a registry answering 302 Location: http://127.0.0.1:3000/… (or, in the no-pinning fallback, any private name) reaches the private network, and a file: or plain-http downgrade hop was never checkedFixed: redirects followed by hand (3 hops), each hop scheme-checked and run through the guard again. Test: registry.test.ts ("follows tarball redirects by hand…").
G-09MediumAbuse / third-party DoSpackages/core/src/importer/runner.ts:33, routes/imports.ts:13, importer/service.ts:75rateLimitMs may be 0: four workers at full speed against any public host, with respectRobots: false to ignore its Crawl-delayFixed: IMPORT_MIN_RATE_LIMIT_MS (100 ms) floors the per-host gap in the runner (ImporterOptions.minRateLimitMs; in-process tests pass 0). Test: importer-crawl.test.ts ("never starts requests to a host closer together than the service floor…").
G-10MediumSSRF / abusepackages/core/src/importer/adapters/index.ts:21-38, adapters/drupal.ts:54-62,105-114,154-168The Drupal adapter follows links and next URLs from the JSON:API document it just fetched, wherever they point: a source can make the importer fetch any public host (the guard still refuses private ones)Fixed: getJson only follows links on the source's host, www. folded, or a host under it. Test: importer-crawl.test.ts ("only follows platform API links on the source's own hosts").
G-11LowStored XSS / CSS injectionpackages/core/src/routes/imports-theme.ts:171-181, theme/css.ts:7-10Extracted theme tokens reach the site's :root stylesheet through cssValue, which only strips <>{};: a source stylesheet can plant url(https://tracker/…) or expression(…) in a custom tokenFixed: importableCssValue drops values with url(, image-set(, expression(, -moz-binding, @, backslash escapes or comments. Test: importer-theme.test.ts ("keeps values that fetch or evaluate…", "drops custom token values…").
G-12LowOpen redirect / XSS surfacepackages/core/src/routes/registry.ts:39-49, registry/service.ts:240-262Publisher url/avatarUrl and advisory url pass zod's .url(), which accepts javascript: and data:; React 19 neutralises javascript: hrefs, nothing else doesFixed: http(s) only, at the route and in upsertPublisher. Test: registry.test.ts ("keeps javascript: and data: links out of publisher profiles and advisories").
G-13LowDoSpackages/core/src/routes/registry.ts:165-173, registry/service.ts:462-477GET …/files/<path> reads and gunzips the whole stored tarball (20 MB → up to 64 MB) per request with no rate limit, unlike downloadsFixed: the files endpoint shares the per-address download limiter (120 a minute).
G-14LowDoSpackages/core/src/importer/theme.ts:334-394The CSS parser recurses per nested block and rescans the body per level (a{a{a{… × 100,000 → 3.5 s then RangeError)Fixed: nesting capped at 32. Test: importer-theme.test.ts ("reads a sheet of endless nesting…").
G-15LowStoragepackages/core/src/registry/service.ts:479-495, jobs/retention.tsdownload_daily (one row per address, version and day) is never prunedFixed: rows older than 400 days go with the hourly retention pass.
G-16LowSecretspackages/core/src/cli.ts:357-361, registry/publish-client.ts:63-70spillway publish --registry http://… sends the bearer token in clear to any hostFixed: plain http is refused except to localhost, or with REGISTRY_ALLOW_HTTP=1 (same switch as the client).
G-17InfoReview flowpackages/core/src/registry/service.ts:644-660, routes/registry.ts:285-290A reviewer may approve a trusted module they published themselves (the registry smoke does exactly that with one admin account)Open by design on a one-person registry; registry.approve is audited with the actor and publisherOfVersion shows who published. Recommendation: a REGISTRY_FOUR_EYES=1 switch refusing approval when published_by is the actor. Yanked and rejected versions cannot be re-listed (approve needs pending_review; versions are immutable), confirmed.
G-18InfoOpen redirectpackages/core/src/importer/land.ts:508-518, marketing/redirects.ts:30-33Redirects imported from the source (crawl hops and the Drupal redirect table) may point at absolute http(s) URLs off the new site; they are shown in the Redirects step and landed with an audit row, like any editor-made redirectOpen: targets are validated (site path or absolute http(s), never javascript:); consider landing off-site targets only when the host is the source's or after a confirmation in the Land step.
G-19InfoSandboxpackages/core/src/registry/checks.ts:216-250, modules/sandbox/host.ts:106The smoke load compiles the Wasm on the main thread (WebAssembly.compile of up to 20 MB) before the worker takes over; limits otherwise hold (declared memory maximum ≤ 512 pages, 100 ms guest and 5 s wall budgets, worker.terminate() on timeout, host calls stubbed)Open; bounded by the 30-publish-an-hour limit. WebAssembly.compile could move into the worker.
G-20InfoRoutingpackages/core/src/render/routes.ts:213-227, app.ts:255-270The /<ancestor>/any catch-all page renders only after /api, /admin, /registry/v1, /sso, /mcp, /healthz, media and SEO routes have had their turn, and safePath refuses /_ and /. prefixes; a root-level /any page owns every other 404No shadowing possible; documented here for the record.
G-21InfoGitHub OAuthpackages/core/src/routes/sso.ts:135-141,180-197, sso/github.ts:62-73, sso/service.ts:267-290No PKCE (GitHub OAuth apps ignore it); the signed, HttpOnly, SameSite=Lax flow cookie carries the state and returnTo (safeReturn: same-origin paths only), the callback compares state in constant time, the email is the primary verified address, an account with the same verified email is linked (an operator decision: anyone who proves control of that address at GitHub signs in as that user), JIT users get viewer + registry.publishConfirmed sound; email linking documented in docs/registry.md.
G-22InfoPermissionsall of routes/imports*.ts, routes/registry.tsEvery import route needs imports.manage (admin) and loads the job with the request's site id (requireJob(id, siteId)), pages, assets, clusters, theme and raw files through the job; registry mutations need users.manage or the package's publisher/namespace maintainer; /registry/v1/publish outside /api takes bearer tokens only (no CSRF cover there); public registry JSON is read-onlyConfirmed; nothing from another site is readable.
G-23InfoLogsimporter/service.ts:590-605, registry/checks.ts:139-160The job events log holds source URLs (credentials stripped by normalizeUrl) and error messages; the secrets check reports file names and pattern ids, never the matched strings; audit rows carry sha256, sizes and check idsConfirmed clean.

G-01 (High, CONFIRMED): duplicate tar entries let a sandboxed archive install a trusted module

Where: packages/core/src/modules/tar.ts:63-98 (readTar appends every entry; nothing checks for a repeated path), registry/checks.ts:85-95 (findEntry → entries.find(...), the first copy), modules/system.ts:387-410 (entries.find((e) => e.path === 'manifest.json') validated and consented, then for (const e of entries) await fsp.writeFile(...): the last copy wins on disk), modules/system.ts:142-183 (syncFromDisk at boot: updateModule(existing.id, { manifest, version: manifest.version, tier: manifest.tier }) with the row still enabled).

Exploit: a publisher packs manifest.json (sandboxed, main: main.wasm), main.wasm (a harmless module), then a second manifest.json (tier: "trusted", main: "index.js") and index.js. The registry's checks read the first manifest (sandboxed), smoke-load the Wasm, list the package as sandboxed; a manager (site.settings) installs it from the registry: fetchTarball compares the listed tier with the first manifest (match), the file loop writes the trusted manifest last. On the next restart syncFromDisk flips the row to trusted, loadTrusted imports index.js into the Node process. SPILLWAY_TRUSTED_MODULE_INSTALL and the users.manage gate of F-01 never apply because no trusted install was ever requested. The same archive uploaded through POST /api/modules/install does the same.

Fix: readTar keeps a set of lower-cased paths and throws Duplicate tar entry on a repeat (case-insensitive, because findEntry compares case-insensitively and macOS volumes do too); writeTar matches. syncFromDisk refuses a tier change: the row is disabled with lastError naming both tiers and the module is skipped. The on-disk tier can still never exceed what the row says.

G-02 to G-06 (Medium, CONFIRMED): parsers that are not linear in what the source serves

Each was timed against the built dist (scratch script, Node 22):

InputBeforeAfter
20,000 unclosed <url> (sitemap)3,843 ms32 ms
Disallow: / + 25 × * + z vs a 2,000-char path (robots)> 20 s1 ms
glob /*?*?… (60 pairs) vs a 3,000-char path> 20 s8 ms
re:^/(a+)+$ vs 30 × a + !> 20 srefused
40-compound descendant selector vs 400 nested elements> 20 srefused (8 compounds max; 8 vs 400 in 3 ms)
200,000 nested <b>: parse + text() + innerHtml()RangeError228 ms
64 KB of > (README)RangeError14 ms
20,000 alternating nested list items (README)743 ms106 ms
a{ × 100,000 (CSS)3,535 ms then RangeError64 ms

The importer's are all reachable by the site being imported (which a manager picks, but which may be compromised or hostile), and they run on the event loop of the whole instance: during the 20 s hangs every other request waits. The README one is reachable by any signed-in user with registry.publish (every GitHub sign-in on the registry host) and lands on the public package page.

Fixes: sitemap.ts is an indexOf scanner; robots.ts has wildcardMatch (greedy two-pointer); links.ts has globMatch (a row per token over the path) and isSafeRegexSource (no quantified group holding a quantifier, no backreferences, ≤ 12 quantifiers, ≤ 200 characters; also applied by routes/imports.ts with a 400 and by normaliseOptions); html.ts caps nesting at MAX_NESTING (512), memoises matchesChain per select/selectOne/matches call and throws on more than MAX_SELECTOR_COMPOUNDS (8), which sources.ts refuses first; markdown.ts caps block quotes at 8 and lists at 16 levels and builds lists from arrays; theme.ts caps CSS nesting at 32.

G-07 (Medium, CONFIRMED): no freshness on the signed index

Where: packages/core/src/modules/registry-client.ts:227-245 (load: signature verified, then normaliseIndex(raw)), registry/service.ts:613-640 (buildIndex writes generatedAt: now() inside the signed document).

A valid ed25519 signature proves who produced the index, not when. A CDN that serves a stale object, a DNS or BGP interposition, or a mirror operator can hand out an index captured before a version was yanked (registry.yank removes it from the index, nothing else reaches the client) or before an advisory-fixed release, and every consuming site keeps installing the old version. docs/registry.md noted that the replay "at least says when it was made" but no code read it.

Fix: checkFreshness after verification: generatedAt older than maxIndexAgeMs (REGISTRY_MAX_INDEX_AGE_DAYS, default 30, 0 off) → registry_stale; older than the newest generatedAt this process accepted → registry_rollback; an unreadable generatedAt → registry_invalid; absent (a spillway registry sign static index) → not aged, documented. Advisories themselves are not in the index; the agent manifest and package pages carry them.

G-08 (Medium, CONFIRMED): registry redirects are followed by undici, not by the guard

Where: packages/core/src/modules/registry-client.ts:118-150 (guardedFetch(url, { signal, headers }) with the default redirect: 'follow'), forms/http.ts:118-135 (the pinned agent's lookup is only consulted for host names; net.connect to an IP literal never calls it).

A registry (or whoever can answer for its host) returns 302 Location: http://127.0.0.1:3000/api/… for a tarball: undici follows inside the same dispatcher, the lookup is skipped for the literal, the request reaches the loopback. The sha256 check means the attacker learns only whether the bytes matched, but the request is made with the registry's accept header against anything on the private network (blind SSRF), and in the no-pinning fallback a redirect to a private name also goes through. A Location: file:///… hop would have been refused by undici itself; a https:// → http:// downgrade with REGISTRY_ALLOW_HTTP unset was not.

Fix: readUrl sends redirect: 'manual', follows at most three hops itself, checks each hop's scheme (https:, or http: only with REGISTRY_ALLOW_HTTP) and runs guardedFetch per hop (fresh resolution, classification and pin). The test drives file:, a downgrade, four hops and a CDN hop.

G-09 and G-10 (Medium, CONFIRMED): the importer as a tool against third parties

rateLimitMs: 0 with four workers and respectRobots: false lets one job hit a public host as fast as it answers for an hour (the time budget), 5,000 pages at a time, with a clear user agent but no floor. The Drupal adapter additionally follows whatever links.*.href and links.next say, so a source can point the importer at a third host (the guard refuses private targets, not public ones).

Fixes: ImporterService.minRateLimitMs (default IMPORT_MIN_RATE_LIMIT_MS = 100 ms; the in-process tests pass 0) floors the per-host gap in the runner, so a host never sees more than ten request starts a second from one job and one job runs per instance; adapterContext.getJson returns null for any URL off the source's host (www. folded) or a host under it.

G-11 to G-16 (Low, CONFIRMED): fixed as well

  • G-11 importableCssValue (routes/imports-theme.ts): the site theme's cssValue is meant for values editors type; values the extractor pulled out of a stranger's stylesheet additionally lose anything that fetches or evaluates.
  • G-12 httpUrl in routes/registry.ts and the same check in RegistryService.upsertPublisher: zod's .url() is a WHATWG parse, so javascript:alert(1) and data:text/html,… passed; React 19 blocks javascript: hrefs in the blocks, but data: and future renderers are not covered by that.
  • G-13 the files endpoint shares downloadLimiter.
  • G-14 CSS_MAX_NESTING.
  • G-15 DOWNLOAD_DAILY_RETENTION_DAYS in jobs/retention.ts.
  • G-16 assertPublishRegistryUrl in cli.ts.

What is done well

  • The guard is used everywhere it should be: the crawler, the stylesheet fetcher, asset downloads, the logo fetch, the adapters and the registry client all go through guardedFetch/assertPublicUrl; the crawler follows redirects by hand and re-vets each hop; bodies are read under a cap while streaming; normalizeUrl strips credentials, fragments and tracking parameters before a URL is stored or logged.
  • Tar: links, absolute paths, .., NUL, base-256 sizes and oversize archives refused; gunzip with maxOutputLength; resolveInside on every write; the writer applies the same rules so the CLI cannot produce what the reader refuses; registry storage paths come from a name regex and a semver parse.
  • Registry flow: versions are immutable and must increase; the tier of a package cannot change; trusted modules are pending_review and absent from search and the index until a users.manage holder approves; approve/reject need pending_review, so a yanked or rejected version cannot come back; yanking recomputes latest; namespace ownership is settled on first publish and checked on every later one; publishes are rate-limited per user and downloads per address; download counts are deduplicated per day under an HMAC of the address; every mutation evicts the tagged public pages and writes an audit row.
  • Public rendering: the Markdown converter escapes before it adds markup, keeps only http(s), mailto and relative links, and adds rel="noopener nofollow"; package names, handles and versions are regex-validated at publish; the agent manifest's shell command interpolates only validated names; screenshots are magic-checked and served only when the manifest lists them and the archive held them, with nosniff.
  • Importer data handling: extracted main content is sanitized before storage and again at conversion (sanitizeHtml with the embed allow-list); raw pages are served under sandbox; default-src 'none'; script-src 'none' and viewed in sandbox="" iframes; redirect targets and menu links are scheme-checked; menu keys and type keys are regex-validated; job directories are keyed by a validated UUID and sha1(url) file names; asset bytes are MIME-sniffed and type-allow-listed; nothing lands published without content.publish.
  • GitHub sign-in: state in a signed HttpOnly cookie bound to the provider slug, constant-time comparison, verified-email-only identity, same-origin returnTo, login limiter shared with the password form.
  • Sandbox smoke load: declared memory bounds, only the three host imports, guest and wall budgets, terminate() on timeout, host calls stubbed so nothing reaches the network or the database.

Routes reviewed

/api/imports* (14 routes in routes/imports.ts, 7 in routes/imports-plan.ts, 3 in routes/imports-theme.ts): all requirePermission(c, 'imports.manage') and requireJob(id, currentSite(c).id) (or getJob + 404), sub-resources loaded through the job id. /api/registry/* (15 routes): requireUser, requirePermission('registry.publish' | 'users.manage') or requireManage (publisher or namespace maintainer); /registry/v1/* (public JSON): read-only, CORS *, 60 s cache, publish bearer-only; /api/registry/status any signed-in user. The catch-all public page cannot shadow any of them (G-20).

Spillway 0.1.0 · Edit this page on GitHub

Was this page helpful?