Component: src/http/static/StaticFiles.ts
Severity (assessment): MEDIUM
CWE: CWE-59
serveFromDirectory runs the realpath confinement check exactly once, on the path the URL resolved to. When that path is a directory, the index file it then serves (join(resolved.fsPath, index)) and the entries it stats for the listing are never re-checked, so a symlink at the final hop escapes the root even though the default policy promises a 404.
Exploit walkthrough
Remote HTTP client. Precondition: one symlink inside the served tree whose target is outside the root — planted by any process that writes into the tree (archive/zip extraction that preserves symlinks, a pnpm/npm node_modules link farm, a CI artifact copy, a mounted volume). Given <root>/sub/ as a real directory and <root>/sub/index.html as a symlink to /etc/passwd (or ../../.env): GET /static/sub/index.html correctly returns 404 — the check at line 159 fires — but GET /static/sub/ returns 200 with the out-of-root file's bytes, because the index branch calls serveResolvedFile(indexPath, …) directly. The attacker reads any file the server process can read, via a URL the framework's own policy claims is closed. The same omission in renderListing makes out-of-root symlink targets appear in the browsable listing with their real size and mtime.
Evidence — src/http/static/StaticFiles.ts:168
src/http/static/StaticFiles.ts:155-169
const stat = await statPath(resolved.fsPath);
if (!stat) return notFound();
if (settings.symlinks === 'within-root') {
const [realFile, realRoot] = await Promise.all([realPath(resolved.fsPath), realPath(root)]);
if (!realFile || !realRoot || (realFile !== realRoot && !realFile.startsWith(realRoot + sep))) return notFound();
}
if (stat.isDirectory) {
if (!request.path.endsWith('/')) return redirect(`${request.path}/${queryString(request.query)}`, Status.MovedPermanently);
for (const index of settings.indexFiles) {
const indexPath = join(resolved.fsPath, index);
const indexStat = await statPath(indexPath);
if (indexStat && indexStat.isFile) return serveResolvedFile(indexPath, indexStat, request, settings, index);
}
src/http/static/StaticFiles.ts:136-140 (listing, same omission)
for (const entry of await readDirectory(fsPath)) {
if (settings.dotfiles === 'deny' && entry.name.startsWith('.')) continue;
const stat = await statPath(join(fsPath, entry.name));
src/http/static/StaticFilesOptions.ts:24-25 (the promise being broken)
/** Symlink policy. Default `'within-root'` (a link escaping the root → 404). */
readonly symlinks?: 'within-root' | 'follow';
Why the existing guard does not cover it
Searched StaticFiles.ts, staticPath.ts and fsAccess.ts for a second confinement check: realPath is imported once (line 11) and called only at line 159. resolveStaticPath is pure path arithmetic over the URL remainder (staticPath.ts:57-61) and cannot see a link at all — I verified its own confinement is sound, including on Windows. statPath uses fs.stat, not lstat (fsAccess.ts:29), so it follows the link and indexStat.isFile is true. grep -rn 'symlink' tests/unit/http/static returns no hits — there is no regression test pinning the policy. Note: I could not run a live PoC because this Windows host refuses fs.symlink without admin (EPERM); the gap is unconditional in the source and platform-independent.
Suggested fix
Extract the within-root check into a local helper (await isWithinRoot(root, candidate)) and call it on indexPath before serveResolvedFile(indexPath, …), and on join(fsPath, entry.name) in renderListing before pushing the entry. Alternatively, when symlinks === 'within-root', use lstat on the index candidate and skip it if it is a symlink. Add a symlink regression test alongside tests/unit/http/static/StaticFiles.test.ts.
Verification status
Found in the whole-framework security audit of 2026-08-01 (v0.12.0), then adjudicated by an independent verifier instructed to refute it.
Verifier note
Independently reproduced by reading src/http/static/StaticFiles.ts. The confinement check runs once, at line 158-161, against resolved.fsPath only. The directory branch then does const indexPath = join(resolved.fsPath, index) (line 166) and return serveResolvedFile(indexPath, indexStat, request, settings, index) (line 168) with no second realpath check — realPath is imported at line 11 and is called at line 159 and nowhere else in the file (grep confirms a single call site). statPath uses fs.stat, which follows symlinks (src/http/static/fsAccess.ts:29), so indexStat.isFile is true for a link pointing outside the root. Consequence: with the default symlinks: 'within-root' (src/http/static/StaticFilesOptions.ts:121), GET /static/sub/index.html is correctly 404'd by line 160 while GET /static/sub/ serves the out-of-root target's bytes — a documented policy (StaticFilesOptions.ts:24-25, 'a link escaping the root -> 404') that is not enforced on the final index hop. renderListing has the same omission (line 138 stats join(fsPath, entry.name) with no confinement check), leaking out-of-root names/size/mtime, though clicking such an entry does hit the check and 404s. No symlink regression test exists (grep over tests/ finds only options-validation hits). Severity stays medium rather than high: it needs a pre-existing out-of-root symlink whose name is exactly one of indexFiles (default index.html) inside the served tree — plausible via archive extraction or a build-output link, but not attacker-created from HTTP alone.
Component:
src/http/static/StaticFiles.tsSeverity (assessment): MEDIUM
CWE: CWE-59
serveFromDirectoryruns the realpath confinement check exactly once, on the path the URL resolved to. When that path is a directory, the index file it then serves (join(resolved.fsPath, index)) and the entries it stats for the listing are never re-checked, so a symlink at the final hop escapes the root even though the default policy promises a 404.Exploit walkthrough
Remote HTTP client. Precondition: one symlink inside the served tree whose target is outside the root — planted by any process that writes into the tree (archive/zip extraction that preserves symlinks, a pnpm/npm
node_moduleslink farm, a CI artifact copy, a mounted volume). Given<root>/sub/as a real directory and<root>/sub/index.htmlas a symlink to/etc/passwd(or../../.env):GET /static/sub/index.htmlcorrectly returns 404 — the check at line 159 fires — butGET /static/sub/returns 200 with the out-of-root file's bytes, because the index branch callsserveResolvedFile(indexPath, …)directly. The attacker reads any file the server process can read, via a URL the framework's own policy claims is closed. The same omission inrenderListingmakes out-of-root symlink targets appear in the browsable listing with their real size and mtime.Evidence —
src/http/static/StaticFiles.ts:168Why the existing guard does not cover it
Searched StaticFiles.ts, staticPath.ts and fsAccess.ts for a second confinement check:
realPathis imported once (line 11) and called only at line 159.resolveStaticPathis pure path arithmetic over the URL remainder (staticPath.ts:57-61) and cannot see a link at all — I verified its own confinement is sound, including on Windows.statPathusesfs.stat, notlstat(fsAccess.ts:29), so it follows the link andindexStat.isFileis true.grep -rn 'symlink' tests/unit/http/staticreturns no hits — there is no regression test pinning the policy. Note: I could not run a live PoC because this Windows host refusesfs.symlinkwithout admin (EPERM); the gap is unconditional in the source and platform-independent.Suggested fix
Extract the within-root check into a local helper (
await isWithinRoot(root, candidate)) and call it onindexPathbeforeserveResolvedFile(indexPath, …), and onjoin(fsPath, entry.name)inrenderListingbefore pushing the entry. Alternatively, whensymlinks === 'within-root', uselstaton the index candidate and skip it if it is a symlink. Add a symlink regression test alongside tests/unit/http/static/StaticFiles.test.ts.Verification status
Found in the whole-framework security audit of 2026-08-01 (
v0.12.0), then adjudicated by an independent verifier instructed to refute it.Verifier note
Independently reproduced by reading src/http/static/StaticFiles.ts. The confinement check runs once, at line 158-161, against
resolved.fsPathonly. The directory branch then doesconst indexPath = join(resolved.fsPath, index)(line 166) andreturn serveResolvedFile(indexPath, indexStat, request, settings, index)(line 168) with no second realpath check —realPathis imported at line 11 and is called at line 159 and nowhere else in the file (grep confirms a single call site). statPath usesfs.stat, which follows symlinks (src/http/static/fsAccess.ts:29), soindexStat.isFileis true for a link pointing outside the root. Consequence: with the defaultsymlinks: 'within-root'(src/http/static/StaticFilesOptions.ts:121),GET /static/sub/index.htmlis correctly 404'd by line 160 whileGET /static/sub/serves the out-of-root target's bytes — a documented policy (StaticFilesOptions.ts:24-25, 'a link escaping the root -> 404') that is not enforced on the final index hop. renderListing has the same omission (line 138 statsjoin(fsPath, entry.name)with no confinement check), leaking out-of-root names/size/mtime, though clicking such an entry does hit the check and 404s. No symlink regression test exists (grep over tests/ finds only options-validation hits). Severity stays medium rather than high: it needs a pre-existing out-of-root symlink whose name is exactly one ofindexFiles(defaultindex.html) inside the served tree — plausible via archive extraction or a build-output link, but not attacker-created from HTTP alone.