Ch. 6 · Node.js

Node.js File Paths and Traversal Prevention

Why path.join doesn't stop ../ traversal, how a startsWith check and a symlink both leak files, and a Node.js containment check that holds up.

~6 min readadvancedupdated Oct 7, 2026

Path traversal is one of the oldest web vulnerabilities, and it keeps appearing because the vulnerable code looks so reasonable. A download endpoint takes a filename, joins it to an uploads directory, and streams the file. Then someone asks for ../../.env and gets your database password. In Node, the node:path module makes the joining easy and does nothing to make it safe, because building paths and enforcing boundaries are different jobs.

Interviewers use this as a code-review exercise (“what’s wrong with this download handler?”) and then push on the fix, since the first few fixes people suggest each have a hole. The examples below ran on Node 22 on macOS, against this directory layout:

srv/
  uploads/
    a.txt          "invoice"
    link.txt  ->   ../secret/.env   (a symlink)
  uploads-old/
    b.txt          "OLD"
  secret/
    .env           "DB_PASSWORD=hunter2"
Text

What path.join actually does with ../

path.join concatenates segments and then normalises the result, which means it resolves .. by removing the previous segment. It has no notion of a root you meant to stay inside:

import path from 'node:path';
const ROOT = path.resolve('srv/uploads');
const show = (p) => p.replace(process.cwd(), '.');

for (const name of ['a.txt', '../secret/.env', decodeURIComponent('..%2Fsecret%2F.env'), '/etc/passwd']) {
  console.log(`join    ${JSON.stringify(name).padEnd(18)} -> ${show(path.join(ROOT, name))}`);
}
console.log(`resolve "/etc/passwd"      -> ${path.resolve(ROOT, '/etc/passwd')}`);
JavaScript
join    "a.txt"            -> ./srv/uploads/a.txt
join    "../secret/.env"   -> ./srv/secret/.env
join    "../secret/.env"   -> ./srv/secret/.env
join    "/etc/passwd"      -> ./srv/uploads/etc/passwd
resolve "/etc/passwd"      -> /etc/passwd
Text

