Skip to content

fix: prevent symlinks from escaping the public directory - #238

Closed
sahenjp wants to merge 1 commit into
vercel:mainfrom
sahenjp:fix/symlink-root-escape
Closed

sahenjp wants to merge 1 commit into
vercel:mainfrom
sahenjp:fix/symlink-root-escape

Conversation

@sahenjp

@sahenjp sahenjp commented Oct 8, 2026

Copy link
Copy Markdown

What

serve-handler documents that symlinks are disabled by default and are treated as if they do not exist (README, symlinks). The check only inspected the final path component, so two ordinary requests were still followed and could serve files outside the public directory:

  • a directory symlink requested with a trailing slash (/link/) — lstat resolves the final symlink when the path ends with /
  • a symlink used as an intermediate path component (/link/file) — lstat leaves only the final component unresolved

This defeats the documented default, which exists specifically to keep symlinks from escaping the served root.

Fix

hasSymlinkInPath() walks the path components with lstat and returns a 404 as soon as one of them is a symlink, which is what the documented default promises. The final component continues to be checked by the existing code path, except when the request has a trailing slash, where the new walk covers it.

Tests

  • a directory symlink should not work by default
  • a symlink used as an intermediate path component should not work by default
  • directory symlinks still work when the option is enabled (no regression)

Full suite: 73 passed.

Notes

  • Behaviour with symlinks: true is unchanged.
  • Unrelated to this change: with symlinks: true, requesting a directory symlink without a trailing slash currently throws an uncaught EISDIR from createReadStream and terminates the process, because isDirectory is not re-evaluated after realpath. I can send a follow-up patch for that if it is useful.

intent(security): symlinks are disabled by default and documented as being treated as if they do not exist, but a directory symlink requested with a trailing slash and a symlink used as an intermediate path component were both followed, exposing files outside the public directory
decision(symlink-handling): inspect every path component with lstat instead of only the final one, so the documented "any symlink is a 404" behaviour holds for all requests
rejected(symlink-handling): resolving with realpath and checking containment instead — that would start serving symlinks which resolve inside the public directory, contradicting the README
constraint(lstat): a trailing slash makes lstat resolve the final symlink, so that component is checked here when the request path ends with a slash; otherwise the existing final-component check still covers it
learned(fixtures): the added fixture is a directory symlink pointing outside test/fixtures so the escape path is exercised, rather than a symlink that stays inside the public directory
@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

@sahenjp

sahenjp commented Oct 8, 2026

Copy link
Copy Markdown
Author

This is a security-relevant fix rather than a plain bug fix: it closes a way to defeat the documented default in which symlinks are disabled and treated as if they do not exist, which could expose files outside the served directory.

If you agree, would it be possible to handle this through a GitHub security advisory? I am happy to provide any additional detail (reproduction, affected versions) and to be credited as the reporter.

@sahenjp

sahenjp commented Oct 8, 2026

Copy link
Copy Markdown
Author

Sorry for my poor English...

@sahenjp sahenjp closed this by deleting the head repository Oct 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant