path.resolve() lets absolute user input escape your root directory
published
TL;DR
path.resolve(root, userInput) throws the root away the moment userInput is absolute. path.resolve('/srv/uploads', '/etc/passwd') is /etc/passwd, not /srv/uploads/etc/passwd. The result contains no .., so a “does it contain dot-dot” check passes it and a log line looks normal. Validate by checking the resolved path is still inside the root — with path.relative(), not with a string prefix.
The problem
Here is the shape almost every file-serving handler ends up with:
import path from 'node:path';
import { readFile } from 'node:fs/promises';
const ROOT = '/srv/uploads';
export async function getFile(userPath) {
const full = path.resolve(ROOT, userPath);
if (full.includes('..')) throw new Error('nope'); // the usual guard
return readFile(full);
}
Traversal with .. is handled. Now call it with an absolute path:
await getFile('/etc/passwd');
// reads /etc/passwd
ROOT was silently discarded. The guard never fired, because there is no .. in /etc/passwd. On Windows the same call with C:\Windows\win.ini behaves identically, and so does \\server\share\file — a UNC path is absolute too.
What makes this one nasty is that it does not look like an escape anywhere downstream. There is no /srv/uploads/../../etc/passwd in the logs to catch the eye, no encoded %2e%2e, no suspicious segment. The access log shows a plain read of a plain path.
Why it happens
This is documented behaviour, not a bug. From the Node docs on path.resolve():
The given sequence of paths is processed from right to left, with each subsequent path prepended until an absolute path is constructed. […] If, after processing all given
pathsegments, an absolute path has not yet been generated, the current working directory is used.
Right to left, stopping as soon as it has an absolute path. userInput is processed first; if it is already absolute, ROOT is never consulted.
path.join() has completely different semantics — it concatenates all segments and then normalizes, with no notion of one segment overriding another:
| Call | POSIX result |
|---|---|
path.join('/srv/uploads', 'a.txt') | /srv/uploads/a.txt |
path.join('/srv/uploads', '/etc/passwd') | /srv/uploads/etc/passwd |
path.resolve('/srv/uploads', 'a.txt') | /srv/uploads/a.txt |
path.resolve('/srv/uploads', '/etc/passwd') | /etc/passwd |
The two agree on relative input and disagree completely on absolute input, which is exactly why this survives testing: every fixture anyone writes by hand is relative.
And join is not a fix by itself. path.join('/srv/uploads', '../../etc/passwd') normalizes to /etc/passwd just fine. join is safe against absolute input, resolve is safe against nothing; neither is a containment check.
What to do
Resolve first, then prove the result is inside the root. path.relative() is the check, because it works on normalized paths and answers the actual question:
import path from 'node:path';
import { readFile } from 'node:fs/promises';
const ROOT = path.resolve('/srv/uploads');
function safeJoin(root, userPath) {
const full = path.resolve(root, userPath);
const rel = path.relative(root, full);
// Outside the root: relative() returns something starting with '..',
// or an absolute path when there is no relative route at all
// (different Windows drive letters).
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('path escapes root');
}
return full;
}
export async function getFile(userPath) {
return readFile(safeJoin(ROOT, userPath));
}
Three details in there that matter:
rel.startsWith('..')catches..and../x, but on Windows it also has to handle the case whererootandfullare on different drives. Therepath.relative()returns an absolute path rather than a chain of.., hence the second condition.- Do not use
full.startsWith(root)./srv/uploads-public/secretstarts with/srv/uploadsas a string and is a different directory. If you insist on a prefix check, compare againstroot + path.sep— butpath.relative()is less to get wrong. ROOTis resolved once at module scope. A relative root would otherwise be interpreted againstprocess.cwd(), which changes depending on where the process was started.
If the input arrives as a URL path rather than a filesystem path, decode it before any of this — %2e%2e%2f is ../ and your check runs on whatever string you actually hold.
Node also ships fs.realpath() for the other half of the problem:
const real = await fs.realpath(safeJoin(ROOT, userPath));
if (path.relative(ROOT, real).startsWith('..')) throw new Error('symlink escapes root');
path operates purely on strings and knows nothing about the filesystem, so a symlink at /srv/uploads/evil pointing to /etc passes every check above. If untrusted parties can create files inside the root — an upload directory, an extracted archive — resolve symlinks too.
Caveats
- This is about
node:path, but the same asymmetry exists elsewhere. Python’sos.path.join('/srv/uploads', '/etc/passwd')returns/etc/passwd, matching Node’sresolve, not Node’sjoin. Do not carry an intuition across languages. path.resolve()is the right function for many jobs — turning a CLI argument into an absolute path is exactly its purpose. The bug is using it as a containment operation. It was never that.- Checking the resolved path does not make the read authorized. Staying inside
/srv/uploadsstill lets one tenant read another tenant’s upload if that is all the check does. fs.realpath()costs a syscall and throwsENOENTif the path does not exist yet, so it does not fit a write path unchanged — resolve the parent directory instead.- TOCTOU still applies: a symlink can be swapped between your
realpathcheck and youropen. For hostile multi-tenant input, hold a directory handle and use thefs.promises.FileHandlepositional APIs rather than re-opening by path.