Skip to content
Chapter 3 of 7 · RootsConverting a root URI to a path

15 min read

Converting a root URI to a path

On this page

A URI is not a path with a prefix on it. It is a structured string with a scheme, an authority section, a percent-encoded path, and optional query and fragment components, and file:///srv/app/web is the one shape of it that looks like a path with four characters glued to the front. The check that decides whether a requested file lies inside a root compares filesystem paths, so something has to turn the URI the client sent into the path the check will use, and whatever that something does defines the boundary as surely as the comparison does.

Here is the conversion as it has shipped in more than one MCP server:

ts
const rootPath = root.uri.replace("file://", "");

It is wrong for four separate reasons, and each of them produces a different escape. Before any of that, though, notice the structural problem it creates: a perfect prefix comparison applied to a path you derived incorrectly is a perfect comparison against the wrong string.

Four ways a string-surgery conversion goes wrong

Take the four failures in the order they bite.

The first is percent-encoding. A file:// URI is a URI, which means every character outside a small unreserved set may legitimately appear in its %XX form, and a conforming client is allowed to emit file:///srv/app/my%20project for a directory whose name contains a space. If you strip the scheme and compare prefixes without decoding, then a directory literally named my%20project matches and the real one does not. Run the decode in the wrong order and it gets worse: comparing an incoming path against the root before decoding lets /srv/app/web/%2e%2e/%2e%2e/etc/shadow sail through, because as raw text it starts with /srv/app/web/ and contains no .. at all. Whatever decodes it later — the filesystem layer, an HTTP client, a subprocess argument — turns %2e%2e back into .. after the check has already passed.

The second is the host component. The // in file:// opens an authority section, and it is empty in the form everyone recognises, which is why the local form has three slashes. file://localhost/etc/passwd and file://attacker.example/share/data are both syntactically valid, and both have a non-empty authority. Strip the literal string file:// from either and you get localhost/etc/passwd and attacker.example/share/data, which are relative paths that will resolve against the process's working directory into something nobody intended.

The third is Windows. file:///C:/Users/dev/app is the drive-letter form, and the correct conversion drops the leading slash before the drive letter to produce C:\Users\dev\app. Naive stripping yields /C:/Users/dev/app, which is not a valid Win32 path. The two-slash form with a host, file://build-01/shared/app, is a UNC path — Universal Naming Convention, the \\server\share form Windows uses to name a directory on another machine — and converts to \\build-01\shared\app, which is the one case where a non-empty host is meaningful: it names a remote SMB share, and reaching it means network access rather than local file access.

The fourth is subtler and shows up as flakiness rather than escape. Path comparison on Windows and on macOS is case-insensitive by default, so /Users/Dev/App and /users/dev/app name the same directory while comparing unequal as strings. macOS goes further and normalises filenames to Unicode NFD on disk, the decomposed form in which an accented letter is stored as a plain letter followed by a separate combining mark, so a root whose URI encodes café as NFC — the composed form, where the same letter is a single codepoint, percent-encoded here as caf%C3%A9 — decodes to a five-byte string that will not byte-compare equal to the six-byte NFD form readdir hands you back. A check that rejects everything is a bug report; a check that rejects everything sometimes is a week of confusion.

Two of those four repay a slower reading, because in both cases the mechanism is a step smaller than the description. Take the host first. What makes the stripped result dangerous is not that it names a host, it is that the result is relative, and relative is what turns a mangled URI into a live path rather than an error. A path is absolute when it begins with a separator. In the three-slash form the authority is empty, so the separator that opens the path sits immediately after the two slashes of //; strip the literal file:// and that separator survives, and the path is still absolute. A non-empty authority occupies that same position — the host name is written exactly where the leading separator would otherwise be. Strip the same seven characters and what is left starts with a letter, every path API on the platform reads a string that does not start with a separator as relative, and it is resolved against the directory the process happens to be sitting in. That directory was not chosen by the server. It is inherited from whatever the client was in when it spawned the child.

The Unicode one is worth counting out, because the mismatch is small enough to look like a typo and is not. In the composed form, é is a single codepoint, and UTF-8 spells that codepoint in two bytes — which is precisely what %C3%A9 shows, one percent escape per byte. So café composed is five bytes: one each for c, a and f, and two for the é. In the decomposed form the same letter is two codepoints, a plain e and a combining acute accent, and UTF-8 spells those in one byte and two bytes respectively. So the decomposed name is six bytes. Nothing here is corrupt and nothing is missing — both strings are valid UTF-8 spellings of the same letter — but startsWith compares bytes, five bytes are not six bytes, and the check refuses a path inside the user's own root.

What a correct conversion looks like

Every platform's standard library already contains the parser you want, and the entire job is to use it and then refuse the cases it cannot represent.

ts
import { fileURLToPath } from "node:url";
import path from "node:path";

