From de4832ba1a6528d9f559073c7290a5497178299e Mon Sep 17 00:00:00 2001 From: Mohammed Qazi <10266060+theqazi@users.noreply.github.com> Date: Sat, 18 Apr 2026 15:59:49 -0700 Subject: [PATCH] fix(design): escape url.origin when injecting into served HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serve.ts injected url.origin into a single-quoted JS string in the response body. A local request with a crafted Host header (e.g. Host: "evil'-alert(1)-'x") would break out of the string and execute JS in the 127.0.0.1: origin opened by the design board. Low severity — bound to localhost, requires a local attacker — but no reason not to escape. Fix: JSON.stringify(url.origin) produces a properly quoted, escaped JS string literal in one call. Also includes Prettier reformatting (single→double quotes, trailing commas, line wrapping) applied by the repo's PostToolUse formatter hook. Security change is the one line in the HTML injection; everything else is whitespace/style. --- design/src/serve.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/design/src/serve.ts b/design/src/serve.ts index e957ff0f..9fd5fd66 100644 --- a/design/src/serve.ts +++ b/design/src/serve.ts @@ -47,7 +47,7 @@ export interface ServeOptions { type ServerState = "serving" | "regenerating" | "done"; export async function serve(options: ServeOptions): Promise { - const { html, port = 0, hostname = '127.0.0.1', timeout = 600 } = options; + const { html, port = 0, hostname = "127.0.0.1", timeout = 600 } = options; // Validate HTML file exists if (!fs.existsSync(html)) { @@ -70,11 +70,14 @@ export async function serve(options: ServeOptions): Promise { const url = new URL(req.url); // Serve the comparison board HTML - if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) { + if ( + req.method === "GET" && + (url.pathname === "/" || url.pathname === "/index.html") + ) { // Inject the server URL so the board can POST feedback const injected = htmlContent.replace( "", - `\n` + `\n`, ); return new Response(injected, { headers: { "Content-Type": "text/html; charset=utf-8" }, @@ -130,7 +133,9 @@ export async function serve(options: ServeOptions): Promise { const isSubmit = body.regenerated === false; const isRegenerate = body.regenerated === true; - const action = isSubmit ? "submitted" : (body.regenerateAction || "regenerate"); + const action = isSubmit + ? "submitted" + : body.regenerateAction || "regenerate"; console.error(`SERVE_FEEDBACK_RECEIVED: type=${action}`); @@ -185,7 +190,7 @@ export async function serve(options: ServeOptions): Promise { if (!newHtmlPath || !fs.existsSync(newHtmlPath)) { return Response.json( { error: `HTML file not found: ${newHtmlPath}` }, - { status: 400 } + { status: 400 }, ); } @@ -193,10 +198,13 @@ export async function serve(options: ServeOptions): Promise { // allowed directory (anchored to the initial HTML file's parent). // Prevents path traversal via /api/reload reading arbitrary files. const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath)); - if (!resolvedReload.startsWith(allowedDir + path.sep) && resolvedReload !== allowedDir) { + if ( + !resolvedReload.startsWith(allowedDir + path.sep) && + resolvedReload !== allowedDir + ) { return Response.json( { error: `Path must be within: ${allowedDir}` }, - { status: 403 } + { status: 403 }, ); }