Secretus logo

Four CVEs, One Bug: fast-uri and the Parser Differential Problem

·9 min read

The fast-uri package has now accumulated four separate host-confusion advisories. The most recent, CVE-2026-18446 (High, CVSS 7.5, published 31 July 2026), is patched in 2.4.4, 3.1.5 and 4.1.2. If you run Node and have ever validated a URL before fetching it, this advisory family is worth ten minutes of your attention — not because of one library, but because of the mistake underneath all four.

That mistake is a parser differential: two pieces of code read the same string and disagree about what it means. One of them makes the security decision. The other one makes the request.

What host confusion actually looks like

The clearest example comes straight from the CVE-2026-18446 advisory. Resolve the reference \\evil.com/path against the base https://allowed.com/:

  • fast-uri produces https://allowed.com/%5C%5Cevil.com/path — host allowed.com, backslashes percent-encoded into the path.
  • Node's WHATWG URL produces https://evil.com/path — host evil.com.

Both are defensible readings of a messy input. RFC 3986 says an authority begins with a literal //, and fast-uri implemented exactly that. The WHATWG URL Standard, which browsers and Node's fetch(), undici and http/https clients follow, treats \ as interchangeable with / for the special schemes: http, https, ws, wss, ftp and file. Neither parser is broken in isolation. The vulnerability lives in the gap between them.

Now put that gap inside a real application. A server validates a user-supplied URL with fast-uri, sees allowed.com, and approves the request. It then hands the original string to fetch(), which resolves it to evil.com and connects there. The allow-list was enforced against one destination and the request went to another. That is server-side request forgery through nothing more exotic than a punctuation disagreement.

The same bug, four times

What makes this family instructive is that it kept coming back through different syntax. Each advisory is a different way of writing the same idea:

  • CVE-2026-13676 — failed IDN canonicalization. http://127。0。0。1/ uses ideographic full stops. fast-uri kept them literal; WHATWG and fetch() canonicalize them to 127.0.0.1. A loopback denylist checks a hostname that never gets used.
  • CVE-2026-16221 — literal backslash authority delimiter. A single \ where the parsers disagree about whether the authority has ended.
  • GHSA-v39h-62p7-jpjc — percent-encoded authority delimiters. The same trick, hidden one encoding layer down.
  • CVE-2026-18446 — backslash authority introducer. \\, /\ and \/ as substitutes for //.

Four patches, four correct fixes, one unchanged root cause: a library that implements RFC 3986 is being used to gate a runtime that implements WHATWG. Patch four and a fifth encoding will eventually turn up. The durable fix is structural, and we will get to it below.

Why a build-only dependency still counts

Here is the part worth sitting with. In our own dependency tree, fast-uri is nowhere near production code. It arrives four levels down a build-tool chain — a PWA plugin, which pulls a service-worker builder, which pulls a JSON schema validator, which pulls fast-uri — and it is never shipped to a browser. It is, by the usual triage shorthand, “just a devDependency.”

That shorthand is worth retiring. A build-time dependency runs with your build machine's privileges, reads your source tree, and produces the artifact your users execute. The reason a dev-only URL-parsing flaw is lower risk than a production one is not that it cannot matter — it is that reaching it requires an attacker to already influence what your build feeds the parser. That is a real reduction in exposure. It is not zero, and it is not a reason to leave a High advisory sitting in the tree when the upgrade is a one-line lockfile change with no API surface.

We took the patch the day it surfaced. The interesting part was not the fix. It was how we found out.

A gate that cannot run is not a gate

Our CI pipeline runs a dependency-audit step that fails the build on any unapproved high or critical advisory, with a narrow, expiring exception file for advisories that have been reviewed and shown not to apply. It is a good control. It was also, at the moment this advisory landed, not running — CI had been rejected upstream for an unrelated billing reason, before any runner started.

So the gate was green in the sense that mattered least: nothing was reporting failure. No red X, no alert, no signal at all. The finding surfaced only because a human ran the audit by hand during a scheduled review.

This is the failure mode we now watch for above all others, because it is the one that looks exactly like success:

  • A check that is disabled reports nothing, and nothing reads as fine.
  • A check that never starts produces no failure to alert on.
  • A check that runs against the wrong target passes honestly and proves nothing.

The lesson generalizes past dependency audits. For every automated control you rely on, you need a second signal that answers a different question: not “did it pass?” but “did it run at all, recently, against the thing I think it ran against?” A dead-man alarm on the pipeline itself. A last-success timestamp you can look at. A deliberate failure injected on a schedule to confirm the alarm still fires. Absence of a failure signal is not evidence of health.

Finding parser differentials in your own stack

The fast-uri family is a specific instance of a pattern you can go looking for deliberately. Ask one question of every security check in your codebase: is the component that validates this value the same component that later acts on it? Wherever the answer is no, you have a candidate.

  1. Find the validate/use pairs. Grep for URL allow-lists, redirect validators, SSRF filters, webhook destination checks and outbound-proxy routing. For each, identify which library parses during validation and which one performs the request.
  2. Validate the parsed object, not the string. This is the structural fix. Parse the input once with the same parser that will act on it — in Node, the WHATWG URL — then run your policy against the resulting url.hostname, and pass the reconstructed url object onward. Never re-parse the original string downstream. A differential needs two parsers; give it one.
  3. Resolve DNS before you trust the host. Host-based allow-lists are weak on their own: allowed.com can resolve to 169.254.169.254, and a redirect can move the request after the check. Validate the resolved IP, re-validate on every redirect hop, and prefer an egress proxy that enforces the policy at the network layer, where string parsing cannot reach it.
  4. Fuzz the boundary. Feed both parsers the same corpus — backslashes, mixed slashes, percent-encoded delimiters, Unicode look-alike dots, userinfo @ tricks, embedded newlines, uppercase schemes — and assert that the extracted host matches. Any input where they disagree is a finding, with or without a CVE.
  5. Look past URLs. The same shape appears in JSON parsers disagreeing about duplicate keys, HTTP servers and proxies disagreeing about Content-Length versus Transfer-Encoding (request smuggling), path normalizers disagreeing about .. and encoded separators, and content-type checks disagreeing with the browser about what a MIME string means — a case we wrote about separately in the upload-filter bypass hiding in a charset parameter.

What to do this week

  1. Run npm audit by hand, today, on every repository. Not in CI — by hand, so you learn whether CI has been telling you the truth.
  2. If fast-uri appears anywhere in your tree, upgrade to at least 2.4.4, 3.1.5 or 4.1.2 depending on your major. Transitive copies count; check the lockfile, not the manifest.
  3. Confirm your audit gate ran in the last 24 hours, and can prove it.
  4. Pick your single most important URL allow-list and make it validate a parsed object rather than a string.

None of these is difficult. The fourth one is the only permanent fix, and it is the one that survives the fifth advisory in this family — which, on the evidence of the first four, is a question of when rather than whether.

Sources

Share a secret the safe way

Start a 14-day trial to send; recipients open one-time links without an account.

Try Secretus