export function rootToPath(uri: string): string {
  const parsed = new URL(uri);              // throws on malformed input

  if (parsed.protocol !== "file:") {
    throw new Error(`unsupported root scheme: ${parsed.protocol}`);
  }
  if (parsed.host !== "") {
    throw new Error(`root URI has a host component: ${parsed.host}`);
  }
  if (parsed.search !== "" || parsed.hash !== "") {
    throw new Error("root URI carries a query or fragment");
  }

  // fileURLToPath percent-decodes and applies the platform's
  // drive-letter and separator rules.
  const decoded = fileURLToPath(parsed);

  // Normalise once, here, and never re-decode downstream.
  return path.resolve(decoded);
}

Three of those lines are refusals rather than conversions, and that ratio is the point. new URL() does the percent-decoding and the structural validation, fileURLToPath handles the drive letters and the leading-slash rule, and what is left for you is deciding which of the syntactically valid inputs you are willing to serve.

The host check deserves a note, because it is stricter than the RFC. RFC 8089 explicitly permits localhost as an authority and defines it as equivalent to the empty host, and Node's fileURLToPath follows that: it accepts file://localhost/etc/passwd and returns /etc/passwd, while throwing ERR_INVALID_FILE_URL_HOST for any other host on POSIX. Silently accepting the localhost form means a root URI and a path that share no visible prefix nonetheless denote the same file, which is exactly the kind of aliasing that makes a boundary hard to reason about. Rejecting every non-empty authority costs nothing, because clients emit the empty form, and it turns a URI you did not expect into an error rather than into a path.

There is a stumble waiting on the line above it, and it is worth naming because the code looks like it has already done the work. new URL() throwing on malformed input reads as validation, and the natural next thought is that anything it accepted is therefore safe to convert. It is not, and the reason is that the two words mean different things here. new URL() decides whether a string is a well-formed URI. file://attacker.example/share is a well-formed URI; so is https://example.com/repo; so is a file: URI carrying a query. Every one of them parses, and every one of them denotes something this server has no business serving. Syntactic validity tells you the string can be interpreted, never that the thing it denotes is one you meant to accept, and the three guards above exist precisely to make that second decision, which no parser can make on your behalf.

The rule underneath all four failures is one that web engineers already know from a neighbouring problem. Server-side request forgery (SSRF) filters that parse URLs with a regular expression and then hand the original string to an HTTP client are defeated by exactly this pattern: two parsers, disagreeing, with the check running on one interpretation and the effect happening on the other. Roots have the same structure. The parser that decides whether the path is inside the root must be the same parser whose output is passed to open(), and the string must be decoded exactly once, at the boundary, before anything compares it.

Stated as a property rather than as a rule, what the whole section is protecting is a one-to-one correspondence: after the conversion, each directory on disk should have exactly one spelling inside the server. Every failure above is a place where two representations of the same directory survive into the comparison, or where one representation is quietly read as two different directories. Percent-encoding gives a name an encoded spelling and a decoded one. The localhost authority gives a path a spelling with a host and one without. NFC and NFD give a name two byte sequences. A boundary check is a statement about equality of names, and equality is only meaningful once each thing has one name.

The forms worth having a test for

The negative tests here are cheap, because a URI-to-path converter is a pure function that needs no filesystem to exercise. A table of inputs and expected outcomes is a reasonable specification for the function and a reasonable test file.

Root URICorrect outcomeWhat the naive strip does
file:///srv/app/web/srv/app/web/srv/app/web — the one case it gets right
file:///srv/app/my%20project/srv/app/my project/srv/app/my%20project, a directory that does not exist
file:///srv/app/web/%2e%2e/%2e%2e/etc/etc, then rejected as outsidepasses a raw prefix check unchanged
file://localhost/etc/passwdrejected: non-empty hostlocalhost/etc/passwd, resolved against cwd
file://attacker.example/sharerejected: non-empty hostattacker.example/share, resolved against cwd
file:///C:/Users/dev/app (Windows)C:\Users\dev\app/C:/Users/dev/app, invalid
file://build-01/shared/app (Windows)\\build-01\shared\app, or rejectedbuild-01/shared/app, resolved against cwd
https://example.com/reporejected: unsupported schemehttps://example.com/repo as a literal path
file:///srv/app/web?x=1#fragrejected: query or fragment presentincludes ?x=1#frag in the path
not a urirejected: new URL throwsreturned verbatim

Ten cases, none of which needs a running server, and the last one is worth keeping even though it looks trivial: a converter that returns a plausible-looking string for garbage input is how a misconfiguration becomes a silently very wide root instead of a startup failure.

The table and the function check each other, and reading them together is a better use of five minutes than reading either alone. Split the rows into the ones whose correct outcome is a path and the ones whose correct outcome is a refusal. Every refusal row traces to exactly one line in rootToPath: the https row to the scheme guard, the localhost and attacker.example rows to the host guard, the query-and-fragment row to the third guard, and the not a uri row to new URL throwing before any guard runs. Run the correspondence the other way and it also closes: no guard in the function is without a row, and no refusal row is without a guard. Those two directions are worth separating, because they catch opposite mistakes. A refusal row with no guard behind it means the function is missing a check and the test will fail. A guard with no row in front of it means the check is untested, which is the one of the two that stays green.

Decoding twice is as bad as decoding too late

