diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index a2f2fe52fdb..c3693406962 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -180,6 +180,29 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("serves HEAD for in-root assets without writing a body", async () => { + await withControlUiRoot({ + fn: async (tmp) => { + const assetsDir = path.join(tmp, "assets"); + await fs.mkdir(assetsDir, { recursive: true }); + await fs.writeFile(path.join(assetsDir, "actual.txt"), "inside-ok\n"); + + const { res, end } = makeMockHttpResponse(); + const handled = handleControlUiHttpRequest( + { url: "/assets/actual.txt", method: "HEAD" } as IncomingMessage, + res, + { + root: { kind: "resolved", path: tmp }, + }, + ); + + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(end.mock.calls[0]?.length ?? -1).toBe(0); + }, + }); + }); + it("rejects symlinked SPA fallback index.html outside control-ui root", async () => { await withControlUiRoot({ fn: async (tmp) => { diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 08bc2500bb7..85a68caf86b 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -179,19 +179,21 @@ function respondNotFound(res: ServerResponse) { res.end("Not Found"); } -function serveFile(res: ServerResponse, filePath: string) { +function setStaticFileHeaders(res: ServerResponse, filePath: string) { const ext = path.extname(filePath).toLowerCase(); res.setHeader("Content-Type", contentTypeForExt(ext)); // Static UI should never be cached aggressively while iterating; allow the // browser to revalidate. res.setHeader("Cache-Control", "no-cache"); +} + +function serveFile(res: ServerResponse, filePath: string) { + setStaticFileHeaders(res, filePath); res.end(fs.readFileSync(filePath)); } function serveResolvedFile(res: ServerResponse, filePath: string, body: Buffer) { - const ext = path.extname(filePath).toLowerCase(); - res.setHeader("Content-Type", contentTypeForExt(ext)); - res.setHeader("Cache-Control", "no-cache"); + setStaticFileHeaders(res, filePath); res.end(body); } @@ -217,12 +219,11 @@ function areSameFileIdentity(preOpen: fs.Stats, opened: fs.Stats): boolean { } function resolveSafeControlUiFile( - root: string, + rootReal: string, filePath: string, -): { path: string; body: Buffer } | null { +): { path: string; fd: number } | null { let fd: number | null = null; try { - const rootReal = fs.realpathSync(root); const fileReal = fs.realpathSync(filePath); if (!isContainedPath(rootReal, fileReal)) { return null; @@ -243,7 +244,9 @@ function resolveSafeControlUiFile( return null; } - return { path: fileReal, body: fs.readFileSync(fd) }; + const resolved = { path: fileReal, fd }; + fd = null; + return resolved; } catch (error) { if (isExpectedSafePathError(error)) { return null; @@ -377,6 +380,25 @@ export function handleControlUiHttpRequest( return true; } + const rootReal = (() => { + try { + return fs.realpathSync(root); + } catch (error) { + if (isExpectedSafePathError(error)) { + return null; + } + throw error; + } + })(); + if (!rootReal) { + res.statusCode = 503; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end( + "Control UI assets not found. Build them with `pnpm ui:build` (auto-installs UI deps), or run `pnpm ui:dev` during development.", + ); + return true; + } + const uiPath = basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname; const rel = (() => { @@ -402,14 +424,24 @@ export function handleControlUiHttpRequest( return true; } - const safeFile = resolveSafeControlUiFile(root, filePath); + const safeFile = resolveSafeControlUiFile(rootReal, filePath); if (safeFile) { - if (path.basename(safeFile.path) === "index.html") { - serveResolvedIndexHtml(res, safeFile.body.toString("utf8")); + try { + if (req.method === "HEAD") { + res.statusCode = 200; + setStaticFileHeaders(res, safeFile.path); + res.end(); + return true; + } + if (path.basename(safeFile.path) === "index.html") { + serveResolvedIndexHtml(res, fs.readFileSync(safeFile.fd, "utf8")); + return true; + } + serveResolvedFile(res, safeFile.path, fs.readFileSync(safeFile.fd)); return true; + } finally { + fs.closeSync(safeFile.fd); } - serveResolvedFile(res, safeFile.path, safeFile.body); - return true; } // If the requested path looks like a static asset (known extension), return @@ -424,10 +456,20 @@ export function handleControlUiHttpRequest( // SPA fallback (client-side router): serve index.html for unknown paths. const indexPath = path.join(root, "index.html"); - const safeIndex = resolveSafeControlUiFile(root, indexPath); + const safeIndex = resolveSafeControlUiFile(rootReal, indexPath); if (safeIndex) { - serveResolvedIndexHtml(res, safeIndex.body.toString("utf8")); - return true; + try { + if (req.method === "HEAD") { + res.statusCode = 200; + setStaticFileHeaders(res, safeIndex.path); + res.end(); + return true; + } + serveResolvedIndexHtml(res, fs.readFileSync(safeIndex.fd, "utf8")); + return true; + } finally { + fs.closeSync(safeIndex.fd); + } } respondNotFound(res);