fix(design): escape url.origin when injecting into served HTML

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:<port> 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.
This commit is contained in:
Mohammed Qazi
2026-04-18 15:59:49 -07:00
committed by Garry Tan
parent 693eadf6f3
commit de4832ba1a
+15 -7
View File
@@ -47,7 +47,7 @@ export interface ServeOptions {
type ServerState = "serving" | "regenerating" | "done";
export async function serve(options: ServeOptions): Promise<void> {
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<void> {
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(
"</head>",
`<script>window.__GSTACK_SERVER_URL = '${url.origin}';</script>\n</head>`
`<script>window.__GSTACK_SERVER_URL = ${JSON.stringify(url.origin)};</script>\n</head>`,
);
return new Response(injected, {
headers: { "Content-Type": "text/html; charset=utf-8" },
@@ -130,7 +133,9 @@ export async function serve(options: ServeOptions): Promise<void> {
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<void> {
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<void> {
// 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 },
);
}