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, res.sendFile and res.download whose path includes request data (req.query, req.params, req.body, 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 only one non-empty string: Express returns an array for ?path=a&path=b, and Node's fs functions throw on a NUL byte.
- Add regression tests with 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, requestedPath) {
return readFile(path.join(baseDir, requestedPath));
}With IMPL=vulnerable, 4 of 12 tests fail (4 exploit tests).
The fix
export async function readUserFile(baseDir, requestedPath) {
// 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, requestedPath) {
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 () => {
assertNotServed(await get('../secret.txt'));
});
test('percent-encoded ../ is decoded by Express and still refused', async () => {
assertNotServed(await getRaw('path=%2e%2e%2fsecret.txt'));
});
test('an absolute path cannot replace the base directory', async () => {
assertNotServed(await get(path.join(tmp, 'secret.txt')));
});
test('backslashes cannot climb out (a separator on Windows)', async () => {
assertNotServed(await get('..\\secret.txt'));
});
test('a sibling folder that shares the prefix is outside (files-secret vs files)', async () => {
assertNotServed(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'));
assertNotServed(await get('shared/api-keys.txt'));
});
test('....// does not turn back into ../ (single-pass filter bypass)', async () => {
assertNotServed(await get('....//secret.txt'));
});
});- path.join(base, name) is not a check: it collapses ../ and happily returns a path outside base.
- path.join('/srv/files', '/etc/passwd') stays under the base, but 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.
- res.sendFile(name, { root }) and express.static refuse a ../ segment, but both served a file through a symlink inside the root that points outside it (checked with Express 5.3.0, send 1.2.1 and serve-static 2.2.1). Express's documentation says root contains access; that holds for the path string, not for links.
- Express decodes values before your code sees them: a query value of %2e%2e%2f arrives as ../, and a route parameter in /files/:name receives ../../secret.txt from /files/..%2F..%2Fsecret.txt.
- 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.
- On Windows a backslash is also a separator: path.win32.resolve('C:\\srv\\files', '..\\secret.txt') is C:\srv\secret.txt. The resolve-then-check fix covers it; filters that look for '/' do not.
- 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, which enforces containment at open time.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs path-traversal/node-express does this for you).
- Against your own endpoint, request ?path=..%2Fsecret.txt, an absolute path and a file reached through a symlink that points outside the base; each must answer 404 without the file's content.
- Do not count on static analysis here: the Semgrep rules above 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.express.security.audit.express-path-join-resolve-traversal.express-path-join-resolve-traversal | No findings on this card: req.query is read in app.js and path.join is called in impl/*.js, and Semgrep CE taint tracking stays inside one file. On probe files with the handler and path.join in one file it flagged both a function and an arrow handler when path came from import path from 'path', and nothing when it came from 'node:path'.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal | No findings on this card: the rule only recognises path imported as 'path', and the card imports 'node:path'. On a copy of impl/ with plain 'path' and 'fs/promises' imports it flagged vulnerable.js, and fixed.js too: every function parameter counts as a source and the containment check is not a sanitiser. In wrong.js it reported only baseDir, because the rule treats any .replace() call as a sanitiser, so the single-pass filter hides the user-controlled path.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | javascript.lang.security.audit.detect-non-literal-fs-filename.detect-non-literal-fs-filename | No findings on this card, because of the 'node:fs/promises' import. On the plain-import copy it flagged the fs calls in all three implementations, fixed.js included: it reports any fs call whose path comes from a function parameter and has no notion of a containment check.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | js/path-injection | From TaintedPathCustomizations.qll and NodeJSLib.qll: node:-prefixed and fs/promises imports are modelled, path.resolve and the callback and sync forms of fs.realpath count as normalisation, and a startsWith(dir) check on a normalised absolute path counts as a barrier (StartsWithDirSanitizer). That barrier does not require a trailing separator, so it would also accept the files-secret mistake; the promise form of realpath used here has no normalisation model.Checked: github/codeql main at b5b8165; not executed |
| Component | Version |
|---|---|
| Language | JavaScript (ES modules) |
| Runtime | Node.js 26.11.1 |
| Framework | Express 5.3.0 |
| express | 5.3.0 |
| node:fs/promises and node:path (built in) | Node.js 26.11.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the path-traversal/node-expressfolder.
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/node-express 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
- express 5.3.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
- IMPL=fixed 12/12 pass; IMPL=vulnerable 4/12 fail, all exploit tests (../, %2e%2e%2f, the files-secret sibling, the symlink); IMPL=wrong 2/12 fail, both exploit tests (the symlink and ....//). The absolute-path test passes even for the vulnerable code because path.join keeps an absolute name under the base. The ..\ test passes in every mode on macOS and Linux, where a backslash is an ordinary file-name character; it guards a Windows deployment.
- Dependency freshness
- express 5.3.0 was published 2026-10-09, one day before verification. It is taken anyway because it is a security release (qs, proxy-addr and body-parser fixes); the previous 5.2.1 dates from 2025-12-01. npm audit reported 0 vulnerabilities in the 65 installed packages.
- Scanners
- Semgrep 1.180.0 from a throwaway venv (pip install semgrep==1.180.0), not a card dependency; CodeQL read from source, not executed.
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
- OWASP WSTG: Testing Directory Traversal File Include
- Node.js v26: path.resolve()
- Node.js v26: path.join()
- Node.js v26: fsPromises.realpath()
- Express 5: res.sendFile() (root option and security note)
- Express 5: express.static()
- PortSwigger Web Security Academy: Path traversal (nested ....// and encoded sequences)
- Linux man page: openat2(2) and RESOLVE_BENEATH
Attack patterns: