mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-13 06:36:26 +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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user