mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
co-authored by
Claude Fable 5
parent
da2982ab95
commit
3f9fccdfd0
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user