Skip to content
Chapter 5 of 7 · RootsUnguarded filesystem entry points

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.

ts
// 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");
}
ts
// 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.

Flowchart: read_file → safe-fs resolveInsideRoot; write_file → safe-fs resolveInsideRoot; list_directory → safe-fs resolveInsideRoot; safe-fs resolveInsideRoot → kernel; search_code → spawned ripgrep; spawned ripgrep → kernel; project_symbols → index built at startup; index built at startup → kernel; read_resource → glob, followSymbolicLinks: true; glob, followSymbolicLinks: true → kernel.

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../ traversalAbsolute outsideSymlink escape%2e%2e encodedPrefix siblingSecond path arg
read_filedenieddenieddenieddenieddeniedn/a
write_filedenieddenieddenieddenieddeniedn/a
list_directorydenieddenieddenieddenieddeniedn/a
move_filedenieddenieddenieddenieddeniedallowed
search_codeallowedallowedallowedallowedallowedn/a
project_symbolsallowedallowedallowedallowedallowedn/a
read_resourcedenieddeniedalloweddenieddeniedn/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.

ts
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.

json
{
  "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

Back to top