Chapter 5 of 7 · 15 min read
Unguarded filesystem entry points
On this page
A filesystem entry point is any place in a program where a path becomes a system call: a
readFile, a spawned subprocess, a glob library's own directory walk, a database of paths built
before the session started. A containment check is a function somebody has to remember to call
at each of them. Those two facts together are the whole of this chapter, and they are why a
server that passes every containment test you can write against its read path can still return
the contents of a directory you excluded.
Enforcement is therefore per code path rather than per server, and the distinction changes what a passing test proves. Guarding one entry point is a claim about one function; guarding a server is a claim about a set of entry points nobody has enumerated, and the enumeration is the work.
Where the second filesystem lives
Open a server whose read path refuses everything you throw at it and whose search path does not, and the divergence is visible in about thirty seconds, because it is not hidden and was never meant to be. The read path threads through the helper from the previous chapter. The search path does not exist in JavaScript at all.
// tools/read.ts
import { resolveInsideRoot } from "../safe-fs.js";
export async function readFile({ path: requested }: Args, ctx: Ctx) {
const safe = await resolveInsideRoot(ctx.rootPath, requested);
return fs.readFile(safe, "utf8");
}// tools/search.ts
import { execFile } from "node:child_process";
export async function searchCode({ query }: Args, ctx: Ctx) {
// ctx.workspaceDir was set at startup from process.cwd(),
// before the roots/list round trip ever happened.
const { stdout } = await execFileAsync(
"rg", ["--line-number", "--max-count", "50", query, ctx.workspaceDir],
);
return stdout;
}A separate process, given a directory that came from somewhere else, doing its own openat calls,
several thousand of them, none of which passes through a line of code that has heard of a root. The
check in safe-fs.ts could be openat2 with RESOLVE_BENEATH and a formal proof attached and it
would make no difference here.
The variants are all the same shape underneath. A server that maintains a symbol index builds it once
at startup by walking a directory chosen before the client answered roots/list, and afterwards every
query is served out of SQLite without touching the filesystem — so the index is a snapshot of whatever
the walk reached, and the root check guards a door into a room that has already been photographed. A
server using a glob library calls fast-glob or globby directly, which does its own readdir and,
with followSymbolicLinks left at its default of true, walks straight out of the tree. A server that
shells out to git grep inherits the repository root from git rev-parse --show-toplevel, which is by
construction above the package you rooted.
The photographed room in that first variant is a metaphor and worth marking as one, because
it is accurate about the thing that matters and misleading about one thing that also
matters. What it gets right is the direction of the guarantee: a check on the door has no
authority over a copy made before the check existed, and querying the index is not a
filesystem operation at all, so there is nothing for a path check to intercept. Where it
breaks is permanence. A photograph is taken once; an index is usually rebuilt on a timer or
on a file-watcher event, which means the unguarded walk is not a single historical mistake
but a recurring one, running in the background while the guarded tools behave impeccably in
the foreground. The git grep variant has the same shape without the delay — the boundary
is decided by a program that computed it from the repository, not from anything the client
sent.
Three tools guarded, three not, one process.
Why the gap opens by default rather than by accident
The number of places you have to remember grows with every feature. Node alone offers node:fs,
node:fs/promises, the synchronous variants, fs.createReadStream, child_process in four flavours,
import() of a path, require.resolve, and every dependency that wraps one of those. A server with
eleven tools might have four distinct filesystem entry points, and the check guards the one the author
was thinking about when they wrote it.
The pull request that adds the gap is also, almost always, a good pull request. Someone measures that
the JavaScript directory walk takes 4.8 seconds on a 40,000-file repository and that ripgrep does the
same job in 180 milliseconds, swaps the implementation, ships a 26× improvement, and has changed the
security properties of the server without touching the security code. Nothing in the diff mentions
roots. A reviewer looking for "did they weaken the path check" sees an untouched safe-fs.ts and
approves.
The improvement figure is worth converting to one unit before comparing, because a ratio between a number of seconds and a number of milliseconds is the easiest place in this chapter to be off by a thousand. Four point eight seconds is four thousand eight hundred milliseconds. Dividing that by one hundred and eighty gives a shade under twenty-seven, which is the twenty-six-fold figure in the sentence above, and the direction is worth stating out loud: the new implementation is that many times faster, so the ratio has the big number on top. That is a change nobody sensible refuses. It is also, precisely because nobody sensible refuses it, the change most likely to travel through review with its second effect unexamined.
Two further shapes are worth adding to the mental inventory because they escape the obvious version of
the test as well. Operations with two path arguments — move_file, copy_file, create_symlink — are
frequently checked on the source and not the destination, which turns a write tool into an
arbitrary-write tool: move_file from inside the root to /etc/cron.d/anything passes a source-only
check. And create_symlink, if it exists at all, lets the caller manufacture the exact condition the
previous chapter spent its length on, from inside the root, using a permitted operation.
Error text is the quiet one. A server that returns
ENOENT: no such file or directory, open '/srv/secrets/prod.key' for a path outside the root, and Access denied - path outside allowed directories for one
that exists, has built a working oracle — a way of asking yes-or-no questions
about files whose contents it never returns — for probing the filesystem it was
supposed to be scoped away from. Roughly 200 requests will map the interesting
parts of a home directory. The fix is that both cases return the same string.
Two hundred sounds like a lot of work until the mechanism is spelled out, and the mechanism is the reason the fix is a one-line one. Each request costs the caller one guess and returns one of two distinguishable strings, so each request answers exactly one yes-or-no question: does this path exist. Nothing has to be brute-forced, because the list of paths worth guessing on a developer machine is short and public — the credential and key files, the shell history, the config directories of half a dozen well-known tools. A couple of hundred guesses covers it, and the confirmations are what make the next step worth attempting. That is why identical text closes the channel: with one string for both outcomes, the answer no longer varies with the question, so the request stops carrying a bit of information. It closes the channel the message opened, which is the whole of the exposure only if the message is the only thing that differs — where a caller can also time the two paths, or measure their size, the same reasoning has to be run again on those.
The matrix, run against everything
The test artefact that catches this class is a matrix rather than a list, with one row per operation the
server exposes and one column per input shape, and the discipline is that the row set comes from
tools/list at runtime rather than from a hand-maintained array.
| Operation | ../ traversal | Absolute outside | Symlink escape | %2e%2e encoded | Prefix sibling | Second path arg |
|---|---|---|---|---|---|---|
read_file | denied | denied | denied | denied | denied | n/a |
write_file | denied | denied | denied | denied | denied | n/a |
list_directory | denied | denied | denied | denied | denied | n/a |
move_file | denied | denied | denied | denied | denied | allowed |
search_code | allowed | allowed | allowed | allowed | allowed | n/a |
project_symbols | allowed | allowed | allowed | allowed | allowed | n/a |
read_resource | denied | denied | allowed | denied | denied | n/a |
Read down the columns and every column has a failure; read across the rows and the failures cluster by
implementation rather than by input shape, which is the useful signal. search_code and
project_symbols fail uniformly because they never reach the check at all, and the single anomaly in
read_resource — traversal denied, symlink allowed — says that path is running an older prefix check
rather than the realpath version, most likely because resource URIs are resolved in a different module.
Both of those readings are inferences, and the step from the marks in the table to the
diagnosis is short enough to walk. Start from what a check that ran can look like. A
running check evaluates the input, so its result depends on the input: some shapes defeat it
and others do not, and its row is therefore mixed. A check that never ran cannot depend on
the input at all, so its row is uniform. Now read the two anomalous rows against that.
search_code is allowed in every one of the five input columns — five different shapes,
identical outcome — which is what a row looks like when the input was never consulted, and
the only way an input is never consulted is if no check saw it. read_resource denies four
shapes and allows one. That is a mixed row, so a check ran; it just does not resolve
symlinks, which narrows the diagnosis to which check rather than whether. The two
failures wear the same word in the table and are different defects, and it is the shape of
the row rather than any single cell that separates them.
Generating the rows from the live tool list is what keeps the matrix honest as the server grows.
const { tools } = await client.listTools();
const attacks = buildAttackInputs(root); // ../, absolute, symlink, %2e%2e, sibling
for (const tool of tools) {
const pathArgs = pathShapedArgs(tool.inputSchema); // params named path, dir, file, target…
if (pathArgs.length === 0) continue;
for (const arg of pathArgs) {
for (const attack of attacks) {
test(`${tool.name}.${arg} rejects ${attack.label}`, async () => {
const res = await client.callTool(tool.name, { [arg]: attack.value });
expect(res.isError).toBe(true);
expect(text(res)).toBe("Access denied - path outside allowed directories");
});
}
}
}
test("every tool is covered or explicitly exempted", () => {
const uncovered = tools.filter(t => !covered.has(t.name) && !EXEMPT.has(t.name));
expect(uncovered).toEqual([]);
});The last assertion is the one that survives contact with a growing codebase, because it fails the build
the day someone adds a twelfth tool, and the only ways to make it pass are to cover the tool or to write
its name in EXEMPT where a reviewer can see the decision. A matrix that has to be extended by hand
degrades one release at a time; a matrix that fails closed does not.
There is a second technique that finds what no matrix imagined, which is to stop
asking the server and ask the kernel. Run the process under a system-call
tracer, which records every request the program makes of the kernel regardless
of which library or subprocess issued it:
strace -f -e trace=openat,openat2 -o trace.log on Linux, or
fs_usage -w -f filesys on macOS, drive a realistic session through it, then
filter the opened paths against the root. A representative run of a
code-indexing server over a fifteen-minute session opened 1,842 distinct paths,
of which 47 were outside the root: 38 belonged to Node's module loader reaching
into
node_modules, six were the process reading its own configuration under ~/.config,
and three were the index walking a sibling package. The 38 are expected and the
three are the finding. That inventory takes twenty minutes and requires no
cooperation from the server's authors, which makes it the right first move on a
server you did not write.
The property that makes that run usable as evidence is one you have to check rather than assume, and it is easy to check: the three categories add up. Thirty-eight plus six plus three is forty-seven, which is the whole of the out-of-root set, with no fourth line and no remainder. That closing is what licenses the conclusion. If the categories had summed to less than the total, the leftover opens would be a set of unexplained filesystem accesses whose size you knew and whose nature you did not — and a residue of unknown reads is exactly the thing this exercise exists to rule out. Because there is no residue, every out-of-root open has an owner, and the three become a specific defect in a specific component instead of a sample from a population of unknown size.
The middle category deserves a word, since the sentence above moves straight past it. The
six reads under ~/.config are expected in the sense that the process is reading its own
settings, and they are not a bug in the server. They are also the clearest demonstration in
the run that the root bounds nothing: a well-behaved component, doing an ordinary thing,
opened files well outside the declared workspace, and the only reason nobody minds is the
intent behind the read. Change the intent and not one thing in the trace has to change,
which is the whole argument of chapter 2, on roots and OS-level sandboxes, appearing as a
line in a log rather than as a claim.
Making the check hard to skip
Fixing the individual gaps is the smaller half of the work, because the same gaps reopen with the next
feature unless the shape of the code changes. The structural version is a single module that owns every
filesystem entry point, exports only root-aware operations, and is the one place node:fs and
node:child_process are imported.
{
"rules": {
"no-restricted-imports": ["error", {
"paths": [
{ "name": "node:fs", "message": "Use ../safe-fs.js — it enforces the root." },
{ "name": "node:fs/promises", "message": "Use ../safe-fs.js." },
{ "name": "node:child_process", "message": "Use ../safe-exec.js." }
],
"patterns": ["fast-glob", "globby", "glob"]
}]
}
}With that rule in place and an override permitting the raw imports inside safe-fs.ts alone, adding a
tool that walks the filesystem its own way stops being a decision someone makes silently and becomes a
lint error with a message attached.
The search fix itself is worth spelling out, since a spawned subprocess cannot be routed through a
JavaScript helper. What it can be given is the resolved root as its search directory and no ability to
leave it: run rg with cwd set to the resolved root, pass --max-depth and the pattern with no
user-controlled path argument at all, and rely on ripgrep's default of not following symbolic links — it
skips symlinked directories unless -L is passed, which is one of the rare cases where a tool's default
is the safe one. Any user-supplied path fragment has to go through resolveInsideRoot before it reaches
the argument list, because a path parameter that reaches execFile unchecked is the same escape as
before with a different spelling.
Filtered search results, and the limits of a green matrix
Ripgrep's other defaults create a subtler confusion. It honours .gitignore and skips hidden files, so a
search for STRIPE_SECRET inside a correctly rooted directory will not report the .env sitting in that
same directory, while read_file on .env returns it happily. Search results are a filtered view rather
than a map of what the server can read, and treating a clean search as evidence that a secret is not
reachable is a conclusion the tool never supported. When the search tool is the one an auditor is looking
at, run it with --hidden --no-ignore once to see the difference — on one repository that changed a 3-hit
result into a 19-hit result, twelve of them in ignored build output.
Subtracting gives sixteen results the default run never showed, of which twelve sat in
ignored build output and four came from elsewhere in what the defaults hide. The ratio is
the number to carry away:
the default view was three matches out of nineteen, under a fifth of what was there. It is
worth being precise about what the missing sixteen are and are not. They are not files the
server is unable to read — read_file on any of them returns the contents. They are matches
the search tool declined to report, on rules that were designed to keep a developer's grep
output tidy and have nothing to do with any boundary. So a clean search result supports the
claim this pattern does not appear in the files ripgrep chose to look at and no claim at
all about what the server can reach — and on this repository the second set of results came
back more than six times the size of the first.
Suppose the gaps close: search_code routes through the same helper, move_file checks both arguments,
the resource path gets the realpath version, and the whole matrix is green across every tool with the
fail-closed coverage assertion in place. What has been established is that every code path the test suite
can reach, in this version, consults the boundary and enforces it correctly on the input shapes anyone
thought of.
Each of those qualifiers is doing work. A test suite reaches code paths through the tool interface, and a server also has startup code, background timers, telemetry, update checks and dependency initialisers, none of which take a tool call to run. "This version" is a claim with a shelf life measured in weeks. And the input shapes are the ones an honest engineer imagined while writing tests against a program that, in this framing, is assumed to be trying.
The matrix measures the server's cooperation at increasing resolution, which is a genuinely useful thing to
measure and is not the same as the property an auditor is asking about. A server that wants a file does not
need traversal syntax, an encoded separator or a symlink; it calls open("/Users/dev/.aws/credentials") on
a code path no tool invokes. The next chapter is about what you show someone who asks whether that read can
succeed.
Roots — read the current version at https://daviddominguez.dev/learn/en/roots/unguarded-filesystem-entry-points