Check where the path lands, not what it looks like: resolve base with fs.realpath, resolve the request with realpath(path.resolve(base, name)), which collapses ../ and follows symlinks, and serve it only if it starts with base + path.sep. Answer 404 otherwise. Stripping "../" or comparing without the separator does not work.
- Find every fs call in route handlers, server actions and server components whose path includes request data: searchParams, dynamic route params (params.slug), form fields, headers or uploaded file names, including in helper modules.
- Resolve the base directory with fs.promises.realpath, then resolve the request against it with realpath(path.resolve(base, name)); realpath also rejects with ENOENT for a missing file.
- Serve the file only if the real path starts with base + path.sep, and answer the same 404 for refused and missing paths so the response does not reveal what exists outside the folder.
- Accept exactly one non-empty string (searchParams.getAll() has length 1) and refuse a NUL byte; Node's fs functions throw on one.
- Add regression tests that call the route handler with a Request carrying this card's payloads: ../, %2e%2e%2f, an absolute path, ..\, the files-secret sibling, a symlink created in a temporary directory, and ....//.
- When users only pick from known files, map an ID to a server-side path instead of accepting a path at all.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
export async function readUserFile(baseDir: string, requestedPath: string): Promise<Buffer | null> {
return readFile(path.join(baseDir, requestedPath));
}With IMPL=vulnerable, 4 of 12 tests fail (4 exploit tests).
The fix
export async function readUserFile(baseDir: string, requestedPath: string): Promise<Buffer | null> {
// realpath() returns an absolute path with ../ collapsed and every symlink followed. Resolve
// the base the same way: on macOS, for example, /var is a link to /private/var.
const base = await realpath(baseDir);
// path.resolve() lets an absolute requestedPath replace base; the check below catches it.
// realpath() rejects with ENOENT for a missing file, which the route answers with 404.
const target = await realpath(path.resolve(base, requestedPath));
// Compare with a trailing separator: /srv/files-secret starts with "/srv/files" but is not
// inside /srv/files.
if (!target.startsWith(base + path.sep)) {
return null;
}
return readFile(target);
}With IMPL=fixed, 12 of 12 tests pass.
A fix that looks safe but is not
export async function readUserFile(baseDir: string, requestedPath: string): Promise<Buffer | null> {
const cleaned = requestedPath.replace(/\.\.\//g, '');
return readFile(path.join(baseDir, cleaned));
}With IMPL=wrong, 2 of 12 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
describe(`exploit (IMPL=${implName})`, () => {
// Each payload aims at a real file outside files/, so a leak would come back as a 200.
test('../ cannot climb out of the base directory', async () => {
expectNotServed(await get('../secret.txt'));
});
test('percent-encoded ../ is decoded by URLSearchParams and still refused', async () => {
expectNotServed(await getRaw('path=%2e%2e%2fsecret.txt'));
});
test('an absolute path cannot replace the base directory', async () => {
expectNotServed(await get(path.join(tmp, 'secret.txt')));
});
test('backslashes cannot climb out (a separator on Windows)', async () => {
expectNotServed(await get('..\\secret.txt'));
});
test('a sibling folder that shares the prefix is outside (files-secret vs files)', async () => {
expectNotServed(await get('../files-secret/api-keys.txt'));
});
test('a symlink inside the base cannot lead outside it', async () => {
await symlink(path.join(tmp, 'files-secret'), path.join(baseDir, 'shared'));
expectNotServed(await get('shared/api-keys.txt'));
});
test('....// does not turn back into ../ (single-pass filter bypass)', async () => {
expectNotServed(await get('....//secret.txt'));
});
});- Dynamic segments are decoded: with Next.js 16.4.0, /files/..%2Fsecret.txt gives a [...path] catch-all params.path of ['../secret.txt'] and /files/a%2F..%2F..%2Fx gives ['a/../../x'], so params need the same containment check as a query value. (Literal ../ and %2e%2e segments are removed when the URL is normalised, before routing.)
- path.join(base, name) is not a check: it collapses ../ and happily returns a path outside base. path.resolve('/srv/files', '/etc/passwd') returns /etc/passwd; the fix uses resolve() and lets the containment check refuse absolute names.
- Compare with base + path.sep: '/srv/files-secret/api-keys.txt'.startsWith('/srv/files') is true.
- replace(/\.\.\//g, '') makes one pass, so ....// turns back into ../. Filtering the string cannot tell where the path will land.
- Compare real paths on both sides. On macOS, os.tmpdir() lives under /var, a symlink to /private/var, so a realpath() result never starts with the configured string and every legitimate file would be refused.
- realpath() followed by readFile() leaves a window in which someone who can write inside the base can swap in a symlink. Keep the served tree read-only for untrusted users; Node has no binding for Linux's openat2 RESOLVE_BENEATH.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs path-traversal/typescript-nextjs does this for you).
- Against your own deployment, request /files?path=..%2Fsecret.txt, an absolute path and a file reached through a symlink that points outside the base, and if you serve from a dynamic segment, /files/..%2Fsecret.txt; each must answer 404 without the file's content.
- Do not count on static analysis here: the Semgrep rules report nothing for node:-prefixed imports, and with plain imports they also flag the fixed code. The regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal | No findings on any implementation: the rule only recognises path imported as 'path', and the card imports 'node:path', as the node-express card found too.Checked: Semgrep 1.180.0 (CE); on app and lib |
| Semgrep | javascript.lang.security.audit.detect-non-literal-fs-filename.detect-non-literal-fs-filename | No findings, because of the 'node:fs/promises' import. With plain imports the node-express card found it flags the fixed code as well.Checked: Semgrep 1.180.0 (CE); on app and lib |
| Semgrep | p/default, p/javascript, p/typescript, p/security-audit, p/owasp-top-ten | No findings on any implementation.Checked: Semgrep 1.180.0 (CE); on app and lib |
| CodeQL | js/path-injection | Next.qll models request.url of a GET exported from app/**/route.ts as a remote source; here GET is the return value of a factory in lib/files-handler.ts, and whether the analysis follows it was not tested. The node-express card's reading of the sink and barrier models applies unchanged: node:fs/promises is modelled, and the startsWith barrier does not require the trailing separator.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | TypeScript |
| Runtime | Node.js 26.11.1 (route handlers use the default Node.js runtime, which node:fs needs) |
| Framework | Next.js 16.4.0 (App Router route handlers) |
| next | 16.4.0 |
| react, react-dom | 19.3.0 |
| node:fs/promises, node:path (built in) | Node.js 26.11.1 |
| vitest (test only) | 5.0.3 |
| typescript (type check and next build only) | 6.0.3 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the path-traversal/typescript-nextjsfolder.
npm ci# Expected: every test passes
IMPL=fixed npm test --silent
# Expected: an exploit test fails
IMPL=vulnerable npm test --silent
# Expected: a test fails
IMPL=wrong npm test --silentEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs path-traversal/typescript-nextjs runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- node 26.11.1
- npm 11.20.0
- next 16.4.0
- react 19.3.0
- vitest 5.0.3 (Vite 8.3.4)
- typescript 6.0.3
- semgrep 1.180.0
- Matrix
- fixed: 12 of 12 tests pass; vulnerable: 4 of 12 tests fail (4 exploit tests); wrong: 2 of 12 tests fail (2 exploit tests)
- Test results
- fixed 12/12 pass; vulnerable 4/12 fail, all exploit tests (../, %2e%2e%2f, the files-secret sibling, the symlink); wrong 2/12 fail, both exploit tests (the symlink and ....//), offline after npm ci. As on Node.js, the absolute-path test passes for the vulnerable code because path.join keeps an absolute name under the base, and the ..\ test guards a Windows deployment.
- Dependency freshness
- next 16.4.0 was published on 2026-10-06, react and react-dom 19.3.0 on 2026-09-09, vitest 5.0.3 on 2026-09-30 and typescript 6.0.3 on 2026-04-16 (TypeScript 7 no longer exports the compiler API next build's type check loads). npm audit found 0 vulnerabilities. tsc --noEmit and next build pass; the dynamic-segment decoding in the pitfalls was observed with next build and next start on this machine. Rerun on Node.js 26.11.1, above the 26.5.1 security floor in engines.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP: Path Traversal
- Next.js: Route Handlers (App Router)
- Next.js: Dynamic Route Segments (catch-all segments)
- Node.js v26: path.resolve()
- Node.js v26: fsPromises.realpath()
- PortSwigger Web Security Academy: Path traversal (nested ....// and encoded sequences)
- Linux man page: openat2(2) and RESOLVE_BENEATH
Attack patterns: