home intel cve-2026-6321-fast-uri-path-traversal-normalization-bypass
CVE Analysis 2026-05-04 · 7 min read

CVE-2026-6321: fast-uri Normalizes Encoded Traversal Past Policy Boundaries

fast-uri ≤3.1.0 decodes percent-encoded slashes and dot segments before applying RFC 3986 dot-segment removal, allowing attacker-controlled URIs to collapse onto paths outside enforced prefixes.

#path-traversal#url-normalization#percent-encoding#authorization-bypass#directory-traversal
Technical mode — for security professionals
▶ Attack flow — CVE-2026-6321 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-6321Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

CVE-2026-6321 is a path-normalization bypass in fast-uri, the high-performance URI parser used heavily in the Fastify ecosystem. The library's normalize() and equal() functions incorrectly decode percent-encoded path separators (%2F) and parent-directory references (%2E%2E) before the RFC 3986 dot-segment removal algorithm runs. The result: a URI that looks syntactically confined under an allowed prefix (e.g., /api/v1/) can normalize to an entirely different path. Any middleware or gateway that calls normalize() or equal() on attacker-supplied input before making an authorization decision is vulnerable.

CVSS 7.5 (HIGH). No authentication required. No exploit has been observed in the wild as of publication. Versions ≤ 3.1.0 are affected; 3.1.1 contains the fix.

Root cause: normalize() and equal() call decodeURIComponent() on path segments prior to dot-segment removal, so %2F and %2E sequences are resolved as real path characters before any policy check is applied.

Affected Component

Package: fast-uri (npm) — fastify/fast-uri
Functions: normalize(), equal() (both delegate to the same internal normalization pipeline)
Versions affected: ≤ 3.1.0
Fixed in: 3.1.1 (commit referenced in GHSA-q3j6-qgpj-74h6)
CVSSv3.1 vector: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Root Cause Analysis

RFC 3986 §5.2.4 defines the dot-segment removal algorithm and is explicit: it operates on the raw, syntax-level path string. Percent-encoded octets are not equivalent to their decoded counterparts at the syntax layer — %2F is a data octet that happens to share a byte value with /, but must never be treated as a path delimiter during normalization.

The vulnerable code in fast-uri 3.1.0 violated this ordering. Below is a reconstructed pseudocode representation of the internal normalization path, with the exact defect annotated:

// fast-uri <=3.1.0 — internal _normalizePath() pseudocode
// Real JS function: normalize() -> _normalizePath(components)
function _normalizePath(components) {
    let path = components.path;

    // BUG: pct-decode happens HERE, before dot-segment removal.
    // %2F becomes '/', %2E%2E becomes '..' — now indistinguishable
    // from real structural separators and parent references.
    path = decodePercentEncoded(path);   // BUG: premature decode

    // Dot-segment removal now operates on the decoded string.
    // An attacker-controlled '../' produced by decoded %2E%2E/%2F
    // is processed identically to a literal '../' in the original URI.
    path = removeDotSegments(path);      // RFC 3986 §5.2.4 — too late

    components.path = path;
    return components;
}

// equal() calls normalize() on both sides before comparison —
// same decode-first defect is inherited.
function equal(uriA, uriB, options) {
    let nA = normalize(uriA, options);  // BUG: both sides decode-then-normalize
    let nB = normalize(uriB, options);
    return (serialize(nA) === serialize(nB));
}

The internal removeDotSegments() implementation faithfully follows RFC 3986 §5.2.4 — the bug is entirely in the call ordering. Once decodePercentEncoded() has run, the input to removeDotSegments() contains synthetic / and .. characters that were never present in the original serialized URI.

Exploitation Mechanics

The primitive is straightforward to weaponize against any path-prefix authorization check that normalizes before comparing. The following chain describes a concrete exploitation scenario against a Fastify-based API gateway.

EXPLOIT CHAIN:

1. Target application enforces: normalize(requestURI).startsWith("/api/v1/public/")
   Anything not matching this prefix is rejected with HTTP 403.

2. Attacker crafts URI:
     /api/v1/public/%2E%2E%2F%2E%2E%2Fadmin/config

   Raw path (as transmitted):
     /api/v1/public/%2E%2E%2F%2E%2E%2Fadmin/config

3. Application calls fast-uri normalize() on the raw URI.

4. Inside _normalizePath():
   a. decodePercentEncoded() runs first:
        /api/v1/public/../../admin/config
   b. removeDotSegments() runs on decoded form:
        /admin/config

5. normalize() returns "/admin/config" — outside the allowed prefix.
   BUT: the startsWith("/api/v1/public/") check was already passed
   against the pre-normalize form by a naive implementation, OR
   the policy check operates on the normalize() return value and
   the attacker has forced a collapse to a restricted path.

6. Depending on application logic, the attacker reaches /admin/config
   with a request that appeared syntactically valid to prefix checks.

ALTERNATE: equal()-based bypass
1. Policy checks: equal(normalize(requestPath), "/api/v1/public/safe-resource")
2. Attacker sends: /api/v1/public/%2E%2E%2F%2E%2E%2Fpublic/safe-resource
   from a different subtree that resolves identically after decode+normalize.
3. equal() returns true; policy passes a request to the wrong handler.

A minimal Node.js proof-of-concept demonstrating the normalization collapse:

# Equivalent Python to demonstrate the decode-before-normalize logic
from urllib.parse import unquote
import posixpath

crafted = "/api/v1/public/%2E%2E%2F%2E%2E%2Fadmin/config"

# Vulnerable order (fast-uri <=3.1.0 behaviour):
decoded_first = unquote(crafted)          # '/api/v1/public/../../admin/config'
normalized    = posixpath.normpath(decoded_first)  # '/admin/config'
print("Vulnerable:", normalized)          # -> /admin/config  ← BYPASSES PREFIX CHECK

# Correct order (fast-uri 3.1.1 behaviour):
# Dot-segment removal on raw form, then decode only pct-encoded *data* octets
normalized_raw = posixpath.normpath(crafted)  # leaves %2E%2E%2F intact as data
decoded_after  = unquote(normalized_raw)
print("Patched:   ", decoded_after)       # -> /api/v1/public/%2E%2E%2F%2E%2E%2Fadmin/config
                                          #    or correctly: /api/v1/public/....%2Fadmin/config

Memory Layout

This is a logic vulnerability rather than a memory corruption bug; there is no heap state to corrupt. The "state" that matters is the URI component structure as it transitions through the normalization pipeline. Showing the URI object state instead:

URI COMPONENT STATE — fast-uri <=3.1.0

BEFORE normalize():
  components.scheme   = "https"
  components.host     = "target.example.com"
  components.path     = "/api/v1/public/%2E%2E%2F%2E%2E%2Fadmin/config"
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                         pct-encoded traversal — should be treated as opaque data

AFTER decodePercentEncoded() [BUG — runs before dot-segment removal]:
  components.path     = "/api/v1/public/../../admin/config"
                                         ^^  ^^
                         synthetic '..' and '/' now visible to removeDotSegments()

AFTER removeDotSegments() [operates on already-decoded form]:
  components.path     = "/admin/config"
                         ^^^^^^^^^^^^^
                         COLLAPSED — no longer under /api/v1/public/

CORRECT STATE (3.1.1 — dot-segment removal on raw, decode after):
  components.path     = "/api/v1/public/%2E%2E%2F%2E%2E%2Fadmin/config"
  after removeDotSegments (no real '..' or '/' to remove):
                        = "/api/v1/public/%2E%2E%2F%2E%2E%2Fadmin/config"
  after decode of data octets only:
                        = "/api/v1/public/....%2Fadmin/config"  (traversal neutralized)

Patch Analysis

The fix in 3.1.1 (GHSA-q3j6-qgpj-74h6) inverts the operation order. Dot-segment removal is applied to the raw, still-encoded path string. Only after the RFC 3986 §5.2.4 algorithm completes are the remaining percent-encoded octets decoded — and only those that represent non-reserved, non-structural characters.

// BEFORE (vulnerable — fast-uri <=3.1.0):
function _normalizePath(components) {
    let path = components.path;

    path = decodePercentEncoded(path);   // BUG: decode first
    path = removeDotSegments(path);      // dot-removal on decoded form

    components.path = path;
    return components;
}

// AFTER (patched — fast-uri 3.1.1):
function _normalizePath(components) {
    let path = components.path;

    // FIX: dot-segment removal operates on the raw encoded path.
    // %2F is NOT a separator; %2E%2E is NOT a parent reference.
    path = removeDotSegments(path);      // RFC 3986 §5.2.4 on raw form

    // Only decode octets that are safe to decode (unreserved chars).
    // Reserved chars (%2F, %3A, etc.) remain encoded.
    path = decodeSafePercentEncoded(path);  // FIX: selective decode after

    components.path = path;
    return components;
}

The secondary fix applies the same correction inside equal()'s normalization pass, which was independently vulnerable because it constructed a temporary normalized form for string comparison using the same defective pipeline.

Detection and Indicators

Log-based detection: look for request paths containing %2E, %2F, or %5C sequences adjacent to each other or to literal dots and slashes, particularly in paths that cross authorization boundaries.

DETECTION PATTERNS (regex, apply to raw request URI path):

# Encoded dot-dot-slash traversal variants:
(%2E%2E|%2e%2e)[%2F%5C/\\]
[./\\](%2E|%2e)[%2F%5C/\\]
(%2F|%5C)(%2E%2E|%2e%2e)(%2F|%5C|$)

# Mixed encoded/literal traversal:
\.\.%2[Ff]
%2[Ee]\.%2[Ff]
%2[Ee]%2[Ee]%2[Ff]

EXAMPLE MALICIOUS PATHS TO TEST AGAINST normalize():
  /allowed/%2E%2E%2Frestricted
  /allowed/%2e%2e%2frestricted
  /allowed/%2E%2E/%2E%2E/restricted
  /allowed/..%2Frestricted
  /allowed/%2E./restricted

If you are running fast-uri and need to verify your installed version's behaviour before patching:

// Node.js canary test — returns true if vulnerable
const { normalize } = require('fast-uri');
const result = normalize('https://host/a/b/%2E%2E%2Fc');
const vulnerable = !result.includes('%2E') && result.includes('/a/c') || result === 'https://host/a/c';
console.log('Vulnerable:', vulnerable);
// Patched 3.1.1: result preserves %2E%2E encoding -> not collapsed
// Vulnerable <=3.1.0: result = 'https://host/a/c' -> traversal succeeded

Remediation

Immediate: Update fast-uri to 3.1.1 or later.

npm install fast-uri@latest
# or pin to exact safe version:
npm install fast-uri@3.1.1

# Verify:
node -e "const u=require('fast-uri');console.log(u.normalize('https://h/a/%2E%2E/b'))"
# Expected (patched): path retains encoded form / does not collapse to /b

Architectural mitigations (defense-in-depth, not a substitute for patching):

  • Never perform authorization decisions on the output of a single normalize call over raw attacker input. Validate before and after normalization.
  • Reject requests containing any percent-encoded path separator (%2F, %5C) or dot sequence (%2E) at the ingress layer, before routing or authorization. Most applications have no legitimate need for these in path segments.
  • If using Fastify, confirm your router version pulls in a patched fast-uri transitively: npm ls fast-uri.
  • Add the canary test above to your CI pipeline against the installed version.

Packages that transitively depend on fast-uri (Fastify core, @fastify/router, and several Fastify plugins) should be updated simultaneously to ensure the patched version is resolved by npm/yarn rather than a cached older copy nested in node_modules.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →