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