fix(design): reload guard rejects directory paths

design/src/serve.ts:200-212 used to accept a path that resolved to the
allowedDir itself (the OR branch `|| resolvedReload === allowedDir`),
which then crashed readFileSync with EISDIR. Now:

  1. startsWith(allowedDir + path.sep) must pass — rejects the dir itself
     and anything outside (403).
  2. statSync(resolvedReload).isFile() must pass — rejects subdirectories
     inside allowedDir with a clear "Path must be a file" 400.

The test stub in serve.test.ts mirrors prod; both updated, plus two new
test cases for the previously-broken paths. Codex caught this in the
plan-review pass; it's a latent bug in shipping code, not a regression
from the daemon work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-25 14:32:06 -07:00
parent f55595d594
commit 51a8d26be2
2 changed files with 50 additions and 8 deletions
+13 -7
View File
@@ -197,19 +197,25 @@ export async function serve(options: ServeOptions): Promise<void> {
);
}
// Security: resolve symlinks and validate the reload path is within the
// allowed directory (anchored to the initial HTML file's parent).
// Prevents path traversal via /api/reload reading arbitrary files.
// Security: resolve symlinks and validate the reload path is a FILE
// inside the allowed directory (anchored to the initial HTML file's
// parent). Prevents path traversal via /api/reload reading arbitrary
// files. A path resolving to the allowedDir itself (a directory) used
// to pass the guard and then crash readFileSync with EISDIR — reject
// it explicitly with a clear 400 instead.
const resolvedReload = fs.realpathSync(path.resolve(newHtmlPath));
if (
!resolvedReload.startsWith(allowedDir + path.sep) &&
resolvedReload !== allowedDir
) {
if (!resolvedReload.startsWith(allowedDir + path.sep)) {
return Response.json(
{ error: `Path must be within: ${allowedDir}` },
{ status: 403 },
);
}
if (!fs.statSync(resolvedReload).isFile()) {
return Response.json(
{ error: `Path must be a file, not a directory: ${newHtmlPath}` },
{ status: 400 },
);
}
// Swap the HTML content
htmlContent = fs.readFileSync(resolvedReload, "utf-8");