Skip to content
8 changes: 5 additions & 3 deletions .github/audit/application-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ harnesses that already exercise this are
— read what they *do not* cover, and say so.

For `## Loopback Listeners`, read `lib/src/host/loopback-guard.ts` first — it
states the rule — then each listener it names. Derive the set of listeners by
searching the shipped trees yourself; the section's own list is a description of
today's tree, not the scope.
states the rule — then run `node scripts/loopback-lint.mjs`. Inspect every non-test listener
it prints; test listeners and self-test fixtures need no further investigation.
The lint scans all tracked JavaScript and TypeScript. Search the same files for
`createServer`, `.listen(`, `serve(` and `WebSocket` too, because a new API or a
host built at runtime can escape its patterns.

For the rest of `docs/specs/security-local.md`, read each section's owner first
— `docs/specs/terminal-escapes.md`, `docs/specs/dor-browser.md`,
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/security-local.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ ancestor chain the webview supplies with each proxy URL request.** `'self'` allo
same-grant nesting; any foreign ancestor fails. No request header identifies the
embedder, and the browser checks the whole chain (rationale).

- **FAIL IF** any loopback HTTP or WebSocket listener grants an unrecognized caller a privilege it could not obtain by reaching the upstream directly. Refusing the request is one way; the iframe proxy's *admits all, vouches for none, names its embedder* is another, and is not a violation (rationale). `scripts/loopback-lint.mjs` (`pnpm test`) makes the cheap half deterministic — a new loopback bind that does not reference a guard module fails the build — but only in the bind forms its `BIND_FORMS` lists, each pinned by a fixture in `scripts/loopback-lint-selftest.mjs`, which goes red on a form that has none. **Adding a server dependency means adding its bind spelling there**; a host built at runtime is invisible to a regex in any spelling. The lint sees only a guard reference, not whether every request calls it, so this bullet is still read by hand. Derive the set by searching the shipped trees for `createServer`, `.listen(`, `serve(` and `WebSocket` rather than trusting this list. Today the set is three: the iframe proxy (`lib/src/host/iframe-proxy.ts`), the VS Code agent-browser stream relay (`vscode-ext/src/agent-browser-host.ts`), and the browser-dev bridge (`standalone/scripts/dev-agent-browser.mjs`). A Unix-domain socket or named pipe is not in scope — no browser can reach one — which is why the `dor` control channel is bounded by socket permissions instead.
- **FAIL IF** any loopback HTTP or WebSocket listener grants an unrecognized caller a privilege it could not obtain by reaching the upstream directly. Refusing the request is one way; the iframe proxy's *admits all, vouches for none, names its embedder* is another, and is not a violation (rationale). `scripts/loopback-lint.mjs` (`pnpm test`) scans all tracked JavaScript and TypeScript and prints every bind it recognizes, with tests and its own fixtures separate from non-test listeners. A new non-test listener without a guard reference fails the build, but the lint cannot tell whether every request calls that guard. **Adding a server dependency means adding its bind spelling to `BIND_FORMS`**, each form pinned by `scripts/loopback-lint-selftest.mjs`; a host built at runtime is invisible to a regex in any spelling. Search the same files for `createServer`, `.listen(`, `serve(` and `WebSocket` to cover that ceiling. The Relay is separate: no foreign browser origin may drive its API, whatever interface it binds (`docs/specs/security-remote.md` -> "Cross-origin access"). A Unix-domain socket or named pipe is out of scope — no browser can reach one — which is why the `dor` control channel is bounded by socket permissions instead.
- **FAIL IF** the iframe proxy rewrites `Origin` to the upstream's own origin for a caller whose inbound `Origin` is not the proxy's own — in `handleRequest` **or** `handleUpgrade`. A foreign `Origin` must be forwarded untouched rather than blocked, so the upstream sees the truth and applies its own policy (rationale).
- **FAIL IF** the iframe proxy forwards `Cookie` upstream or `Set-Cookie` downstream on HTTP or WebSocket handshakes, including refused upgrades. Pinned by `lib/src/host/iframe-proxy.test.ts` (rationale).
- **FAIL IF** the iframe proxy stops checking that `Host` names its own grant port, on either path. Its per-grant ephemeral port and one-fixed-upstream binding are real mitigations but neither is a secret, so the `Host` check is what makes DNS rebinding fail.
Expand Down
53 changes: 42 additions & 11 deletions scripts/lint-kit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,29 @@ export function trackedFiles() {
return trackedCache;
}

/** Run one of the lints in a child, so a thrown rule cannot pass as a failure. */
export function lintFails(script) {
/** Run one of the lints in a child and capture the result. */
export function runLint(script) {
try {
execFileSync('node', [join(repoRoot, 'scripts', script)], { stdio: 'pipe' });
return false;
} catch {
return true;
return {
ok: true,
stdout: execFileSync('node', [join(repoRoot, 'scripts', script)], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}),
};
} catch (error) {
return {
ok: false,
stdout: typeof error?.stdout === 'string' ? error.stdout : '',
};
}
}

/** Run one of the lints in a child, so a thrown rule cannot pass as a failure. */
export function lintFails(script) {
return !runLint(script).ok;
}

/**
* A self-test run: mutate a file, require the lint to go red, restore the file.
*
Expand All @@ -107,16 +120,18 @@ export function lintFails(script) {
export function makeSelftest(script, backupSuffix) {
const weak = [];
let held = 0;
const appendTo = (text) => (path) =>
writeFileSync(path, (existsSync(path) ? readFileSync(path, 'utf8') : '') + text);

/** Edit `relative` with `mutate`, run the lint, restore, and record. */
function withMutation(relative, mutate, label) {
function runMutation(relative, mutate, check, label) {
const path = join(repoRoot, relative);
const existed = existsSync(path);
const backup = `${path}${backupSuffix}`;
if (existed) copyFileSync(path, backup);
try {
mutate(path);
if (lintFails(script)) held += 1;
if (check()) held += 1;
else weak.push(label);
} finally {
if (existed) {
Expand All @@ -130,12 +145,28 @@ export function makeSelftest(script, backupSuffix) {

return {
weak,
withMutation,
/** Apply any mutation and require the lint to fail. */
withMutation(relative, mutate, label) {
runMutation(relative, mutate, () => lintFails(script), label);
},
/** Append `text` to `relative` — the shape every "put it back" case takes. */
withAppended(relative, text, label) {
withMutation(
runMutation(
relative,
appendTo(text),
() => lintFails(script),
label,
);
},
/** Append `text`, require the lint to pass, and find `expected` in its output. */
withAppendedOutput(relative, text, expected, label) {
Comment thread
dormouse-bot marked this conversation as resolved.
runMutation(
relative,
(path) => writeFileSync(path, (existsSync(path) ? readFileSync(path, 'utf8') : '') + text),
appendTo(text),
() => {
const result = runLint(script);
return result.ok && result.stdout.includes(expected);
},
label,
);
},
Expand Down
40 changes: 36 additions & 4 deletions scripts/loopback-lint-selftest.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env node
/**
* Proves `loopback-lint.mjs` is load-bearing: add one unguarded loopback
* listener, in each bind form the tree can express, and require the lint to go
* red.
* listener, in each bind form the tree can express and in a source extension the
* other fixtures do not reach, and require the lint to go red. Also add one to a
* test file and require the lint to report it separately without failing.
*
* Why this exists rather than trusting a green run: the lint's whole job is to
* *find* a bind, and the characteristic failure of a finding check is passing
Expand Down Expand Up @@ -35,6 +36,15 @@ const LINT = 'scripts/loopback-lint.mjs';
* between the edit and the restore.
*/
const TARGET = 'standalone/scripts/clean-dev-sidecar.mjs';
const TEST_TARGET = 'lib/src/lib/feature-flags.test.ts';

/**
* A tracked file in a source extension `TARGET` does not exercise. `SOURCE_EXT`
* decides which files are read at all, so a narrowed extension list exempts a
* whole language variant silently — the failure the bind-form fixtures cannot
* see, because they only ever appear in a `.mjs` file.
*/
const EXT_TARGET = 'vscode-ext/vitest.smoketest.config.mts';

/**
* A fixture per bind form, keyed by the label the lint's own `BIND_FORMS`
Expand Down Expand Up @@ -64,6 +74,25 @@ for (const [name, source] of FIXTURES) {
);
}

// `.mts` and `.cts` are TypeScript too, and the spec, the audit prompt and this
// lint's own header all say it scans every tracked JavaScript and TypeScript
// file. Without this case that claim rests on an extension list nothing reads.
selftest.withAppended(
EXT_TARGET,
FIXTURES[0][1],
`${EXT_TARGET}\n adding this bind stays green — SOURCE_EXT in scripts/loopback-lint.mjs skips this extension`,
);

// Test listeners belong in the live inventory but do not need a product guard.
// Mutate a test that has no loopback bind of its own: this must stay green and
// print the path under the test heading.
selftest.withAppendedOutput(
TEST_TARGET,
FIXTURES[0][1],
`${TEST_TARGET}:`,
`${TEST_TARGET}\n a test listener is not reported separately by loopback-lint`,
);

// Every alternative the lint declares needs a fixture above, or it is a claim
// nothing checks — which is how a `WebSocket.Relay` branch that matched no real
// API rode along beside a working one. Read as text because `loopback-lint.mjs`
Expand All @@ -87,6 +116,9 @@ selftest.finish(
'Each fixture adds one unguarded loopback listener. A case that stays green means\n'
+ 'LISTEN_RE in scripts/loopback-lint.mjs does not match that bind form — and a form\n'
+ 'reported with no fixture is one nothing has ever matched. Either way the\n'
+ '"a new loopback bind that does not reference a guard module fails the build"\n'
+ 'clause in docs/specs/security-local.md -> "Loopback Listeners" is not true of it.',
+ '"A new non-test listener without a guard reference fails the build"\n'
+ 'clause in docs/specs/security-local.md -> "Loopback Listeners" is not true of it.\n'
+ 'A green extension case means SOURCE_EXT does not read that file type, so the\n'
+ '"all tracked JavaScript and TypeScript" scope is narrower than it claims.\n'
+ 'The test case must stay green and appear under the test heading.',
);
84 changes: 50 additions & 34 deletions scripts/loopback-lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,18 @@
*
* Why this exists: a loopback bind is not an access control — the attacker that
* matters is a page open in the user's own browser, which reaches `127.0.0.1`
* as easily as our webview does, and an ephemeral port is not a secret. Two of
* the three listeners we ship got that wrong at some point, and both were found
* as easily as our webview does, and an ephemeral port is not a secret. Two
* listeners got that wrong at some point, and both were found
* by an LLM audit rather than by CI. The audit is thorough but probabilistic;
* this makes the cheap half of the rule deterministic, so a *fourth* listener
* this makes the cheap half of the rule deterministic, so a new listener
* fails a build instead of waiting for the next audit to notice it.
*
* The check: any non-test source file that binds a TCP listener to loopback
* must reference one of the guard modules — `lib/src/host/loopback-guard.ts`
* for shipped code, `standalone/scripts/dev-host-guard.mjs` for the dev
* harness — or sit on ALLOWED below with a stated reason.
* The check scans every tracked JavaScript and TypeScript file and prints every
* bind it recognizes. Test files and this lint's own fixtures are reported
* separately; every other file that binds a TCP listener to loopback must
* reference one of the guard modules — `lib/src/host/loopback-guard.ts` for
* shipped code, `standalone/scripts/dev-host-guard.mjs` for the dev harness —
* or sit on ALLOWED below with a stated reason.
*
* `scripts/loopback-lint-selftest.mjs` proves each bind form is load-bearing by
* adding one and requiring this lint to go red, and goes red itself on a form in
Expand All @@ -37,8 +39,8 @@
* - Unix-domain sockets and named pipes are out of scope by design: no
* browser can reach one, which is why the `dor` control channel is bounded
* by socket permissions instead.
* - Test files are skipped. A fixture that stands up a loopback server is not
* a product listener.
* - Test files and this lint's own fixtures are reported, but need no guard.
* A fixture that stands up a loopback server is not a product listener.
*
* Scans `git ls-files`, not the working tree. Build output is exactly what must
* not be scanned: `standalone/sidecar/iframe-proxy.cjs` is a bundle of the very
Expand All @@ -47,12 +49,12 @@
* depend on whether someone had run a build.
*
* Checks:
* 1. Every matching listener references a guard module or is allowlisted.
* 1. Every matching non-test listener references a guard module or is allowlisted.
* 2. Every ALLOWED entry still names a real file that still matches — a stale
* allowlist silently exempts nothing, or worse, the next file to reuse
* that path.
* 3. Finding no listeners at all is a failure, not a pass: it means the bind
* shape moved and this lint has quietly stopped checking anything.
* 3. Finding no non-test listeners at all is a failure, not a pass: it means
* the bind shape moved and this lint has quietly stopped checking anything.
*/
import { readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
Expand Down Expand Up @@ -111,39 +113,48 @@ const BIND_FORMS = [
{ label: 'ws, port only', re: `${WS_NEW}port\\s*:` },
];

const LISTEN_RE = new RegExp(BIND_FORMS.map((form) => form.re).join('|'), 's');
const LISTEN_RE = new RegExp(BIND_FORMS.map((form) => form.re).join('|'), 'gs');

const SOURCE_EXT = /\.(?:ts|tsx|js|jsx|mjs|cjs)$/;
const SOURCE_EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
const IS_TEST = /(?:\.test\.|\.spec\.|[\\/]tests?[\\/])/;
// These two files spell out the pattern this lint looks for — one documenting
// it, one adding each form to prove it is load-bearing — so they match
// themselves. Excluded by name rather than exempted by a guard reference: an
// exemption would leave them counted as listeners, which is a count nobody can
// read.
// it, one adding each form to prove it is load-bearing — so they can match
// themselves. Report those matches as fixtures rather than listeners.
const SELF = new Set([
'scripts/loopback-lint.mjs',
'scripts/loopback-lint-selftest.mjs',
]);

/** Every tracked, non-test source file, as repo-relative POSIX paths. */
/** Every tracked JavaScript and TypeScript file, as a repo-relative POSIX path. */
function sourceFiles() {
return trackedFiles().filter((rel) => (
!SELF.has(rel) && SOURCE_EXT.test(rel) && !IS_TEST.test(rel)
));
return trackedFiles().filter((rel) => SOURCE_EXT.test(rel));
}

const problems = [];
const listeners = [];
const nonTestListeners = [];
const testListeners = [];
const selfTestFixtures = [];
const matchedAllowed = new Set();

for (const rel of sourceFiles()) {
// A tracked path can still be absent mid-rebase or in a sparse checkout.
if (!existsSync(join(ROOT, rel))) continue;
const text = readFileSync(join(ROOT, rel), 'utf-8');
const match = LISTEN_RE.exec(text);
if (!match) continue;
const line = text.slice(0, match.index).split('\n').length;
listeners.push(rel);
const matches = [...text.matchAll(LISTEN_RE)];
if (matches.length === 0) continue;
const sites = matches.map((match) => ({
rel,
line: text.slice(0, match.index).split('\n').length,
}));
if (SELF.has(rel)) {
selfTestFixtures.push(...sites);
continue;
}
if (IS_TEST.test(rel)) {
testListeners.push(...sites);
continue;
}
nonTestListeners.push(...sites);

if (rel in ALLOWED) {
matchedAllowed.add(rel);
Expand All @@ -152,7 +163,7 @@ for (const rel of sourceFiles()) {
if (GUARD_REFERENCES.some((g) => text.includes(g))) continue;

problems.push(
`${rel}:${line}: binds a loopback listener without referencing a guard module.\n`
`${rel}:${sites[0].line}: binds a loopback listener without referencing a guard module.\n`
+ ' A loopback bind is not an access control: a page in the user\'s own browser\n'
+ ' reaches 127.0.0.1 too, and the port is not a secret. Check Host and\n'
+ ' authenticate the caller — see lib/src/host/loopback-guard.ts and\n'
Expand All @@ -172,9 +183,9 @@ for (const rel of Object.keys(ALLOWED)) {
}

// --- Check 3: the pattern still finds something ------------------------------
if (listeners.length === 0) {
if (nonTestListeners.length === 0) {
problems.push(
'no loopback listeners matched at all — the bind shape has moved and LISTEN_RE\n'
'no non-test loopback listeners matched at all — the bind shape has moved and LISTEN_RE\n'
+ ' in scripts/loopback-lint.mjs no longer matches anything. This lint is not\n'
+ ' passing, it has stopped looking.',
);
Expand All @@ -187,7 +198,12 @@ if (problems.length > 0) {
console.error('\nThe rule is in docs/specs/security-local.md ("Loopback Listeners").');
process.exit(1);
}
console.log(
`loopback-lint: OK (${listeners.length} loopback listeners, `
+ `${Object.keys(ALLOWED).length} allowlisted)`,
);
console.log(`loopback-lint: OK (${Object.keys(ALLOWED).length} allowlisted)\n`);
for (const [label, sites] of [
['non-test listeners', nonTestListeners],
['test listeners (no audit needed)', testListeners],
['self-test fixtures', selfTestFixtures],
]) {
console.log(` ${label}:`);
for (const { rel, line } of sites) console.log(` ${rel}:${line}`);
}