A bouncer checks only forward slashes, but the turnstile quietly converts backslashes into slashes
The @fastify/reply-from plugin checks proxy paths for dot-segments using forward-slash separators, then builds the outgoing URL with the WHATWG URL parser. Under http, https, ws, and wss, that parser normalizes backslash characters and their percent-encoded form (%5c) into forward slashes. An attacker can therefore send %5c..%5c..%5cadmin or similar after a trusted prefix; the validation step sees an innocuous path, but the constructed upstream URL escapes the prefix and points at admin or another endpoint. Affected versions are up to and including 12.8.0; fixed in 12.8.1, which rejects any segment resolving to single or double dot plus control characters and ambiguous whitespace before URL construction. There is no workaround other than upgrading.
The vendor severity HIGH at 8.2 is fair for the worst plausible internet-facing API gateway/BFF use: unauthenticated remote, low complexity, and confidentiality impact can span many upstream endpoints. The real-world population is narrower, because this only matters where the application passes user-controlled path segments into the target URL behind a prefix or relies on an upstream route guard before forwarding; many Fastify apps use fixed rewrites or never expose the proxy publicly. The lack of KEV listing and no observed in-the-wild exploitation pull the operational ceiling below the pure CVSS math, but the plausible high-value use as an internal API gateway keeps this above MEDIUM. No live primary URL for this CVE was available for independent confirmation, so the assessment leans on the authoritative description supplied in the prompt.
4 steps from start to impact.
Find a reachable Fastify app using prefix-scoped reply-from
/api/v1/... forwarding to http://internal:9000/api/v1/.... This is the prerequisite for the traversal to matter.- Application uses @fastify/reply-from <=12.8.0
- Proxy target is built from the request path after a trusted prefix or behind a route guard
- Attacker can reach the proxy endpoint directly or via an internal request
- Many Fastify deployments use fixed path mappings and do not pass user-controlled path segments to reply-from
- The proxy may be an internal service not reachable from the internet
- Not all applications expose a prefix-scoped proxy path
Send backslash dot-segment payload past the forward-slash check
--path-as-is or Burp Repeater to send GET /api/v1/%5c..%5c..%5cadmin HTTP/1.1. @fastify/reply-from validates the path only for forward-slash dot-segments, so the encoded backslashes pass. The WHATWG URL parser then normalizes each %5c or raw \ into /, producing http://internal:9000/api/v1/../../admin or similar.- Target supports HTTP/HTTPS/WS/WSS schemes (WHATWG normalization applies)
- Encoded/raw backslash reaches Node.js URL parser
- Upstream path after prefix contains enough depth for
..segments
- Some WAFs or reverse proxies reject or normalize encoded backslashes before they reach Fastify
- URL path parsing differences in ingress may strip or decode
%5cinconsistently - The attacker needs a valid prefix with at least one traversable segment
Upstream URL resolves outside the intended prefix
- The upstream server has a resource at the traversed path
- The upstream does not independently re-check the normalized path
- The HTTP method of the request is allowed for that endpoint
- Many upstream services use additional authentication, CSRF, or allow-list routing that blocks unknown paths
- The app may reject paths containing
%2e%2ebefore reply-from if a later guard normalizes after the check - Non-sensitive endpoints may be the only reachable targets
%5c traversal attempts; upstream access logs may show normalized backslash paths.Read unintended upstream data or trigger limited integrity changes
- The upstream endpoint contains sensitive data or permits state-changing methods
- The proxy passes response back to the attacker
- No independent authorization check on the upstream blocks the request
- Modern API gateways and service meshes often enforce mTLS or auth at upstream, which reduces direct exposure
- If only GET is permitted and endpoints are read-only, integrity is nil
- Sensitive data may be split across endpoints, requiring multiple requests and path guesses
@fastify/reply-from to 12.8.1 — This is the only complete fix; it normalizes paths and rejects dot segments, control characters, and ambiguous whitespace before URL construction. Deploy as the noisgate remediation SLA for HIGH — vendor patch within 180 days, but we recommend much sooner for internet-facing APIs. If exposure is confirmed, treat as CRITICAL-style emergency patch.%5c, \, and %2e%2e before Fastify prevents the bypass at the edge. But custom normalizers can be bypassed, so this is containment, not a fix.- Relying on route guards in front of
reply-fromwithout validating the normalized incoming path — the guard sees the raw path before URL normalization and will miss backslash dot-segments. - Disabling encoded slashes only at the HTTP server — WHATWG URL normalization still converts backslashes or
%5cto slashes during URL construction. - Generic WAF path-traversal signatures for
../and%2e%2e/— the backslash and percent-encoded variants require explicit canonicalization rules.
The supporting signals.
| CVE ID | CVE-2026-117518 |
|---|---|
| In-the-wild status | No known exploitation; not in CISA KEV as of disclosure date |
| PoC availability | No public PoC identified; a raw request is trivial to replicate manually once a reachable prefix-scoped proxy is found |
| EPSS | Not scored or not yet published; application-specific exploitation likely keeps it low |
| KEV status | Not listed |
| CVSS vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:N |
| Affected versions | @fastify/reply-from <=12.8.0 |
| Fixed versions | 12.8.1; upgrade required, no workaround other than upgrade |
| Exposure / scanning | No Shodan/Censys/GreyNoise signature for this npm plugin; scanning must be package-level via SBOM |
| Disclosure date | 2026-11-02; reporter not disclosed in available record |
Sources.
Why this verdict
- Role multiplier: if deployed as an internet-facing API gateway or BFF fronting internal services, this bug turns an unauthenticated request into a multi-endpoint data disclosure primitive; that high-value role keeps the floor at HIGH even though many instances are low-consequence. Includes
@fastify/reply-fromas an app-level edge component reaching internal admin/data APIs. - Friction-adjusted: vendor CVSS 8.2 assumes any unauthenticated client can reach the vulnerable path; actually it needs an application that forwards user-controlled path after a prefix or relies on a guard, which is common but not universal.
- No KEV or observed exploitation: no known in-the-wild use and no public PoC as of disclosure date, so the score declines slightly below the CVSS arithmetic to 7.3.
Why not higher?
Not CRITICAL because the affected package is not a canonical security control or data tier; it is a plugin/library where high-value use depends on the application design. The impact is primarily confidentiality with limited integrity, and there is no known active exploitation or supply-chain pivot. An attacker still needs a reachable prefix-scoped proxy and a sensitive upstream endpoint; not a default condition on most Fastify apps.
Why not lower?
Not MEDIUM because the bug is remotely triggerable without authentication and with low complexity; the WHATWG backslash normalization is deterministic. When used as an API gateway/BFF in front of internal APIs, the blast radius includes arbitrary upstream endpoint access, mass data disclosure, and possible limited integrity changes. The affected package is widely deployed in the Fastify ecosystem and patch adoption may lag.
Crowdsourced verification payload.
Run this on the project directory or CI build agent that has the application's package-lock.json or node_modules. Invoke with python3 check_reply_from.py /path/to/project (or pass the lockfile directly). No special privileges are required; it only reads package metadata. Outputs VULNERABLE, PATCHED, or UNKNOWN and exits 1/0/2 respectively.
#!/usr/bin/env python3
'''
Check whether @fastify/reply-from is <= 12.8.0.
Usage:
python3 check_reply_from.py /path/to/project
python3 check_reply_from.py /path/to/package-lock.json
Exit codes:
0 -> PATCHED
1 -> VULNERABLE
2 -> UNKNOWN
'''
import json
import os
import re
import sys
VULNERABLE_MAX = (12, 8, 0)
def parse_version(ver):
if not ver:
return None
ver = ver.strip().lstrip('v')
ver = re.split('[+-]', ver)[0]
nums = re.findall('[0-9]+', ver)
if not nums:
return None
nums = nums[:3]
while len(nums) < 3:
nums.append('0')
return tuple(int(n) for n in nums[:3])
def is_vulnerable(ver):
parsed = parse_version(ver)
if parsed is None:
return None
return parsed <= VULNERABLE_MAX
def check_package_lock(path):
if not os.path.isfile(path):
return None
with open(path, 'r', encoding='utf-8', errors='ignore') as fh:
data = json.load(fh)
version = None
pkgs = data.get('packages', {})
if 'node_modules/@fastify/reply-from' in pkgs:
version = pkgs['node_modules/@fastify/reply-from'].get('version')
elif '@fastify/reply-from' in pkgs:
version = pkgs['@fastify/reply-from'].get('version')
deps = data.get('dependencies', {})
if not version and '@fastify/reply-from' in deps:
version = deps['@fastify/reply-from'].get('version')
return version
def check_node_modules(pkg_path):
if os.path.isfile(pkg_path):
with open(pkg_path, 'r', encoding='utf-8', errors='ignore') as fh:
data = json.load(fh)
return data.get('version')
return None
def find_reply_from_pkg(root):
for dirpath, dirnames, filenames in os.walk(root):
if os.path.basename(dirpath) == 'reply-from' and os.path.basename(os.path.dirname(dirpath)) == '@fastify':
return os.path.join(dirpath, 'package.json')
return None
def main():
target = sys.argv[1] if len(sys.argv) > 1 else '.'
version = None
if os.path.isfile(target):
if target.endswith('package-lock.json') or target.endswith('npm-shrinkwrap.json'):
version = check_package_lock(target)
elif target.endswith('package.json'):
print('UNKNOWN: direct package.json is not sufficient for transitive @fastify/reply-from')
sys.exit(2)
else:
version = check_node_modules(target)
elif os.path.isdir(target):
version = check_package_lock(os.path.join(target, 'package-lock.json'))
if version is None:
version = check_package_lock(os.path.join(target, 'npm-shrinkwrap.json'))
if version is None:
pkg = find_reply_from_pkg(target)
if pkg:
version = check_node_modules(pkg)
if version is None:
print('UNKNOWN: @fastify/reply-from not found in provided path')
sys.exit(2)
vuln = is_vulnerable(version)
if vuln is True:
print(f'VULNERABLE: @fastify/reply-from {version} is <= 12.8.0')
sys.exit(1)
elif vuln is False:
print(f'PATCHED: @fastify/reply-from {version} is > 12.8.0')
sys.exit(0)
else:
print(f'UNKNOWN: cannot parse version {version}')
sys.exit(2)
if __name__ == '__main__':
main()