Chapter 4 of 7 · 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.
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:
$ 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/logsThe 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.
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.
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.
| Approach | Rejects ../ | Rejects symlink escape | Race-free | Cost per open |
|---|---|---|---|---|
startsWith(root) | no — /srv/app/webhooks passes | no | n/a | ~1 µs |
path.resolve + root + sep | yes | no | n/a | ~1 µs |
realpath + prefix check | yes | yes | no — 40–90 µs window | ~20 µs |
openat2(RESOLVE_BENEATH) | yes | yes | yes | ~5 µs, Linux 5.6+ |
Descriptor walk with O_NOFOLLOW | yes | yes | yes | ~3 µs per component |
Hard links and bind mounts
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