From 01bfa12c19b7778ff107d3e089e3aa3bbd3b7ff5 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:40:11 +0000 Subject: [PATCH 1/5] Teach the loopback lint Vite's bind form and pin the dev server's origins `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 --- scripts/loopback-lint-selftest.mjs | 1 + scripts/loopback-lint.mjs | 14 ++++++++++++++ standalone/scripts/dev-run.mjs | 13 +++++++++++++ 3 files changed, 28 insertions(+) diff --git a/scripts/loopback-lint-selftest.mjs b/scripts/loopback-lint-selftest.mjs index f6f1ffa37..a9ce2f1c7 100644 --- a/scripts/loopback-lint-selftest.mjs +++ b/scripts/loopback-lint-selftest.mjs @@ -62,6 +62,7 @@ const FIXTURES = [ ['ws, explicit loopback host', "\nexport function __selftest() { return new WebSocket.Server({ host: '127.0.0.1' }); }\n"], ['ws, port only', '\nexport function __selftest() { return new WebSocketServer({ port: 9999 }); }\n'], ['ws, port only', '\nexport function __selftest() { return new WebSocket.Server({ port: 9999 }); }\n'], + ['vite, server.host', "\nexport const __selftest = { server: { host: '127.0.0.1', strictPort: true } };\n"], ]; const selftest = makeSelftest('loopback-lint.mjs', '.loopback-selftest.bak'); diff --git a/scripts/loopback-lint.mjs b/scripts/loopback-lint.mjs index 85b048dfd..5a65fe479 100644 --- a/scripts/loopback-lint.mjs +++ b/scripts/loopback-lint.mjs @@ -69,6 +69,14 @@ const ROOT = fileURLToPath(new URL('..', import.meta.url)); * review; forgetting the guard entirely does not. */ const ALLOWED = { + 'standalone/scripts/dev-run.mjs': + 'The Vite dev server owns its own request path, so neither guard module can ' + + 'run on it. What stands in for them is pinned at the bind: cors: false, ' + + 'because the modules Vite serves carry the browser-dev bridge token and ' + + 'Vite\'s default admits every http://localhost:* origin to read them; and ' + + 'allowedHosts: [], the Host check that makes DNS rebinding fail. Dev-only ' + + 'and unbundled — it ships in nothing. See standalone/scripts/dev-run.mjs ' + + 'and standalone/scripts/dev-host-guard.mjs for the bridge beside it.', 'vscode-ext/src/agent-browser-host.ts': 'The stream relay authenticates with a single-use 64-hex token (60s TTL, ' + 'pinned to one target port) and drops Origin rather than rewriting it, so ' @@ -111,6 +119,12 @@ const BIND_FORMS = [ { label: '@hono/node-server', re: `\\bserve\\(\\s*\\{[^}]*?hostname\\s*:\\s*${LOOPBACK}` }, { label: 'ws, explicit loopback host', re: `${WS_NEW}host\\s*:\\s*${LOOPBACK}` }, { label: 'ws, port only', re: `${WS_NEW}port\\s*:` }, + // Vite binds from config rather than from a call argument: `createServer({ + // server: { host } })` then an argument-less `listen()`, so neither `.listen` + // 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}` }, ]; const LISTEN_RE = new RegExp(BIND_FORMS.map((form) => form.re).join('|'), 'gs'); diff --git a/standalone/scripts/dev-run.mjs b/standalone/scripts/dev-run.mjs index 29f9bf7e3..39d7de1a9 100644 --- a/standalone/scripts/dev-run.mjs +++ b/standalone/scripts/dev-run.mjs @@ -31,6 +31,19 @@ export async function startDevVite(define) { host: '127.0.0.1', port: Number(process.env.DORMOUSE_BROWSER_DEV_VITE_PORT || 0), strictPort: true, + // Vite owns this listener's request path, so the bridge's guard module + // cannot run on it; these two stand in for it, pinned here rather than + // inherited. `cors: false` because Vite's default admits every + // `http://localhost:*` origin, and the modules served here carry the + // browser-dev bridge token (`VITE_DORMOUSE_BROWSER_DEV_HOST`, baked in by + // `dev-agent-browser.mjs`) — a page in the developer's own browser must + // not be able to read it. 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: []` is Vite's own default, restated so a + // widening is a visible diff — it is the anti-DNS-rebind Host check the + // bridge makes for itself. + cors: false, + allowedHosts: [], // Share Vite's listener, including when TAURI_DEV_HOST is inherited. hmr: { host: 'localhost', port: 0, protocol: 'ws' }, }, From e082d0bebdca7b188a10bc9fe64c4d948576f0bd Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:44:33 +0000 Subject: [PATCH 2/5] fix(loopback-lint): match a nested server key, and pin cors: false with a test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/loopback-lint-selftest.mjs | 3 +++ scripts/loopback-lint.mjs | 17 +++++++++++++---- standalone/scripts/dev-agent-browser.test.mjs | 7 +++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/scripts/loopback-lint-selftest.mjs b/scripts/loopback-lint-selftest.mjs index a9ce2f1c7..8828ea187 100644 --- a/scripts/loopback-lint-selftest.mjs +++ b/scripts/loopback-lint-selftest.mjs @@ -63,6 +63,9 @@ const FIXTURES = [ ['ws, port only', '\nexport function __selftest() { return new WebSocketServer({ port: 9999 }); }\n'], ['ws, port only', '\nexport function __selftest() { return new WebSocket.Server({ port: 9999 }); }\n'], ['vite, server.host', "\nexport const __selftest = { server: { host: '127.0.0.1', strictPort: true } };\n"], + // A nested `server` key above `host` — the shape a real `vite.config.ts` has + // and the one a first-brace-terminated scan misses. + ['vite, server.host', "\nexport const __selftest = { server: { fs: { allow: ['.'] }, host: '127.0.0.1' } };\n"], ]; const selftest = makeSelftest('loopback-lint.mjs', '.loopback-selftest.bak'); diff --git a/scripts/loopback-lint.mjs b/scripts/loopback-lint.mjs index 5a65fe479..efe8be427 100644 --- a/scripts/loopback-lint.mjs +++ b/scripts/loopback-lint.mjs @@ -75,8 +75,9 @@ const ALLOWED = { + 'because the modules Vite serves carry the browser-dev bridge token and ' + 'Vite\'s default admits every http://localhost:* origin to read them; and ' + 'allowedHosts: [], the Host check that makes DNS rebinding fail. Dev-only ' - + '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. The cors pin is checked by ' + + 'standalone/scripts/dev-agent-browser.test.mjs; see ' + + 'standalone/scripts/dev-host-guard.mjs for the bridge beside it.', 'vscode-ext/src/agent-browser-host.ts': 'The stream relay authenticates with a single-use 64-hex token (60s TTL, ' + 'pinned to one target port) and drops Origin rather than rewriting it, so ' @@ -105,6 +106,12 @@ const LOOPBACK = "['\"](?:127\\.0\\.0\\.1|localhost)['\"]"; // branch that can rot alone, which is how `WebSocket\.Relay` sat here matching // nothing while the `WebSocketServer` branch beside it kept the lint green. const WS_NEW = '\\bnew\\s+WebSocket\\.?Server\\(\\s*\\{[^}]*?'; +// Keys of one options object, allowing one level of nested object between the +// opening brace and the key being looked for. `[^}]*?` stops at the first `}`, +// so a form that uses it only matches while its key precedes every nested +// object — fine for a call's flat options, wrong for a `vite.config.ts` `server` +// block, where a nested key above `host` is the common shape. +const ONE_NESTED = '(?:[^{}]|\\{[^{}]*\\})*?'; /** * Every bind form `LISTEN_RE` looks for, one entry per alternative — the @@ -123,8 +130,10 @@ const BIND_FORMS = [ // server: { host } })` then an argument-less `listen()`, so neither `.listen` // 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}` }, + // Storybook's builder — passes to the same server. `ONE_NESTED`, not `[^}]*?`, + // because `fs`, `hmr`, `proxy`, `headers` and `watch` are ordinary `server` + // keys and any of them written above `host` would otherwise end the scan. + { label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{${ONE_NESTED}host\\s*:\\s*${LOOPBACK}` }, ]; const LISTEN_RE = new RegExp(BIND_FORMS.map((form) => form.re).join('|'), 'gs'); diff --git a/standalone/scripts/dev-agent-browser.test.mjs b/standalone/scripts/dev-agent-browser.test.mjs index c305dc0b6..18a309ae4 100644 --- a/standalone/scripts/dev-agent-browser.test.mjs +++ b/standalone/scripts/dev-agent-browser.test.mjs @@ -104,6 +104,13 @@ test('parallel worktrees own ports, browser identities and bridges; stopping one for (const [run, dir, other] of [[one, a.root, two], [two, b.root, one]]) { const js = await (await fetch(`${run.app}/app.js`)).text(); assert.ok(js.includes(`${run.bridge}/?t=${run.token}`)); + // `cors: false` in dev-run.mjs, pinned here because nothing else would + // notice its removal: these modules carry the bridge token, and Vite's + // default answers every http://localhost:* origin with an acao of its own, + // which is a read of the token by any other page in the developer's browser. + const foreign = await fetch(`${run.app}/app.js`, { headers: { origin: 'http://localhost:31337' } }); + assert.equal(foreign.status, 200); + assert.equal(foreign.headers.get('access-control-allow-origin'), null); // HMR must share this listener, even with a Tauri-specific host inherited. await new Promise((resolve, reject) => { const ws = new WebSocket(run.app.replace('http:', 'ws:'), 'vite-ping'); From 9c11aa87a6738862517b573b4e7633c3e76e1d0d Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 16 Sep 2026 13:55:40 -0700 Subject: [PATCH 3/5] test: pin Vite Host protection and document its security boundary --- docs/specs/security-local.md | 4 +++- docs/specs/security-local.rationale.md | 6 ++++++ scripts/loopback-lint.mjs | 2 +- standalone/scripts/dev-agent-browser.test.mjs | 11 +++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 481608288..1fac8c03e 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -124,11 +124,13 @@ embedder, and the browser checks the whole chain (rationale). - **FAIL IF** a request bearing a *foreign* `Origin` refreshes a grant's idle timer: a grant holds a live upstream binding, and a stranger polling it keeps a closed pane's binding open. An *absent* `Origin` must keep refreshing it — that is what a live frame's own navigations and sub-resources send. - **FAIL IF** the stream relay's grant stops being single-use, TTL-bounded, and pinned to one target port, or if it begins rewriting `Origin` rather than dropping it. It needs no `Host` check while the token holds (rationale). - **FAIL IF** the browser-dev bridge drops any of its four gates — the per-run token, the loopback `Host` check, the `application/json` content-type required of every non-GET, and the exact-origin `access-control-allow-origin`. The first three live together in the gate that runs before routing, so a route that never reads a body is covered by all of them. It is dev-only and ships in nothing, but it dispatches `pty_spawn` with caller-supplied `shell`, `args`, `cwd` and `env` — arbitrary command execution on a maintainer or CI-agent machine (`docs/specs/security-ci.md` -> "Automated Maintainer (tend)"). The content-type rule is a security control, not tidiness (rationale). +- **FAIL IF** the browser-dev Vite server permits cross-origin reads of token-bearing modules or disables its DNS-rebinding Host check. Pinned by `standalone/scripts/dev-agent-browser.test.mjs` (rationale). **Cookie-authenticated iframe pages are unsupported.** Header stripping does not isolate `document.cookie`: proxied scripts still share the loopback hostname's non-HttpOnly cookies across grant ports. This remains a browser-pane isolation gap (rationale). Source of truth: the shared rule and predicates — `isLoopbackHost`, `isOwnOrigin`, -`isForeignOrigin` — in `lib/src/host/loopback-guard.ts`. +`isForeignOrigin` — in `lib/src/host/loopback-guard.ts`; +`startDevVite` in `standalone/scripts/dev-run.mjs`. ## Persisted state diff --git a/docs/specs/security-local.rationale.md b/docs/specs/security-local.rationale.md index 9fd66ea8f..4922fb22c 100644 --- a/docs/specs/security-local.rationale.md +++ b/docs/specs/security-local.rationale.md @@ -130,6 +130,12 @@ is `pty_spawn` with caller-supplied `shell`, `args`, `cwd` and `env`. Why proxy cookies are stripped in both directions. [RFC 6265 §8.5](https://www.rfc-editor.org/rfc/rfc6265#section-8.5) scopes cookies by host, not port. An inbound cookie can therefore belong to another local service, including an HttpOnly credential, rather than the fixed upstream. Forwarding it leaks that credential; forwarding an upstream Set-Cookie lets even a remote HTTP target overwrite loopback cookies. The WebSocket handshake is HTTP too, including a refused upgrade. Parsing that handshake before piping bytes closes the same boundary without filtering WebSocket payloads. +The Vite listener serves modules containing the browser-dev bridge token. Vite's +default CORS policy allows other localhost origins to read those modules +(measured with Vite 8.3.0, 2026-09). Disabling CORS closes that read; the Host +check separately blocks DNS rebinding, where the browser sees a same-origin +request and CORS does not apply. + What header stripping cannot protect. A proxied script runs on `127.0.0.1` and can still read or write non-HttpOnly cookies through `document.cookie`, subject to browser partitioning. The per-grant port isolates origins, not cookie storage. Full isolation needs a separate browser storage context or host namespace; cookie-backed login in the iframe renderer cannot be preserved safely by forwarding ambient cookies. ## Persisted state diff --git a/scripts/loopback-lint.mjs b/scripts/loopback-lint.mjs index efe8be427..3e8bf6475 100644 --- a/scripts/loopback-lint.mjs +++ b/scripts/loopback-lint.mjs @@ -75,7 +75,7 @@ const ALLOWED = { + 'because the modules Vite serves carry the browser-dev bridge token and ' + 'Vite\'s default admits every http://localhost:* origin to read them; and ' + 'allowedHosts: [], the Host check that makes DNS rebinding fail. Dev-only ' - + 'and unbundled — it ships in nothing. The cors pin is checked by ' + + 'and unbundled — it ships in nothing. Both controls are checked by ' + 'standalone/scripts/dev-agent-browser.test.mjs; see ' + 'standalone/scripts/dev-host-guard.mjs for the bridge beside it.', 'vscode-ext/src/agent-browser-host.ts': diff --git a/standalone/scripts/dev-agent-browser.test.mjs b/standalone/scripts/dev-agent-browser.test.mjs index 18a309ae4..f64074e9d 100644 --- a/standalone/scripts/dev-agent-browser.test.mjs +++ b/standalone/scripts/dev-agent-browser.test.mjs @@ -4,6 +4,7 @@ import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; +import { get } from 'node:http'; import { setTimeout as delay } from 'node:timers/promises'; import { sessionForKey } from 'dor-lib-common/agent-browser'; import { cleanEnv, devWorkspace, runner, writeShims } from './dev-fixture.mjs'; @@ -111,6 +112,16 @@ test('parallel worktrees own ports, browser identities and bridges; stopping one const foreign = await fetch(`${run.app}/app.js`, { headers: { origin: 'http://localhost:31337' } }); assert.equal(foreign.status, 200); assert.equal(foreign.headers.get('access-control-allow-origin'), null); + // DNS rebinding looks same-origin to a browser; the Host check must refuse it. + const reboundStatus = await new Promise((resolve, reject) => { + get(`${run.app}/app.js`, { + headers: { host: 'evil.example' }, signal: AbortSignal.timeout(5000), + }, response => { + response.resume(); + resolve(response.statusCode); + }).on('error', reject); + }); + assert.equal(reboundStatus, 403); // HMR must share this listener, even with a Tauri-specific host inherited. await new Promise((resolve, reject) => { const ws = new WebSocket(run.app.replace('http:', 'ws:'), 'vite-ping'); From d23abb327612b30b9558c1d2d2fc8fcb0eb936ac Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:59:47 +0000 Subject: [PATCH 4/5] fix(loopback-lint): reach a `server` block's `proxy` key, and pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- scripts/loopback-lint-selftest.mjs | 3 +++ scripts/loopback-lint.mjs | 23 +++++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/scripts/loopback-lint-selftest.mjs b/scripts/loopback-lint-selftest.mjs index 8828ea187..b7c9427f5 100644 --- a/scripts/loopback-lint-selftest.mjs +++ b/scripts/loopback-lint-selftest.mjs @@ -66,6 +66,9 @@ const FIXTURES = [ // A nested `server` key above `host` — the shape a real `vite.config.ts` has // and the one a first-brace-terminated scan misses. ['vite, server.host', "\nexport const __selftest = { server: { fs: { allow: ['.'] }, host: '127.0.0.1' } };\n"], + // `proxy` nests a target object per route — two levels, and the deepest shape + // this form reaches. + ['vite, server.host', "\nexport const __selftest = { server: { proxy: { '/api': { target: 'http://up' } }, host: '127.0.0.1' } };\n"], ]; const selftest = makeSelftest('loopback-lint.mjs', '.loopback-selftest.bak'); diff --git a/scripts/loopback-lint.mjs b/scripts/loopback-lint.mjs index 3e8bf6475..39a8960f9 100644 --- a/scripts/loopback-lint.mjs +++ b/scripts/loopback-lint.mjs @@ -106,12 +106,14 @@ const LOOPBACK = "['\"](?:127\\.0\\.0\\.1|localhost)['\"]"; // branch that can rot alone, which is how `WebSocket\.Relay` sat here matching // nothing while the `WebSocketServer` branch beside it kept the lint green. const WS_NEW = '\\bnew\\s+WebSocket\\.?Server\\(\\s*\\{[^}]*?'; -// Keys of one options object, allowing one level of nested object between the -// opening brace and the key being looked for. `[^}]*?` stops at the first `}`, -// so a form that uses it only matches while its key precedes every nested -// object — fine for a call's flat options, wrong for a `vite.config.ts` `server` -// block, where a nested key above `host` is the common shape. -const ONE_NESTED = '(?:[^{}]|\\{[^{}]*\\})*?'; +// Keys of one options object, allowing nested objects up to two levels deep +// between the opening brace and the key being looked for. `[^}]*?` stops at the +// first `}`, so a form that uses it only matches while its key precedes every +// nested object — fine for a call's flat options, wrong for a `vite.config.ts` +// `server` block, where a nested key above `host` is the common shape. Two +// levels is as deep as a `server` key goes; deeper is the regex ceiling this +// file's header already disclaims. +const NESTED_KEYS = '(?:[^{}]|\\{(?:[^{}]|\\{[^{}]*\\})*\\})*?'; /** * Every bind form `LISTEN_RE` looks for, one entry per alternative — the @@ -130,10 +132,11 @@ const BIND_FORMS = [ // server: { host } })` then an argument-less `listen()`, so neither `.listen` // 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. `ONE_NESTED`, not `[^}]*?`, - // because `fs`, `hmr`, `proxy`, `headers` and `watch` are ordinary `server` - // keys and any of them written above `host` would otherwise end the scan. - { label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{${ONE_NESTED}host\\s*:\\s*${LOOPBACK}` }, + // Storybook's builder — passes to the same server. `NESTED_KEYS`, not + // `[^}]*?`, because `fs`, `hmr`, `headers` and `watch` (one level) and + // `proxy` (two: a target object per route) are ordinary `server` keys, and + // any of them written above `host` would otherwise end the scan. + { label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{${NESTED_KEYS}host\\s*:\\s*${LOOPBACK}` }, ]; const LISTEN_RE = new RegExp(BIND_FORMS.map((form) => form.re).join('|'), 'gs'); From 3c90b9f09246467b441cf1ef5097e4be5cc9df2d Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:12:25 +0000 Subject: [PATCH 5/5] docs(loopback-lint): state the server-block depth ceiling instead of denying it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- scripts/loopback-lint-selftest.mjs | 5 +++-- scripts/loopback-lint.mjs | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/scripts/loopback-lint-selftest.mjs b/scripts/loopback-lint-selftest.mjs index b7c9427f5..b5bf21061 100644 --- a/scripts/loopback-lint-selftest.mjs +++ b/scripts/loopback-lint-selftest.mjs @@ -66,8 +66,9 @@ const FIXTURES = [ // A nested `server` key above `host` — the shape a real `vite.config.ts` has // and the one a first-brace-terminated scan misses. ['vite, server.host', "\nexport const __selftest = { server: { fs: { allow: ['.'] }, host: '127.0.0.1' } };\n"], - // `proxy` nests a target object per route — two levels, and the deepest shape - // this form reaches. + // `proxy` nests a target object per route — two levels, the deepest the form + // reaches. A route option that nests again (`headers`, `configure`) is past + // the ceiling `scripts/loopback-lint.mjs` states. ['vite, server.host', "\nexport const __selftest = { server: { proxy: { '/api': { target: 'http://up' } }, host: '127.0.0.1' } };\n"], ]; diff --git a/scripts/loopback-lint.mjs b/scripts/loopback-lint.mjs index 39a8960f9..a5d0ef9e8 100644 --- a/scripts/loopback-lint.mjs +++ b/scripts/loopback-lint.mjs @@ -29,6 +29,12 @@ * - It knows the bind forms listed at BIND_FORMS and no others. A library * nobody has added yet spells its bind some way this file has never seen, * so adding a server dependency means adding its spelling here. + * - The `server`-block form scans past nested objects two levels deep, which + * reaches `fs`, `hmr`, `headers`, `watch` and a `proxy` route object. It + * stops there because no depth is the last one: a `proxy` route's own + * options nest again (`headers`, `cookieDomainRewrite`), and `configure` + * takes a function whose body carries braces of its own. A `host` written + * below one of those is a miss, and the audit is what covers it. * - Outside `ws`, it matches only an explicit loopback host. A listener that * binds every interface (`.listen(port)` with no host) is a different and * larger problem, and `relay/` does it deliberately from config, so @@ -111,8 +117,8 @@ const WS_NEW = '\\bnew\\s+WebSocket\\.?Server\\(\\s*\\{[^}]*?'; // first `}`, so a form that uses it only matches while its key precedes every // nested object — fine for a call's flat options, wrong for a `vite.config.ts` // `server` block, where a nested key above `host` is the common shape. Two -// levels is as deep as a `server` key goes; deeper is the regex ceiling this -// file's header already disclaims. +// levels is where this stops, not where `server` keys stop — the header states +// what that leaves out. const NESTED_KEYS = '(?:[^{}]|\\{(?:[^{}]|\\{[^{}]*\\})*\\})*?'; /** @@ -133,9 +139,10 @@ const BIND_FORMS = [ // 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. `NESTED_KEYS`, not - // `[^}]*?`, because `fs`, `hmr`, `headers` and `watch` (one level) and - // `proxy` (two: a target object per route) are ordinary `server` keys, and - // any of them written above `host` would otherwise end the scan. + // `[^}]*?`, because `fs`, `hmr`, `headers` and `watch` (one level) and a + // `proxy` route object (two) are ordinary `server` keys, and any of them + // written above `host` would otherwise end the scan. A route's own nested + // options go deeper than the scan does — see this file's header. { label: 'vite, server.host', re: `\\bserver\\s*:\\s*\\{${NESTED_KEYS}host\\s*:\\s*${LOOPBACK}` }, ];