Skip to content

[Security] symlinks: 'within-root' is not enforced on directory index files or on directory-listing entries #575

Description

@pathosDev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority: mediumUseful, not urgentsecuritySecurity-relevant — see severity label for impact tierseverity: mediumModerate impact or requires specific conditions

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions