~/blog

path.resolve() lets absolute user input escape your root directory

published

#node#security#path

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 path segments, 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:

CallPOSIX 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:

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

References