Live: lock down the local server against same-machine token theft (#304)

Two defense-in-depth layers close the P1 in issue #304, where any browser
tab on the machine could fetch /live.js, extract the embedded token, and
drive every token-gated route.

1. Loopback-restricted CORS. The shared handler replaced its wildcard
   `Access-Control-Allow-Origin: *` with reflection gated on a strict
   isLoopbackOrigin() that URL-parses the Origin (so localhost.evil.com and
   127.0.0.1.evil.com fail) and accepts only http/https on localhost,
   127.0.0.1, or [::1]. Reflection always pairs with `Vary: Origin` so a
   cache never hands one origin's authorized response to another. Remote
   origins get no ACAO header; origin-less callers (script tags, curl, the
   agent's own fetches) are unaffected.

2. Token-gated /live.js. The handler now 401s unless `?token=` matches
   state.token, so the bundle (which embeds the token) is no longer served
   to unauthenticated local pages. The injected <script src> carries the
   token: live.mjs passes --token to live-inject.mjs, which threads it
   through every injection path (HTML/JSX tag, Nuxt plugin, SvelteKit root
   component) via a shared buildLiveScriptSrc(). The token stays optional in
   live-inject so static fixture tests keep their bare src.

Tests: new live-server integration cases for the 401 gate, remote-origin
denial, loopback reflection + Vary, and token-guarded routes under a
loopback Origin; e2e session harness now injects with the token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-22 21:59:28 -07:00
co-authored by Claude Fable 5
parent da2982ab95
commit 3f9fccdfd0
6 changed files with 148 additions and 27 deletions
+7 -3
View File
@@ -117,10 +117,14 @@ export function stopLiveServer(tmp) {
} catch { /* already gone */ }
}
export function runInject(tmp, port) {
export function runInject(tmp, port, token) {
const out = execFileSync(
process.execPath,
[join(SCRIPTS_DIR, 'live-inject.mjs'), '--port', String(port)],
[
join(SCRIPTS_DIR, 'live-inject.mjs'),
'--port', String(port),
...(token ? ['--token', String(token)] : []),
],
{
cwd: tmp,
encoding: 'utf-8',
@@ -296,7 +300,7 @@ export async function bootFixtureSession({
const injectStartedAt = Date.now();
trace('setup.inject.start', { fixture: name });
log(`live-inject --port ${live.port}`);
const injectResult = runInject(tmp, live.port);
const injectResult = runInject(tmp, live.port, live.token);
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
trace('setup.inject.end', { fixture: name, files: injectResult.files || injectResult.pageFiles || [] });
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
+60 -2
View File
@@ -167,7 +167,7 @@ describe('live-server integration', () => {
// rather than an inline copy, so the server must serialize the canonical
// vocabulary into /live.js (next to the token/port).
const { LIVE_COMMANDS } = await import('../skill/scripts/live/vocabulary.mjs');
const body = await (await fetch(`http://localhost:${server.port}/live.js`)).text();
const body = await (await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`)).text();
assert.match(body, /window\.__IMPECCABLE_VOCAB__\s*=/);
const injected = JSON.parse(body.match(/window\.__IMPECCABLE_VOCAB__\s*=\s*(\[.*?\]);/s)[1]);
assert.deepEqual(injected, LIVE_COMMANDS);
@@ -259,7 +259,7 @@ describe('live-server integration', () => {
});
it('/live.js serves script with token injected', async () => {
const res = await fetch(`http://localhost:${server.port}/live.js`);
const res = await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`);
assert.equal(res.status, 200);
assert.equal(res.headers.get('content-type'), 'application/javascript');
const text = await res.text();
@@ -302,6 +302,64 @@ describe('live-server integration', () => {
);
});
it('/live.js returns 401 without the token and 200 with it', async () => {
const noToken = await fetch(`http://localhost:${server.port}/live.js`);
assert.equal(noToken.status, 401);
const wrongToken = await fetch(`http://localhost:${server.port}/live.js?token=not-the-token`);
assert.equal(wrongToken.status, 401);
const ok = await fetch(`http://localhost:${server.port}/live.js?token=${server.token}`);
assert.equal(ok.status, 200);
const body = await ok.text();
assert.ok(body.includes('__IMPECCABLE_LIVE_INIT__'), 'authorized /live.js returns the assembled bundle');
});
it('CORS: a remote origin gets no Access-Control-Allow-Origin on any route', async () => {
const evil = 'https://evil.example';
for (const path of ['/health', `/live.js?token=${server.token}`, `/status?token=${server.token}`]) {
const res = await fetch(`http://localhost:${server.port}${path}`, { headers: { Origin: evil } });
assert.equal(
res.headers.get('access-control-allow-origin'),
null,
`remote origin must not be reflected on ${path}`,
);
}
// Preflight from a remote origin is likewise unauthorized to read.
const preflight = await fetch(`http://localhost:${server.port}/poll`, {
method: 'OPTIONS',
headers: { Origin: evil, 'Access-Control-Request-Method': 'POST' },
});
assert.equal(preflight.headers.get('access-control-allow-origin'), null);
});
it('CORS: a loopback origin is reflected with Vary: Origin', async () => {
for (const origin of [
`http://localhost:${server.port}`,
'http://127.0.0.1:5173',
'http://[::1]:5173',
]) {
const res = await fetch(`http://localhost:${server.port}/health`, { headers: { Origin: origin } });
assert.equal(res.headers.get('access-control-allow-origin'), origin, `reflect ${origin}`);
const vary = res.headers.get('vary') || '';
assert.ok(/\bOrigin\b/i.test(vary), `Vary: Origin present for ${origin}, got "${vary}"`);
}
// A hostname that merely extends "localhost" must not pass the loopback test.
const spoof = await fetch(`http://localhost:${server.port}/health`, {
headers: { Origin: 'http://localhost.evil.com' },
});
assert.equal(spoof.headers.get('access-control-allow-origin'), null, 'localhost.evil.com must not be reflected');
});
it('token-guarded routes still work with a loopback Origin header', async () => {
const origin = `http://localhost:${server.port}`;
const res = await fetch(`http://localhost:${server.port}/status?token=${server.token}`, {
headers: { Origin: origin },
});
assert.equal(res.status, 200);
assert.equal(res.headers.get('access-control-allow-origin'), origin);
});
it('/design-system.json reads DESIGN.md plus .impeccable/design.json', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-design-system-'));
let designServer;