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
+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(() => {