Injection and output
Keep file paths inside a root
Generate storage names, resolve canonical paths, and prevent user-controlled traversal outside the directory an operation owns.
To a user, ../../secrets.env is a filename. To the filesystem, it’s a set of instructions: go up two directories, then open this file. Path traversal happens when your code treats those two meanings as the same thing.
The check that looks right and isn’t
Here is a download route with a prefix check that most people would approve in a code review:
// Vulnerable: prefix check on unresolved text
const target = path.join('/srv/reports', req.query.file)
if (target.startsWith('/srv/reports')) fs.createReadStream(target)
path.join does collapse .. segments, so ?file=../../.env becomes /.env and fails the check. Good. But path.join knows nothing about symlinks. If someone drops a link at /srv/reports/latest pointing to /etc, then ?file=latest/passwd passes the prefix check and streams /etc/passwd. The string looked fine. The file it opened did not.
Resolve first, then check containment
Resolve the path to its real location with fs.realpath, which follows symlinks all the way down. Only then check that it sits inside the root:
const root = await fs.promises.realpath('/srv/reports')
const resolved = await fs.promises.realpath(path.join(root, req.query.file))
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
return res.status(404).end() // outside the root: refuse, do not reveal
}
Request ?file=latest/passwd now. resolved comes back as /etc/passwd, which does not start with /srv/reports/, and the route answers 404. Note the path.sep in the check. Without it, /srv/reports-private/salaries.csv would pass, because the string starts with /srv/reports.
Also note the 404, not a 403 with a message. Telling the caller “that file exists but you can’t have it” is a small gift you don’t need to give.
Better: don’t interpret user paths at all
The strongest fix is to stop reading paths from the request. Store files under IDs you generate, and map an ID to a path the server owns:
const files = { 'q1-report': '/srv/reports/2026-q1.pdf' }
const chosen = files[req.params.id] // unknown IDs do not resolve at all
There is no .. to worry about because there is no path in the request. In real code the map lives in the database, but the idea is the same.
The same discipline covers writes and deletes. A route that saves an upload or removes a temp file is just as exposed if it joins user text onto a base directory. I’ve seen “delete my export” endpoints that accepted a filename. Don’t be that endpoint.
Try this on your own project: hit your file route with ../ segments, an absolute path, URL-encoded slashes like %2F, and a symlink you create inside the root pointing outside it. Log the resolved target for each. Every escape must return 404 with no hint about the external file.
Lesson completed