Skip to content

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

Merged
nedtwigg merged 5 commits into
mainfrom
fix/loopback-lint-vite-bind
Sep 16, 2026
Merged

nedtwigg merged 5 commits into
mainfrom
fix/loopback-lint-vite-bind

Conversation

@dormouse-bot

@dormouse-bot dormouse-bot commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

The loopback lint missed Vite's server.host bind form, leaving the browser-dev Vite listener outside its inventory. Vite's default CORS policy also allowed other localhost origins to read modules containing the browser-dev bridge token.

This change recognizes explicit loopback server.host values, including when options objects up to two levels deep precede host (such as proxy target objects), and adds matching self-test fixtures. The Vite listener is allowlisted with its actual controls: cors: false prevents cross-origin module reads, and allowedHosts: [] retains Vite's DNS-rebinding Host check.

The existing integration test now verifies that a foreign localhost Origin receives no CORS permission and a hostile Host receives 403. security-local.md records this boundary, with its evidence in the paired rationale.

Validation:

  • pnpm lint:specs passed.
  • pnpm lint:loopback passed, including 12 load-bearing self-checks.
  • The browser-dev, native-dev, and dev-host-guard suites passed: 14 tests.
  • Mutation checks confirmed that removing cors: false or changing allowedHosts to true fails the integration test at the expected assertion.

The token leak did not by itself bypass the bridge's separate JSON-content-type and exact-origin CORS gates. This change closes the read and gives it regression coverage.

Refs #598.

…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

cloudflare-workers-and-pages Bot commented Sep 16, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3c90b9f
Status: ✅  Deploy successful!
Preview URL: https://150aabbd.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 Outdated
Comment thread scripts/loopback-lint.mjs Outdated
@nedtwigg
nedtwigg marked this pull request as ready for review September 16, 2026 20:33
dormouse-bot and others added 2 commits September 16, 2026 20:44
…th a test

Two gaps in the review of this PR's own change.

The `vite, server.host` form used `[^}]*?`, which cannot cross a `}`, so it
matched a `server` block only while `host` preceded every nested object in it.
`fs`, `hmr`, `proxy`, `headers` and `watch` are ordinary `server` keys, so the
shape it missed is the common shape of a real `vite.config.ts` — and `dev-run.mjs`
carries `hmr: { … }` in that block, one key reorder away from it. `ONE_NESTED`
permits one level of nesting; the new selftest fixture is the brace-before-`host`
shape, and narrowing the form back to `[^}]*?` makes the selftest go red.

Nothing pinned `cors: false`. The ALLOWED entry named it as a control standing in
for the guard modules, but deleting the line left `pnpm test` green with the
stated reason describing a control that was gone. `dev-agent-browser.test.mjs`
already fetches `/app.js` from the run's live Vite server to assert the bridge
token is baked into it; one more request to that URL with a foreign `Origin`
asserts no `access-control-allow-origin` comes back. Vite 8.3.0's default is
`cors: { origin: /^https?:\/\/(?:(?:[^:]+\.)?localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/ }`,
so without the pin that assertion sees `http://localhost:31337` echoed back.

@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.

The ready-for-review head closed both draft findings. I checked the cors: false
pin isn't vacuous: Node's fetch forwards a caller-set origin rather than
dropping it the way a browser would, so that assertion reads Vite's response and
not an absent request header. http.get forwards an overridden host the same
way, so the new rebind assertion is real too.

One gap left. The comment on the new form names proxy among the server keys
ONE_NESTED was widened to clear, but proxy's idiomatic form nests a target
object per route — two levels — and the pattern reaches one:

server block ONE_NESTED
{ fs: { allow: ['.'] }, host: '127.0.0.1' } match
{ proxy: { '/api': 'http://up' }, host: '127.0.0.1' } match
{ proxy: { '/api': { target: 'http://up' } }, host: '127.0.0.1' } no match

A vite.config.ts that proxies an upstream and binds loopback is exactly the
silent miss this PR exists to close, and the comment tells the next editor that
key is covered. Allowing a second level takes row 3, still stops at the block's
own closing brace (three levels stays a miss), and leaves the match set over
git ls-files unchanged — dev-run.mjs and the selftest fixtures, 29ms across
941 files. The suggestions below carry it; I'm pushing them plus a proxy
fixture under the same label, since there is no separate author here to apply
them.

Comment thread scripts/loopback-lint.mjs Outdated
Comment thread scripts/loopback-lint.mjs Outdated
`proxy` is the one ordinary `server` key whose idiomatic form nests two
objects deep — a target object per route — so a `vite.config.ts` that
proxied an upstream and bound loopback slipped past the one-level scan
while the comment named `proxy` as covered.

Allowing a second level takes that shape, still stops at the block's own
closing brace, and leaves the match set over `git ls-files` unchanged. A
third fixture under the same label pins it: narrowing back to one level
makes the selftest go red.

@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.

proxy is reachable now and the new fixture pins it, but the comments that
arrived with it state a depth bound Vite's own API exceeds.

NESTED_KEYS says "Two levels is as deep as a server key goes", and the
fixture calls the two-level proxy shape "the deepest shape this form reaches".
A proxy route object nests again: headers and cookieDomainRewrite are
plain objects, and configure takes a function whose body carries braces of its
own. Measured against the pattern as pushed:

server block matches
{ proxy: { '/api': { target: 'http://up' } }, host: '127.0.0.1' } yes
{ proxy: { '/api': { target: 'http://up', headers: { 'X-Foo': 'bar' } } }, host: '127.0.0.1' } no
{ proxy: { '/api': { target: 'http://up', configure: (p, o) => { … } } }, host: '127.0.0.1' } no

configure is why a third level is not the answer: a function body carries
unbounded braces, so no depth a regex can spell is the last one, and another
round of widening buys one shape while leaving the claim just as wrong. The
defect is the claim, not the depth — a next editor reads "as deep as a server
key goes" and takes proxy-with-headers for covered, which is the same silent
miss this PR exists to close.

Pushed the correction: the depth stays at two, and the bound moves into the
header's "What it deliberately does NOT do" list, beside the runtime-host
ceiling already named there as the audit's job. Comments only — no pattern
change, so the match set and the 12 load-bearing self-checks are unchanged.

…denying it

`NESTED_KEYS` claimed two levels was as deep as a `server` key goes. A
`proxy` route object nests again — `headers` and `cookieDomainRewrite` are
plain objects, and `configure` takes a function whose body carries braces —
so proxy-with-headers above `host` is a miss the comment called covered.

No depth a regex can spell is the last one, so the depth stays at two and the
bound moves into the header's "What it deliberately does NOT do" list, beside
the runtime-host ceiling already named there as the audit's job. Comments
only: the match set and the 12 load-bearing self-checks are unchanged.
@nedtwigg
nedtwigg merged commit f933dcb into main Sep 16, 2026
7 checks passed
@nedtwigg
nedtwigg deleted the fix/loopback-lint-vite-bind branch September 16, 2026 21:37
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.

2 participants