Skip to content
Chapter 4 of 7 · RootsLexical path checks and realpath

14 min read

Lexical path checks and realpath

On this page

The check that decides whether a requested file is inside a root is a small function with an obvious shape: resolve the request against the root, then require the result to start with the root. Written that way it is correct about three kinds of input and blind to a fourth, and the fourth is the one that reaches production, because nothing about it looks like an attack — a .. in a path is visibly suspicious, and a symbolic link placed by an operations ticket eighteen months ago is visibly ordinary.

The gap is that a path is a name and a file is an object, and the mapping between them is maintained by the kernel rather than by the string. A directory entry can be a pointer to somewhere else entirely, and the kernel dereferences that pointer during path lookup, inside open, after your check has returned. Everything below follows from that one fact: which rejections survive it, what asking the disk costs, and why asking the disk still leaves a window open.

The two rejections that do work

Start with the check as written, because most of it is right and the parts that are right are worth being precise about.

ts
import path from "node:path";
import fs from "node:fs/promises";

// rootPath came from rootToPath() in the previous chapter:
// absolute, decoded, platform-normalised.
export function assertInsideRoot(rootPath: string, requested: string): string {
  const resolved = path.resolve(rootPath, requested);

  if (resolved !== rootPath && !resolved.startsWith(rootPath + path.sep)) {
    throw new Error(`Access denied - path outside allowed directories`);
  }
  return resolved;
}

export async function readFile(rootPath: string, requested: string) {
  const safe = assertInsideRoot(rootPath, requested);
  return fs.readFile(safe, "utf8");
}

path.resolve collapses . and .. segments lexically and produces an absolute path, so /srv/app/web/../../etc/shadow becomes /etc/shadow, which fails the prefix test and is refused. A bare relative path such as src/cart.ts resolves against the root and stays inside it. An absolute path handed in directly, /etc/shadow, resolves to itself and is refused for the same reason.

Worth being precise about what "collapses lexically" means, because the word doing the work is lexically. The function walks the segments left to right onto a stack: an ordinary name is pushed, a . is discarded, and a .. pops whatever is on top. Feed it /srv/app/web/../../etc/shadow and the stack holds srv, app, web, then the first .. pops web and the second pops app, then etc and shadow are pushed, and joining what is left gives /etc/shadow. Every one of those operations is defined on the text of the path. None of them asks whether web exists, whether it is a directory, or what it points at — which is exactly why the result is trustworthy for the three shapes above and worthless for the fourth.

The + path.sep is doing real work and is the single most commonly omitted character in this function. Without it, resolved.startsWith("/srv/app/web") is true for /srv/app/webhooks/secret.pem, because string prefixes do not know about directory boundaries. That sibling directory is a plausible thing to exist next to a web root, and the escape needs no traversal syntax at all — it is a legitimate absolute path to a real file, and a check missing four characters hands it over. The resolved !== rootPath clause before it exists so that a request for the root directory itself, which has no trailing separator, still passes.

So the function correctly refuses lexical traversal, correctly refuses absolute paths outside the tree, and correctly refuses the prefix-sibling trick. A test file of nineteen cases, each exercising one of those three shapes, comes back green.

Where the string resolver and the kernel disagree

path.resolve performs no system calls, opens no file descriptors, and has no way of knowing whether any component of the path it produced exists, let alone what kind of object it is. Node's documentation describes it as resolving a sequence of paths into an absolute path, and the implementation is a loop over segments with a stack — the same computation you would do with a pencil. The filesystem has a feature that pencils do not:

bash
$ ls -l /srv/app/web/
drwxr-xr-x  8 app app  4096 Nov  3 04:11 src
drwxr-xr-x  3 app app  4096 Nov  3 04:11 public
lrwxrwxrwx  1 app app     9 Apr 17  2024 uploads -> /var/lib

$ node -e 'console.log(require("path").resolve("/srv/app/web/uploads/logs"))'
/srv/app/web/uploads/logs

$ readlink -f /srv/app/web/uploads/logs
/var/lib/logs

The two commands disagree, and the disagreement is the bug. path.resolve says the request stays inside the root, so assertInsideRoot returns it. fs.readFile then hands the same string to openat(AT_FDCWD, "/srv/app/web/uploads/logs/audit-2024-11.log", O_RDONLY), and the kernel walks it component by component: srv, app, web, then uploads, which is a symlink, at which point lookup restarts from /var/lib and continues. The descriptor that comes back refers to a file the check never evaluated.

That "restarts" is the whole difference between the two commands, and it is a property of the kernel's lookup loop rather than an accident of this filesystem. A symbolic link is a directory entry whose contents are a path. When lookup reaches one and there are components still to consume, the kernel cannot continue through it, because there is nothing to continue through — a link holds text, not a set of child entries. So it takes the stored path, makes that the new starting point, and re-enters the loop with the remaining components appended. Applied here, uploads holds /var/lib, the remaining components are logs/audit-2024-11.log, and the walk resumes from /var/lib/logs. The prefix the check verified, /srv/app/web, was consumed and thrown away three components before the file was reached, and no rule anywhere obliges the restarted walk to land under it.

This is the same class of mistake as the one in the previous chapter's URI conversion — two components disagreeing about what a string denotes, with the check running on one interpretation and the effect happening on the other. There it was two parsers; here it is a string resolver and a kernel. The pattern is worth naming because it predicts where to look next: any time a boundary is enforced on a representation that something downstream will re-interpret, the boundary is only as good as the agreement between them.

Asking the disk instead

The fix is to make the check use the same resolution the kernel will use, which means a system call.

ts
import fs from "node:fs/promises";
import path from "node:path";

export async function resolveInsideRoot(rootPath: string, requested: string) {
  const lexical = path.resolve(rootPath, requested);

  // realpath() dereferences every symlink in the path, using the same
  // lookup rules open() will use a moment later.
  let real: string;
  try {
    real = await fs.realpath(lexical);
  } catch (err) {
    // ENOENT for a file being created: resolve the parent instead,
    // and require the parent to be inside the root.
    if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
    const parent = await fs.realpath(path.dirname(lexical));
    real = path.join(parent, path.basename(lexical));
  }

  const realRoot = await fs.realpath(rootPath);
  if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
    throw new Error("Access denied - path outside allowed directories");
  }
  return real;
}

Two details in there matter more than they look. The root itself has to be passed through realpath as well, because if /srv/app/web is itself reached through a symlink — a deployment layout where /srv/app is a link to /srv/releases/2024-11-03 is entirely ordinary — then every resolved request will compare against a root string that no real path ever starts with, and the server rejects everything it is supposed to allow. The ENOENT branch exists because realpath fails on paths that do not exist yet, and a server that can write files has to be able to check a destination before creating it; resolving the parent and appending the final component is the standard way, and it is correct as long as the parent's own symlinks are resolved, which they are.

The cost is a handful of extra system calls per request. realpath on a six-component path performs on the order of six lstat-equivalent lookups internally; on a warm dentry cache — the kernel's in-memory record of directory entries it has looked up recently — this measures at roughly 15–25 µs on Linux, against roughly 1 µs for the pure-userspace path.resolve. For a server handling a few dozen tool calls a minute, that difference is not detectable. For a search implementation calling the check once per candidate file across 40,000 files, it is about one extra second per scan, which is enough to change how you structure the walk — resolve the directory once and use descriptor-relative operations beneath it rather than re-resolving every leaf.

That second is worth arriving at rather than accepting, because the shape of the multiplication is what tells you when the cost matters. Take the top of the measured band, twenty microseconds, as the per-call figure. A microsecond is a millionth of a second, so a thousand calls cost a fiftieth of a second and forty thousand calls cost forty times that — eight tenths of a second, which is the "about one second" above. Do it with the difference rather than the total and almost nothing moves: subtracting the microsecond the lexical check already cost leaves nineteen, and forty thousand of those is just over three quarters of a second. The reason the same change is invisible on the tool-call path is the other factor. A few dozen calls a minute is a few dozen multiplications of the same twenty microseconds, which is under a thousandth of a second spread across sixty. Per-call cost did not change between the two cases; the number of calls did, by more than three orders of magnitude, and that is the only term that moved.

The gap between realpath and open

Switching to realpath closes the escape above and does not close the class, because realpath and open are two separate system calls with a gap between them, and the filesystem is shared mutable state. Between the moment the kernel answers the realpath question and the moment it answers the open question, anything with write access to a directory in that path can replace a component.

Sequence: Server → Kernel (realpath(/root/w/data)); Kernel → Server (/root/w/data (inside root, allowed)); Attacker (writes in root) → Kernel (rename(w/data, w/tmp)); Attacker (writes in root) → Kernel (symlink(/etc, w/data)); Server → Kernel (open(/root/w/data/shadow)); Kernel → S: fd; S: fd → /etc/shadow.

That is a time-of-check-to-time-of-use race, written TOCTOU wherever it is discussed, and it has been the standard example in the security literature on setuid programs, which run with their owner's privileges rather than the caller's, since the late 1980s, long enough that the mitigations are well developed and none of them is "check harder". The window in the code above is the time between the two calls: on a lightly loaded machine, measured with a timestamp on either side, roughly 40–90 µs. That sounds unwinnable until you notice the attacker gets unlimited attempts — a loop alternating rename and symlink runs at well over 100,000 iterations per second, and the agent can be induced to retry a failed read — so a race that succeeds one time in a thousand succeeds within seconds. Winning it requires the ability to write inside the root, which is exactly what an agent with a write_file tool provides to anyone who can influence its instructions. The threat model where this matters is not a stranger on the network; it is content the model was asked to read.

