mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-05 05:05:08 +02:00
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:
+15
-7
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user