Skip to content

Teach the loopback lint Vite's bind form and pin the dev server's origins - #671

Draft
dormouse-bot wants to merge 1 commit into
mainfrom
fix/loopback-lint-vite-bind
Draft

dormouse-bot wants to merge 1 commit into
mainfrom
fix/loopback-lint-vite-bind

Conversation

@dormouse-bot

Copy link
Copy Markdown
Collaborator

Problem

standalone/scripts/dev-run.mjs:31 binds a Vite dev server to 127.0.0.1, but it does it through createServer({ server: { host } }) followed by an argument-less vite.listen() — a spelling none of the five BIND_FORMS alternatives in scripts/loopback-lint.mjs matched. The file referenced no guard module either, so it appeared in neither the lint's inventory nor its allowlist: docs/specs/security-local.md -> "Loopback Listeners" says "Adding a server dependency means adding its bind spelling to BIND_FORMS", and Vite was a server dependency with no spelling. Found by the 2026-09-16 application-security audit domain (#598), which returned FAIL on exactly that clause; the same listener was WARNING Q6 on 09-15.

What made it more than bookkeeping: dev-agent-browser.mjs:342-344 bakes VITE_DORMOUSE_BROWSER_DEV_HOST = http://127.0.0.1:<port>/?t=<bridgeToken> into the modules Vite serves, and bridgeToken is the whole authorization for the bridge that dispatches pty_spawn with caller-supplied shell, args, cwd and env. Nothing in this repo set server.cors, so Vite's default applied. Measured against the installed vite@8.3.0:

defaults | loopback Host, foreign Origin: http://localhost:31337 -> 200 | Access-Control-Allow-Origin: http://localhost:31337
pinned   | loopback Host, foreign Origin: http://localhost:31337 -> 200 | (no acao)

Any page the developer has open on some other localhost port could read the served module and lift the token. It could not then drive the bridge — the application/json content-type gate forces a preflight that corsHeaders answers with viteOrigin — so this is a leak the next gate happens to contain, not a reachable execution path.

Solution

Add a vite, server.host alternative to BIND_FORMS, matched on the server block rather than on createServer so a vite.config.ts, Vitest or Storybook builder config with the same shape is caught too. It matches exactly one tracked file today, dev-run.mjs.

Vite owns that listener's request path, so neither guard module can run on it — the file goes on ALLOWED with the two controls that stand in for them, now pinned at the bind instead of inherited:

  • cors: false — no Access-Control-Allow-Origin for any origin, so no foreign page reads the token-bearing modules. Nothing reads this server cross-origin: the app page is served from it, and the bridge is a separate origin sending its own headers.
  • allowedHosts: [] — Vite's own default, restated so a widening of the anti-DNS-rebind Host check shows up in a diff. Behavior-neutral: a Host: evil.example request gets 403 before and after.

No spec change. The violated clause is the BIND_FORMS one, which is satisfied again, and per AGENTS.md the mechanism that constrains this single module belongs as a comment at the code. Whether the CORS pin also deserves its own FAIL IF in security-local.md -> "Loopback Listeners" is a maintainer call — as it stands, the ALLOWED entry's stated reason is the only thing that would flag its removal in review.

Testing

  • scripts/loopback-lint.mjs now reports standalone/scripts/dev-run.mjs:27 in its non-test inventory (2 allowlisted, was 1).
  • scripts/loopback-lint-selftest.mjs — 10 load-bearing checks, was 9. The new fixture appends server: { host: '127.0.0.1' } to an unguarded file and requires the lint to go red; the coverage check that reads BIND_FORMS labels would go red on a form with no fixture.
  • standalone's dev-script tests, which stand up real Vite and HTTP listeners: dev-agent-browser.test.mjs 5/5, dev-standalone.test.mjs + dev-host-guard.test.mjs 9/9.
  • The CORS table above is from a probe against vite@8.3.0 in this checkout, running the real startDevVite and reading the response headers over a raw socket.

Refs #598 — automated triage of the 2026-09-16 audit FAIL.

…gins

`standalone/scripts/dev-run.mjs` binds Vite to 127.0.0.1 through
`createServer({ server: { host } })` and an argument-less `listen()`, a
spelling no BIND_FORMS alternative matched, so the one listener that serves
the browser-dev bridge token appeared in neither the lint's inventory nor its
allowlist.

Add the form with a self-test fixture, and pin what stands in for a guard
Vite's request path cannot run: `cors: false`, because Vite's default answers
every `http://localhost:*` origin with a matching ACAO on modules carrying
`VITE_DORMOUSE_BROWSER_DEV_HOST`, and `allowedHosts: []` restated so a
widening of the anti-rebind Host check is a visible diff.

Refs #598
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 01bfa12
Status: ✅  Deploy successful!
Preview URL: https://ff99eb34.mouseterm.pages.dev
Branch Preview URL: https://fix-loopback-lint-vite-bind.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feedback on work in progress — this is a draft, so it's a COMMENT, not a merge verdict. Mark it ready when you want the full review.

Two things, one of them load-bearing.

Nothing pins cors: false. The ALLOWED exemption says the two config keys "stand in for" the guard modules, but the lint only checks that the file matches a bind form and appears in ALLOWED — deleting cors: false from dev-run.mjs leaves pnpm test fully green, with the allowlist reason still describing a control that is gone. Every other exemption in this repo carries a mechanical check: the sibling lints' self-tests exist because, per AGENTS.md, "A rule added to one of these lints without its self-test case is not enforced — it is a claim that something is checked." The same argument applies to a control an allowlist entry claims. The cheap place to pin it is a test that already exists: in standalone/scripts/dev-agent-browser.test.mjs, the loop body that fetches /app.js from the run's app origin and asserts the bridge token is baked into it already has a live Vite server and is fetching the exact resource cors: false protects. One more request to that URL with a foreign Origin header, asserting no access-control-allow-origin comes back, turns the PR's measured table into a check. As it stands the probe is in the PR description and nowhere in CI.

The new bind form stops at the first closing brace. [^}]*? cannot cross a }, so the alternative matches a server block only while host precedes every nested object in it. Measured against the three shapes:

server block current with the suggested change
{ host: '127.0.0.1', port: 0 } match match
{ fs: { allow: ['.'] }, host: '127.0.0.1' } no match match
{ hmr: { protocol: 'ws', port: 0 }, host: '127.0.0.1' } no match match

fs, hmr, proxy, headers and watch are all ordinary server keys, so rows 2 and 3 are the common shape of a real vite.config.ts — and dev-run.mjs itself now holds hmr: { host: 'localhost', port: 0, protocol: 'ws' } in that block, one key reorder away from row 3. For dev-run.mjs the stale-allowlist check turns that into a red build, so the tracked file is safe; a new Vite config with a nested key above host is the silent case, which is the same failure mode that put this PR here. The new selftest fixture puts host first, so nothing exercises the shape that misses.

The inline suggestion permits one level of nesting. I ran both patterns over git ls-files: identical match sets today (dev-run.mjs and the selftest fixture, nothing else), including no new hit on standalone/vite.config.ts's server: { host: host || false, … } or website/vite.config.ts's server: { host: true }. Worth a second selftest fixture in the brace-before-host shape if you take it — the coverage check is keyed on labels, so a second fixture under the same label rides along without complaint.

Separately, the PR description's open question about whether the CORS pin deserves its own FAIL IF in docs/specs/security-local.md -> "Loopback Listeners" reads the right way to me as written — but the test above is worth having regardless of how that lands, since a FAIL IF is audited probabilistically and the test is not.

Comment thread scripts/loopback-lint.mjs
// form can see it. Matched on the `server` block rather than on `createServer`
// because the same block is what a `vite.config.ts` — or Vitest, or
// Storybook's builder — passes to the same server.
{ label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{[^}]*?host\\s*:\\s*${LOOPBACK}` },

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Permitting one level of nesting so the alternative still matches when a nested server key (fs, hmr, proxy, headers, watch) sits above host. Verified over git ls-files: same match set as the current pattern today, no new hits.

Suggested change
{ label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{[^}]*?host\\s*:\\s*${LOOPBACK}` },
{ label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{(?:[^{}]|\\{[^{}]*\\})*?host\\s*:\\s*${LOOPBACK}` },

Comment thread scripts/loopback-lint.mjs
Comment on lines +78 to +79
+ 'and unbundled — it ships in nothing. See standalone/scripts/dev-run.mjs '
+ 'and standalone/scripts/dev-host-guard.mjs for the bridge beside it.',

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entry's key is standalone/scripts/dev-run.mjs, so the reason points a reader at the file it is about. The sibling entry points only outward.

Suggested change
+ 'and unbundled — it ships in nothing. See standalone/scripts/dev-run.mjs '
+ 'and standalone/scripts/dev-host-guard.mjs for the bridge beside it.',
+ 'and unbundled — it ships in nothing. See '
+ 'standalone/scripts/dev-host-guard.mjs for the bridge beside it.',

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant