Path traversal¶
When is it relevant¶
A new or changed code path that builds a file system path, or an archive entry path, from input. Examples are file downloads and uploads, static serving, template or plugin loading, backups and exports, and extracting zip or tar files.
Attack¶
The attacker uses ../ sequences to reach files outside the intended folder. Variants include URL-encoded sequences, absolute paths and backslashes on Windows. They can read configuration, keys and source code, or overwrite files, which can lead to code execution. Archive entries named ../../app.js do the same during extraction ("zip slip").
Mitigation¶
- Don't use input as a path. Map an ID to a stored file name, and generate upload file names on the server.
- If you must use input, resolve it against the base folder and confirm the result stays inside it. Use
fs.realpathwhen symlinks are possible. - Use
res.sendFile(name, { root }), which rejects paths that escape the root.
Examples¶
Mitigation options
| Option | When it fits | Docs |
|---|---|---|
| Look the path up by ID | Best option: the filesystem path never comes from input | Mongoose queries |
path.basename into a fixed directory |
Flat directories of user files | Node path |
path.resolve + prefix check |
Nested directories you must preserve | Node path |
res.sendFile with root |
Serving a file straight back | Express API |
Vulnerable — join with user input
router.get('/files/:name', requireAuth, (req, res) => {
res.sendFile(path.join(UPLOAD_DIR, req.params.name)) // ../../etc/passwd
})
Vulnerable — prefix check without a separator
const target = path.resolve(UPLOAD_DIR, req.query.name)
if (!target.startsWith(UPLOAD_DIR)) return res.sendStatus(400)
/srv/uploads-evil/secret starts with /srv/uploads and passes.
Mitigated — look it up by ID
const file = await File.findOne({ _id: req.params.id, ownerId: req.user.id })
if (!file) return res.sendStatus(404)
res.sendFile(file.storagePath, { root: UPLOAD_DIR })
Mitigated — basename into a fixed directory
const name = path.basename(req.params.name) // strips any directory part
if (!/^[\w.-]+$/.test(name)) return res.sendStatus(400)
res.sendFile(name, { root: UPLOAD_DIR })
res.sendFile with root refuses paths that escape the root, so it is doing a
second check for you.
Docs: res.sendFile · path.basename
Mitigated — resolve and compare with a separator
const root = path.resolve(UPLOAD_DIR) + path.sep
const target = path.resolve(UPLOAD_DIR, req.query.name)
if (!target.startsWith(root)) return res.sendStatus(400)
await fs.promises.readFile(target)
Docs: path.resolve
Bypasses to check
path.join(dir, input)where the input is absolute — on POSIXpath.join('/srv', '/etc/passwd')stays inside, butpath.resolvewould not; check which one is used and what it receives.- Encodings decoded after the check:
%2e%2e%2f,..%252f,..\on Windows, and Express decoding route params for you. - Check then change: the name is validated, then an extension is appended
or the path is rebuilt —
name + '.pdf'defeated by a null byte in older runtimes, or by a name that already ends in.pdf/../../. - Symlinks inside the upload directory pointing out of it, and archive
extraction that honours
../entries in the archive (zip slip). - Case-insensitive filesystems (macOS, Windows) making an allowlist comparison pass unexpectedly.
- Write paths as well as read: upload filename, export destination, log path, template name.
- The filename echoed back into a response or a
Content-Dispositionheader without quoting.
What to grep for
path.join(,path.resolve(,__dirname,startsWith(fs.readFile,fs.createReadStream,res.sendFile(,res.download(req.params,req.query,file.originalname,unzip,extract