The second line is the attack. The third line is the same attack sent URL-encoded as ..%2Fsecret%2F.env: your router or decodeURIComponent turns it into ../secret/.env before your code sees it. That’s why filtering the raw URL for ../ doesn’t work, and why stripping ../ once is also broken (....// becomes ../ after one pass).

The last two lines show a difference between the two functions that trips people up. path.join treats an absolute second argument as just another segment. path.resolve treats it as a new starting point and discards the root entirely. If your code uses resolve (and the safe version below does), an absolute path from the client jumps straight to /etc/passwd.

The startsWith check and its prefix bug

The usual first fix is to resolve the path and check that it begins with the root:

const candidate = path.resolve(ROOT, '../uploads-old/b.txt');
console.log('startsWith check passes for', show(candidate), '?', candidate.startsWith(ROOT));
JavaScript
startsWith check passes for ./srv/uploads-old/b.txt ? true
Text

/srv/uploads-old/b.txt starts with the string /srv/uploads, so the check passes for a sibling directory. Any directory whose name extends the root’s name is reachable: uploads-old, uploads_backup, uploads2. You can patch it by comparing against ROOT + path.sep, but path.relative expresses the intent directly. It answers “how do I get from the root to this path?”, and if the answer starts by going up, the path is outside:

function isInside(root, target) {
  const rel = path.relative(root, target);
  return rel !== '' && rel !== '..' && !rel.startsWith('..' + path.sep) && !path.isAbsolute(rel);
}
JavaScript

The details matter. An empty rel means the target is the root itself, which you don’t want to serve as a file. Checking for '..' + path.sep rather than just '..' keeps a legitimate file named ..notes.txt working. And path.isAbsolute(rel) catches Windows, where path.relative between different drives (C:\ and D:\) returns an absolute path.

Everything so far is lexical: it inspects the string. The file system doesn’t care about your string. srv/uploads/link.txt is a symlink to ../secret/.env, and its path is perfectly inside the root. A string check approves it, and reading it follows the link:

naive read of link.txt: DB_PASSWORD=hunter2
Text

How does a symlink get into an uploads directory? Extracting a user-supplied zip or tar archive is the classic route, since archives can contain symlink entries. So can a shared volume, a misconfigured deploy, or another vulnerability that lets someone create files. The defence is to resolve the real path with fs.realpath, which follows every symlink, and check containment again on the result. Resolve the root with realpath too, or the comparison fails on systems where the root itself sits under a symlink (on macOS, /tmp is a link to /private/tmp).

Here’s the full function:

import path from 'node:path';
import fs from 'node:fs/promises';

const ROOT = await fs.realpath('srv/uploads');

async function openUpload(name) {
  if (typeof name !== 'string' || name.includes('\0')) throw new Error('invalid name');
  const lexical = path.resolve(ROOT, name);
  if (!isInside(ROOT, lexical)) throw new Error('outside upload root');
  const real = await fs.realpath(lexical);          // follows symlinks, throws ENOENT if missing
  if (!isInside(ROOT, real)) throw new Error('symlink points outside upload root');
  return fs.readFile(real, 'utf8');
}
JavaScript

Run against every case so far, plus a missing file, a null byte and the awkward-but-legal ..notes.txt:

"a.txt"                  ok: invoice
"../secret/.env"         rejected: outside upload root
"../uploads-old/b.txt"   rejected: outside upload root
"/etc/passwd"            rejected: outside upload root
"link.txt"               rejected: symlink points outside upload root
"nope.txt"               rejected: ENOENT
"a.txt\u0000.png"        rejected: invalid name
"..notes.txt"            ok: hi
Text

The null-byte check is belt and braces. Node’s fs functions already refuse paths containing \0 with ERR_INVALID_ARG_VALUE, which closes the old trick of truncating a.txt\0.png past an extension check in C-based code. Rejecting it early just gives a clearer error.

There’s one gap left, and it’s worth naming in an interview because it shows you understand the limits of the check: time-of-check to time-of-use. Between realpath and readFile, something with write access to the directory could swap a file for a symlink. For most upload directories, where only your service writes, this is acceptable. If untrusted processes can write there, open the file first and verify the open handle, or use OS-level isolation.

Design so the client never sends a path

Containment checks are the fallback. The better design removes the question. Store uploads under names your server generates (a UUID or a content hash), keep the original filename as metadata in the database, and have clients request files by ID:

app.get('/files/:id', async (req, res) => {
  const file = await db.files.findOne({ id: req.params.id, ownerId: req.user.id });
  if (!file) return res.status(404).end();
  res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(file.originalName)}`);
  fs.createReadStream(path.join(UPLOAD_ROOT, file.storageKey)).pipe(res);
});
JavaScript

Now there’s nothing for an attacker to traverse with, and the ownership check that you needed anyway lives in the same query. The original name goes only into a header, encoded.

Layer operating-system limits on top. Run the service as a user that can’t read .env files or /etc/shadow in the first place, mount upload volumes read-only where the service only serves them, and consider Node’s permission model: node --permission --allow-fs-read=/srv/uploads/* server.js makes any other read fail with ERR_ACCESS_DENIED. If you serve static files through a library (serve-static, @fastify/static), it already does traversal checks; the risk is in the custom handlers people write next to it.

Interview practice

This handler is meant to serve generated reports. A teammate says it’s safe because it strips ../:

app.get('/reports/:name', (req, res) => {
  const name = req.params.name.replaceAll('../', '');
  res.sendFile(path.join('/srv/reports', name));
});
JavaScript

Is it? Give at least two inputs that break it and describe a fix.

Answer

It isn’t safe. replaceAll('../', '') makes one pass, so ....//....//etc/passwd becomes ../../etc/passwd after the replacement, and path.join resolves that to /etc/passwd. On Windows, ..\\..\\ isn’t touched at all because the filter only knows about forward slashes. And a symlink placed inside /srv/reports (for example by a report generator that extracts archives) bypasses any string-based filter. Depending on routing, encoded separators can also matter; Express decodes %2F inside a param, so ..%2F..%2Fetc%2Fpasswd arrives as a single name.

A string blocklist can’t be fixed by adding more patterns. I’d resolve the path with path.resolve(ROOT, name), check containment with path.relative, then fs.realpath the result and check again so symlinks can’t escape. Better, I’d stop accepting names: look up reports by ID with an ownership check, store them under server-generated keys, and use res.sendFile(key, { root: '/srv/reports' }), whose root option also rejects traversal. Running the process with read access limited to the reports directory adds a final layer.

Further reading

More in Node.js

read ✓Node.js · hard

Node.js Password Hashing with scrypt

Hash passwords in Node.js with crypto.scrypt, a random salt and stored cost parameters, then verify logins with timingSafeEqual.

~7 min readread →
read ✓Node.js · mid

Node.js HTTP Security Headers

Add CSP, nosniff, Referrer-Policy and HSTS to every Node.js response from one function, error pages included, and roll out CSP safely.

~7 min readread →
read ✓Node.js · mid

Node.js URL and URLSearchParams

Build query strings with URLSearchParams, resolve relative URLs against a base, and check protocol and host before your server fetches.

~7 min readread →
esc