mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 00:56:30 +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
@@ -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