The ordering failure described above — comparing before decoding — has a mirror image that is harder to spot in review, because the offending code looks defensive rather than careless. A server that decodes the incoming path, checks it against the root, and then hands the string to a component that decodes again has re-opened the same hole from the other end. The input that exploits it carries its escape twice-encoded:

text
request path   /srv/app/web/%252e%252e/%252e%252e/etc/shadow
after decode 1 /srv/app/web/%2e%2e/%2e%2e/etc/shadow      ← checked; no ".." present, passes
after decode 2 /srv/app/web/../../etc/shadow              ← opened

The second decode is rarely a line anybody wrote deliberately. It arrives when the path is passed to a helper that accepts either a URI or a path and normalises defensively, when it is embedded in a URL for a downstream service, or when a framework decodes route parameters on the way in and the handler decodes them again out of habit. The rule that prevents it is the same one the forgery-filter analogy implies, stated as an invariant rather than a warning: decoding happens exactly once, at the process boundary, and every function inside the boundary takes a decoded path and is documented as doing so. A codebase that cannot say where its single decode lives has more than one.

Comparison needs a matching invariant on Unicode. Normalising both the root and the incoming path to the same form — NFC is the conventional choice — before any prefix comparison removes the macOS NFD mismatch described above, and it has to happen after decoding, since percent-decoding is what produces the codepoints in the first place.

The same rules apply when the server hands a URI back

Conversion runs in both directions. A server that exposes files as MCP resources publishes URIs of its own in resources/list, and those URIs are built from paths that came off the disk with no guarantee of being URI-safe. The failure is symmetrical to the parsing one and produces a resource nobody can read:

ts
// Wrong: a filename is not a URI component.
{ uri: "file://" + filePath, name: basename(filePath) }

// Right: the standard library knows the encoding rules.
import { pathToFileURL } from "node:url";
{ uri: pathToFileURL(filePath).href, name: basename(filePath) }

A file called report#2 draft.pdf illustrates every part of it. Concatenated, the URI reads file:///srv/app/web/report#2 draft.pdf, in which everything from the # onward is a fragment rather than part of the path, so a client that parses it correctly asks for report, gets ENOENT, and reports a missing file that is sitting on disk. The space is separately illegal and will be handled differently by different parsers. pathToFileURL produces file:///srv/app/web/report%232%20draft.pdf, which round-trips through fileURLToPath to the original name.

The round trip is the property worth testing, and it is cheap: for a set of awkward filenames — spaces, #, ?, %, an emoji, a name with a trailing dot, an accented character in both NFC and NFD — assert that fileURLToPath(pathToFileURL(p).href) equals p. Any name that fails that assertion is a name your server cannot address, and finding it in a unit test is considerably cheaper than finding it in a support ticket from the one team whose repository has a directory called c#.

Decisions the library will otherwise make for you

Two rows of that table describe decisions rather than facts. Whether to accept the UNC form at all is a policy question — a server whose job is to index a local workspace has no business reaching an SMB share, and rejecting \\host\share outright is defensible; a build server that genuinely operates on network mounts needs it. Whether to reject the localhost host or normalise it away is the same kind of question. The failure is not picking one answer over the other, it is having no answer written down, so that the behaviour is whatever the URL library happened to do.

Case is a third decision and it cannot be made portably, because there is no answer that is right everywhere. Lowercasing both sides makes the check correct on Windows and on a default macOS volume, and wrong on Linux and on a case-sensitive APFS volume, where /srv/App and /srv/app are genuinely different directories. Compare case-sensitively on POSIX and case-insensitively where the platform is, which means the comparison function has a platform branch in it and that branch has a test.

Both halves of that fail in a direction worth predicting, and the directions are not symmetrical. Comparing case-sensitively where the filesystem is not — lowercasing skipped on Windows — refuses paths that are genuinely inside the root, which is loud: the user sees an access-denied error for their own file and reports it. Comparing case-insensitively where the filesystem is not — lowercasing applied on Linux — accepts /srv/App/secret as though it were inside a root of /srv/app, and on Linux those are two directories that may hold entirely different things. That failure is silent, and it is one a boundary check exists to prevent. When only one branch of the platform test can be verified on the machine you are sitting at, the one to be certain about is the one whose mistake nobody reports.

Getting this step right does not make the boundary correct. It makes the boundary well-defined, which is a smaller claim: after rootToPath, you hold an absolute, decoded, platform-normalised directory path, and you know that every root you accepted was one you meant to accept. Everything the server does with incoming request paths is still ahead of you, and the incoming paths have their own encoding, their own .. segments and their own relationship to the root.

They also have a relationship to the disk, which a URI does not. The conversion in this chapter is pure string work — it never calls stat, never resolves a symbolic link, and cannot: a root URI can perfectly well name a directory that does not exist yet. The check that decides whether a requested file is inside the root is written in the same style, out of the same string-manipulation primitives, and that is where it goes wrong, because the filesystem has features that strings cannot see.

Roots — read the current version at https://daviddominguez.dev/learn/en/roots/converting-a-root-uri-to-a-path

Back to top