Chapter 3 of 7 · 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:
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.
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 URI | Correct outcome | What 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 outside | passes a raw prefix check unchanged |
file://localhost/etc/passwd | rejected: non-empty host | localhost/etc/passwd, resolved against cwd |
file://attacker.example/share | rejected: non-empty host | attacker.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 rejected | build-01/shared/app, resolved against cwd |
https://example.com/repo | rejected: unsupported scheme | https://example.com/repo as a literal path |
file:///srv/app/web?x=1#frag | rejected: query or fragment present | includes ?x=1#frag in the path |
not a uri | rejected: new URL throws | returned 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:
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 ← openedThe 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:
// 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