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
+34 -14
View File
@@ -8,9 +8,14 @@
* with zero LLM involvement.
*
* Usage:
* node live-inject.mjs --port PORT # Insert the live script tag
* node live-inject.mjs --remove # Remove the live script tag
* node live-inject.mjs --check # Check whether live config exists
* node live-inject.mjs --port PORT [--token TOKEN] # Insert the live script tag
* node live-inject.mjs --remove # Remove the live script tag
* node live-inject.mjs --check # Check whether live config exists
*
* When --token is supplied, it is appended to the /live.js src as `?token=...`
* so the server's token-gated /live.js handler will serve the bundle. Omitting
* the token yields a bare `/live.js` src (legacy behavior; the server returns
* 401 for it under the current gate).
*/
import fs from 'node:fs';
@@ -162,18 +167,22 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1);
}
// Optional server token: appended to the /live.js src so the token-gated
// /live.js handler authorizes the browser fetch. `live.mjs` always passes it.
const tokenIdx = args.indexOf('--token');
const token = tokenIdx !== -1 ? args[tokenIdx + 1] : undefined;
const gitIgnore = ensureLiveGitIgnores(
process.cwd(),
nuxt ? [nuxt.pluginFile] : [],
);
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, token, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
if (nuxt) {
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, project: nuxt });
const adapterResult = applyNuxtLiveAdapter({ cwd: process.cwd(), port, token, project: nuxt });
console.log(JSON.stringify({
ok: !adapterResult.error,
port,
@@ -190,7 +199,7 @@ Output (JSON):
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
const withTag = insertTag(withoutOld, config, port, relFile);
const withTag = insertTag(withoutOld, config, port, relFile, token);
if (withTag === withoutOld) {
return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
}
@@ -276,9 +285,9 @@ export function detectNuxtProject(cwd = process.cwd()) {
return { configFile, appDir, pluginFile };
}
export function buildNuxtPlugin(port) {
export function buildNuxtPlugin(port, token) {
return `/* ${NUXT_PLUGIN_MARKER} */
const liveSrc = 'http://localhost:${port}/live.js';
const liveSrc = '${buildLiveScriptSrc(port, token)}';
const liveSelector = 'script[data-impeccable-live-nuxt]';
export default defineNuxtPlugin(() => {
@@ -303,7 +312,7 @@ export default defineNuxtPlugin(() => {
`;
}
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, project = detectNuxtProject(cwd) }) {
export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, token, project = detectNuxtProject(cwd) }) {
if (!project) return { error: 'nuxt_not_detected' };
const absFile = path.join(cwd, project.pluginFile);
const existing = fs.existsSync(absFile) ? fs.readFileSync(absFile, 'utf-8') : null;
@@ -315,7 +324,7 @@ export function applyNuxtLiveAdapter({ cwd = process.cwd(), port, project = dete
};
}
const content = buildNuxtPlugin(port);
const content = buildNuxtPlugin(port, token);
fs.mkdirSync(path.dirname(absFile), { recursive: true });
if (content !== existing) fs.writeFileSync(absFile, content, 'utf-8');
return {
@@ -497,7 +506,18 @@ function validateConfig(cfg) {
function commentOpen(syntax) { return syntax === 'jsx' ? '{/*' : '<!--'; }
function commentClose(syntax) { return syntax === 'jsx' ? '*/}' : '-->'; }
function buildTagBlock(syntax, port, filePath) {
/**
* Build the /live.js src the browser loads. When a token is supplied it rides
* as a `?token=...` query param so the server's token-gated /live.js handler
* authorizes the fetch. Shared by every injection path (HTML/JSX script tag,
* the Nuxt plugin, the SvelteKit root component) so they stay in sync.
*/
export function buildLiveScriptSrc(port, token) {
const base = 'http://localhost:' + port + '/live.js';
return token ? base + '?token=' + encodeURIComponent(token) : base;
}
function buildTagBlock(syntax, port, filePath, token) {
const open = commentOpen(syntax);
const close = commentClose(syntax);
// Astro processes <script> tags by default and rewrites src to its own
@@ -506,7 +526,7 @@ function buildTagBlock(syntax, port, filePath) {
const scriptAttrs = isAstro ? 'is:inline ' : '';
return (
open + ' ' + MARKER_OPEN_TEXT + ' ' + close + '\n' +
'<script ' + scriptAttrs + 'src="http://localhost:' + port + '/live.js"></script>\n' +
'<script ' + scriptAttrs + 'src="' + buildLiveScriptSrc(port, token) + '"></script>\n' +
open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
);
}
@@ -528,9 +548,9 @@ function readLineEndingAt(content, index) {
return '';
}
function insertTag(content, config, port, filePath) {
function insertTag(content, config, port, filePath, token) {
const lineEnding = detectLineEnding(content);
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding);
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding);
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
// belong at the end, and the same literal can appear earlier in code blocks
// within rendered documentation pages.
+34 -1
View File
@@ -624,13 +624,37 @@ function statOrNull(filePath) {
try { return fs.statSync(filePath); } catch { return null; }
}
// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a
// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com`
// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback.
function isLoopbackOrigin(origin) {
if (typeof origin !== 'string' || origin.length === 0) return false;
let parsed;
try { parsed = new URL(origin); } catch { return false; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false;
const host = parsed.hostname.toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
}
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, liveScriptParts }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
// loopback origin, always paired with `Vary: Origin` so an intermediary
// cache never serves a response authorized for one origin to another. A
// remote page (e.g. https://evil.example probing the port from a tab open
// on the same machine) gets no Access-Control-Allow-Origin, so its
// JS-initiated fetch cannot read any response. Requests with no Origin
// header (script tags, curl, the agent's own fetches) are not subject to
// CORS and keep working; no ACAO header is needed for them.
const origin = req.headers.origin;
if (origin && isLoopbackOrigin(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
@@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
// --- Scripts ---
if (p === '/live.js') {
// Token-gated: the script body embeds state.token, which unlocks every
// token-guarded route. Serving it unauthenticated let any local page read
// the token and drive the session. The injected <script src> carries
// `?token=...` (see live-inject.mjs). A missing/wrong token → 401.
if (url.searchParams.get('token') !== state.token) {
res.writeHead(401, { 'Content-Type': 'text/plain' });
res.end('Unauthorized');
return;
}
// Re-read from disk each request so edits to live-browser.js land on
// the next tab reload. No-store headers prevent browser caching across
// sessions — during iteration, a cached old script silently breaks
+5 -1
View File
@@ -112,7 +112,11 @@ The agent should then:
}
// 3. Inject the script tag at the current port
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
const injectOut = runScript(
'live-inject.mjs',
['--port', String(serverInfo.port), '--token', String(serverInfo.token)],
{ cwd: activeCwd },
);
const injectResult = safeParse(injectOut);
if (!injectResult || !injectResult.ok) {
console.log(JSON.stringify({
+8 -6
View File
@@ -36,14 +36,14 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, token, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
ensureSvelteLiveRootComponent(cwd, Number(port), token);
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
@@ -136,18 +136,20 @@ export function unpatchSvelteLayout(content) {
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
export function ensureSvelteLiveRootComponent(cwd, port, token) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
fs.writeFileSync(file, buildSvelteLiveRootComponent(port, token), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
export function buildSvelteLiveRootComponent(port, token) {
const liveUrl = 'http://localhost:' + Number(port) + '/live.js'
+ (token ? '?token=' + encodeURIComponent(token) : '');
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const LIVE_URL = '${liveUrl}';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
+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;