Two numbers there are worth putting side by side, because the intuition that a microsecond-scale window is safe comes from comparing it to human time rather than to the attacker's clock. A hundred thousand iterations per second is one iteration every ten microseconds. The window is forty to ninety microseconds. So the attacker's swap does not have to be threaded through a gap — the gap is four to nine of their iterations wide, and the link is put back in place several times over while the server sits between its two syscalls. That is what makes "unlimited attempts" the wrong consolation rather than the wrong fact: each attempt is closer to a coin flip than to a lottery ticket, and even the pessimistic one-in-a-thousand above resolves in seconds.

It also explains why the table's window figure is larger than its cost figure, which looks like a contradiction and is not. The cost row measures one realpath call. The window measures something else: the interval from that call returning to the next syscall beginning, which in Node contains the hop out of the threadpool, an event-loop turn back into JavaScript, the prefix comparison, a promise resolution, and the descent into readFile. Those are two different quantities, and there is no reason the second should be smaller than the first. A useful consequence follows: the window is not something a faster path check shrinks, because almost none of it is the path check.

The real closure is to stop resolving paths as strings at all and let the kernel enforce containment during lookup. Linux 5.6 added openat2 with RESOLVE_BENEATH, which fails the open if resolution would leave the starting directory, and RESOLVE_NO_SYMLINKS, which fails if it would traverse any symlink; both are evaluated atomically inside the single syscall, so there is no window. FreeBSD has O_RESOLVE_BENEATH. The portable approximation is to open the root directory once, keep the descriptor, and walk each component with openat(dirfd, component, O_NOFOLLOW | O_DIRECTORY), which never lets a relative lookup escape and never follows a link. Node exposes none of this directly, which is why most JavaScript servers stop at realpath; a native addon or a Go or Rust implementation can do better.

ApproachRejects ../Rejects symlink escapeRace-freeCost per open
startsWith(root)no — /srv/app/webhooks passesnon/a~1 µs
path.resolve + root + sepyesnon/a~1 µs
realpath + prefix checkyesyesno — 40–90 µs window~20 µs
openat2(RESOLVE_BENEATH)yesyesyes~5 µs, Linux 5.6+
Descriptor walk with O_NOFOLLOWyesyesyes~3 µs per component

A symbolic link is a path stored in a file, which is why resolving it works: there is a name to follow and realpath follows it. A hard link is a second directory entry pointing at the same inode — the on-disk record that holds a file's data blocks, permissions and link count, and which has no name of its own — with nothing to follow and no target to resolve. If /srv/app/web/notes.txt is a hard link to /etc/shadow, then realpath returns /srv/app/web/notes.txt — that is the canonical path, as far as canonicalisation is defined — the prefix check passes, and the read returns the shadow file. No amount of path resolution detects this, because there is no path to detect; the two names are equally real.

What distinguishes the two entries is the inode they share, so the check that catches it is fstat on the opened descriptor and a comparison against expectations of st_dev, the device the file lives on, and st_nlink, the number of names pointing at that inode, or a policy of refusing any regular file with st_nlink > 1 inside a root. In practice most servers accept this exposure, and the reason is that creating a hard link to a file requires write access to the target directory and, on Linux with fs.protected_hardlinks enabled — the default on most distributions since 2012 — read and write permission on the source file itself. An attacker who can hard-link /etc/shadow into your root could already read /etc/shadow. The case that does matter is a multi-user machine or a shared build agent where somebody else's process can plant links inside a directory you later root.

Bind mounts and network mounts produce the mirror image. A directory inside the root can be a mount point for an entirely different filesystem — /srv/app/web/data bind-mounted from /var/lib/postgres, a bind mount being one that grafts an existing directory into a second place in the tree so that both names lead to the same storage, or an NFS export, or a macOS volume — and realpath reports it as inside the root because it is inside the root by every naming rule the kernel has. Path resolution is answering a question about names and the exposure is a question about storage, and the two only coincide when nobody has arranged otherwise. Checking st_dev on the resolved path against the root's own st_dev catches the crossing and also, inconveniently, catches every legitimate mount a developer has inside their project, which is why the useful version of this check is a startup-time inventory of mount points beneath each root, logged once, rather than a per-request refusal.

Every step down the table above makes the server more correct, and none of them makes the server trustworthy, which is a distinction worth keeping separate from the engineering. All of this work happens inside the server's own code, on the code path that chose to call the check. It improves the behaviour of a program that is trying to honour the boundary. A program that is not trying skips the function entirely, and no amount of openat2 in a helper module affects a call site that never invokes the helper.

That is also the honest limit of a green test suite. Nineteen cases proved that assertInsideRoot refuses three shapes of input; they proved nothing about readFile, and nothing at all about the other ten tools in the same server. Assume you now write the symlink test, and the hard-link one, and the race one, and they all pass for read_file. The next chapter is about the request that comes back with data from a sibling package anyway, through a tool in the same process that never called any of this.

Roots — read the current version at https://daviddominguez.dev/learn/en/roots/lexical-path-checks-and-realpath

Back to top