Skip to content

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.realpath when 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 POSIX path.join('/srv', '/etc/passwd') stays inside, but path.resolve would 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-Disposition header 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

Resources