Teach the loopback lint Vite's bind form and pin the dev server's origins - #671
dormouse-bot wants to merge 1 commit into
Conversation
…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
Deploying mouseterm with
|
| 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 |
dormouse-bot
left a comment
There was a problem hiding this comment.
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.
| // 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}` }, |
There was a problem hiding this comment.
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.
| { label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{[^}]*?host\\s*:\\s*${LOOPBACK}` }, | |
| { label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{(?:[^{}]|\\{[^{}]*\\})*?host\\s*:\\s*${LOOPBACK}` }, |
| + 'and unbundled — it ships in nothing. See standalone/scripts/dev-run.mjs ' | ||
| + 'and standalone/scripts/dev-host-guard.mjs for the bridge beside it.', |
There was a problem hiding this comment.
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.
| + '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.', |
Problem
standalone/scripts/dev-run.mjs:31binds a Vite dev server to127.0.0.1, but it does it throughcreateServer({ server: { host } })followed by an argument-lessvite.listen()— a spelling none of the fiveBIND_FORMSalternatives inscripts/loopback-lint.mjsmatched. 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 toBIND_FORMS", and Vite was a server dependency with no spelling. Found by the 2026-09-16application-securityaudit domain (#598), which returnedFAILon exactly that clause; the same listener was WARNING Q6 on 09-15.What made it more than bookkeeping:
dev-agent-browser.mjs:342-344bakesVITE_DORMOUSE_BROWSER_DEV_HOST = http://127.0.0.1:<port>/?t=<bridgeToken>into the modules Vite serves, andbridgeTokenis the whole authorization for the bridge that dispatchespty_spawnwith caller-suppliedshell,args,cwdandenv. Nothing in this repo setserver.cors, so Vite's default applied. Measured against the installedvite@8.3.0:Any page the developer has open on some other
localhostport could read the served module and lift the token. It could not then drive the bridge — theapplication/jsoncontent-type gate forces a preflight thatcorsHeadersanswers withviteOrigin— so this is a leak the next gate happens to contain, not a reachable execution path.Solution
Add a
vite, server.hostalternative toBIND_FORMS, matched on theserverblock rather than oncreateServerso avite.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
ALLOWEDwith the two controls that stand in for them, now pinned at the bind instead of inherited:cors: false— noAccess-Control-Allow-Originfor 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: aHost: evil.examplerequest gets 403 before and after.No spec change. The violated clause is the
BIND_FORMSone, which is satisfied again, and perAGENTS.mdthe mechanism that constrains this single module belongs as a comment at the code. Whether the CORS pin also deserves its ownFAIL IFinsecurity-local.md-> "Loopback Listeners" is a maintainer call — as it stands, theALLOWEDentry's stated reason is the only thing that would flag its removal in review.Testing
scripts/loopback-lint.mjsnow reportsstandalone/scripts/dev-run.mjs:27in its non-test inventory (2 allowlisted, was 1).scripts/loopback-lint-selftest.mjs— 10 load-bearing checks, was 9. The new fixture appendsserver: { host: '127.0.0.1' }to an unguarded file and requires the lint to go red; the coverage check that readsBIND_FORMSlabels 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.mjs5/5,dev-standalone.test.mjs+dev-host-guard.test.mjs9/9.vite@8.3.0in this checkout, running the realstartDevViteand reading the response headers over a raw socket.Refs #598 — automated triage of the 2026-09-16 audit
FAIL.