A proxy that files two different envelopes into the same mailbox because it forgot to draw a line between the address and the room number
@fastify/reply-from builds an internal URL cache key by concatenating dest + source with no delimiter. That means the pair dest=http://127.0.0.1:3100 + source=1/private collides with dest=http://127.0.0.1:31001 + source=/private — both hash to the same string and resolve to the same upstream URL object. When an app uses getUpstream() to route between security-separated backends, an attacker who can influence either the path or the upstream selector can steer a request to the *wrong* upstream and read/write data it should not touch. Affected versions are 8.3.1 through 12.6.3; fixed in 12.6.4 (GHSA-v574-6498-x57v).
The vendor's HIGH / 8.7 rating is defensible on paper — it's a classic CWE-441 confused-deputy bug with S:C scope-change and C:H/I:H. But the CVSS math doesn't reflect how narrow the trigger is: the collision only fires when two upstream URLs happen to overlap as prefixes (a port-number or hostname where one is a lexical prefix of another) AND the app dispatches between them dynamically via getUpstream. Most real deployments proxy to one backend, or to backends with cleanly distinct hostnames. For the shops that *do* hit this pattern — multi-tenant gateways numbered sequentially — the score is accurate. For everyone else it's noise. We're calling it MEDIUM.
4 steps from start to impact.
Identify a vulnerable reply-from deployment
@fastify/reply-from <= 12.6.3. The proxy must be configured with dynamic upstream selection — either getUpstream() returning different backends per request, or multiple reply.from() calls to different hosts.- Public or reachable Fastify gateway
- Version <=12.6.3
- Dynamic multi-upstream config
- Most reply-from installs proxy to a single backend and never call getUpstream
- Fingerprinting the plugin externally is non-trivial — no distinct banner
Discover a colliding upstream URL pair
dest+source produces the same string as another dest'+source'. In practice this means adjacent port numbers (:3100 / :31001), sequential hostnames (api1/api10), or similar prefix overlap.- Knowledge of at least two upstream URLs
- URLs must be prefix-compatible
- Ability to reason about path handling
- Requires source-code access, verbose errors, or lucky guessing
- Well-designed backend naming (UUID, distinct domains, non-adjacent ports) makes collision impossible
Craft path that poisons or hits the cached collision
source path, when concatenated with the chosen upstream's dest, matches the cache key of a different upstream+path pair. The library returns the *cached* upstream object from the first request, silently rerouting traffic to the wrong backend.- Attacker-controlled path or upstream selector input
- Cache warmed or warmable by attacker
disableCache: truein plugin registration kills the whole class of attack- Path normalization middleware upstream (WAF, reverse-proxy) may reshape the request
Read or write data belonging to another tenant / security zone
- Misrouted backend does not re-authenticate the caller
- Backends handle sensitive per-tenant or per-zone data
- Zero-trust backends that verify JWT/mTLS per request neutralize the impact
- If both backends serve the same tenant scope, no security boundary is crossed
The supporting signals.
| CVE | CVE-2026-16158 / GHSA-v574-6498-x57v |
|---|---|
| CWE | CWE-441 — Unintended Proxy or Intermediary (Confused Deputy) |
| Vendor CVSS | 8.7 HIGH — CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:N (scope-changed, high conf/integ, no availability) |
| Affected versions | @fastify/reply-from 8.3.1 → 12.6.3 (also downstream @fastify/http-proxy which wraps it) |
| Patched version | 12.6.4 — cache key now uses delimited concatenation |
| Disclosed | 2026-07-18 by *mcollina* (Matteo Collina, Fastify maintainer) |
| KEV | Not listed on CISA KEV |
| EPSS | No EPSS score yet (published today) — expect <0.1% given library scope and preconditions |
| Exploitation in the wild | None observed. Same-day disclosure, no PoC circulating, no honeypot activity |
| Exposure telemetry | GreyNoise/Shodan/Censys — no fingerprint for reply-from at the network layer; downloads via npm ~200k/week but active getUpstream users are a small subset |
| Workaround | Set disableCache: true at plugin registration — closes the bug at the cost of per-request URL parsing |
noisgate verdict.
Downgraded from HIGH 8.7 to MEDIUM 5.4 because the single most decisive factor is the extremely narrow architectural precondition — the bug only fires when two upstream URLs share a lexical prefix (e.g., :3100 vs :31001) AND the app dynamically routes between them via getUpstream(). In the majority of reply-from deployments there is one backend or backends with cleanly distinct hostnames, and the collision cannot be constructed at all.
Why this verdict
- Preconditions are stacked and rare: dynamic
getUpstream()routing + prefix-colliding upstream URLs + attacker-controlled path. Missing any one of the three and the bug is unreachable. - No availability impact and no code execution: worst case is cross-tenant data disclosure or state change on the wrong backend — bad, but not fleet-collapse bad.
- Zero-trust backends neutralize impact: any backend that revalidates JWT/mTLS instead of trusting the proxy makes the misroute observable but not exploitable.
- Role multiplier — API gateway / edge proxy: if reply-from is fronting a multi-tenant SaaS and terminates auth at the gateway, the chain succeeds with tenant-crossover impact. That is a real high-value role — but it's a small share of the install base and the URL-prefix precondition still gates it. Floor stays MEDIUM.
- Role multiplier — single-backend proxy or dev tool: chain does not succeed; no collision possible. Majority of installs.
- Same-day disclosure, no exploitation observed: patch is out, no active KEV pressure, no public weaponized PoC beyond the advisory's illustrative example.
Why not higher?
Not HIGH because the exploit chain requires a *specific* application architecture (dynamic multi-upstream routing between security-separated backends) AND *specific* upstream URL naming that produces the collision. That intersection is rare enough that most reply-from shops are not actually reachable. HIGH would imply this is a broad-blast-radius bug that patch teams should treat as a 30-day emergency — the facts don't support that.
Why not lower?
Not LOW because when the preconditions *are* met — multi-tenant gateway with sequentially-named backends — the impact is genuine cross-security-zone confused-deputy access, exactly the CWE-441 nightmare. It merits scheduled patching and a config audit, not silent burial in the backlog.
What to do — in priority order.
- Set
disableCache: trueat plugin registration — This is the vendor-endorsed mitigation. It disables the vulnerable URL cache entirely, at the cost of parsing the URL on every request (measurable but usually tolerable overhead). Ship this in a config-only change now; no mitigation SLA required at MEDIUM but treat it as low-risk hotfix within days if you route across security boundaries. - Audit
getUpstream()and multi-upstream configs for prefix-colliding URLs — Enumerate every distinct upstream URL your reply-from instances can route to. Confirm no pair produces the samedest+sourceconcatenation for any reachable path. If you find a collision candidate, rename the host or port immediately (e.g., move:31001to a non-adjacent value). Complete within the 365-day remediation window; sooner if you're multi-tenant. - Enforce backend-side re-authentication — Every backend behind the proxy should validate the caller's identity (JWT signature + audience, mTLS client cert) instead of trusting the fact that traffic came from the gateway. This turns a successful misroute into a 401, neutralizing the confused-deputy chain entirely. This is standard zero-trust hygiene independent of the CVE.
- Upgrade
@fastify/reply-fromto 12.6.4+ and@fastify/http-proxyto the version pulling the fix — Definitive fix. Version bump only, no API change. Schedule inside the next dependency sweep — no mitigation SLA at MEDIUM, target within the 365-day remediation window (sooner is trivial since this is a semver-safe bump).
- WAF path-normalization rules — the collision is in the proxy's *cache key*, not in a URL-encoding trick, so no ModSecurity/Cloudflare rule will spot it.
- Rate limiting — a single well-crafted request triggers the misroute; there is no burst to throttle.
- TLS or mTLS at the edge — protects the wire, does nothing about server-side cache key logic.
- Egress filtering — the misrouted traffic goes to a *legitimate* backend, just the wrong one; it will pass any allow-list.
Crowdsourced verification payload.
Run on any host that has your Node.js app checked out or installed. Invoke as ./check-reply-from.sh /path/to/app (defaults to CWD). No elevated privileges required — this is a package-manifest audit. Exits 0 PATCHED, 1 VULNERABLE, 2 UNKNOWN.
#!/usr/bin/env bash
# noisgate check: CVE-2026-16158 @fastify/reply-from URL cache key collision
# Vulnerable: 8.3.1 through 12.6.3 Patched: 12.6.4+
set -u
APP_DIR="${1:-$PWD}"
cd "$APP_DIR" 2>/dev/null || { echo "UNKNOWN: cannot cd $APP_DIR"; exit 2; }
vercmp() {
# returns 0 if $1 < $2 (i.e. vulnerable), 1 otherwise
printf '%s\n%s\n' "$1" "$2" | sort -V | head -1 | grep -qx "$1"
}
is_vuln() {
local v="$1"
# strip leading ^, ~, =, v, whitespace
v="${v#[\^~=v]}"
v="${v## }"
[ -z "$v" ] && return 2
# vulnerable if 8.3.1 <= v <= 12.6.3
if vercmp "$v" "8.3.1"; then return 1; fi # v < 8.3.1 -> not in range (older, unaffected here)
if vercmp "12.6.3" "$v"; then
# 12.6.3 < v -> patched
return 1
fi
return 0
}
FOUND=0; VULN=0
while IFS= read -r -d '' pkg; do
name=$(grep -oE '"name"[[:space:]]*:[[:space:]]*"@fastify/reply-from"' "$pkg" || true)
[ -z "$name" ] && continue
ver=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]+"' "$pkg" | head -1 | sed -E 's/.*"([^"]+)"$/\1/')
FOUND=1
if is_vuln "$ver"; then
echo "VULNERABLE: @fastify/reply-from@$ver at $pkg"
VULN=1
else
echo "PATCHED: @fastify/reply-from@$ver at $pkg"
fi
done < <(find . -type d -name node_modules -prune -o -type d -print 2>/dev/null | xargs -I{} find {} -maxdepth 3 -name package.json -path '*/reply-from/*' -print0 2>/dev/null; find ./node_modules -name package.json -path '*@fastify/reply-from/*' -print0 2>/dev/null)
# Also check top-level package-lock / manifests
if [ -f package-lock.json ]; then
grep -oE '"@fastify/reply-from"[[:space:]]*:[[:space:]]*\{[^}]*"version"[[:space:]]*:[[:space:]]*"[^"]+"' package-lock.json | \
sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' | while read -r v; do
FOUND=1
if is_vuln "$v"; then echo "VULNERABLE: lockfile pins @fastify/reply-from@$v"; VULN=1; else echo "PATCHED: lockfile pins @fastify/reply-from@$v"; fi
done
fi
if [ "$FOUND" -eq 0 ]; then echo "UNKNOWN: @fastify/reply-from not found under $APP_DIR"; exit 2; fi
exit "$VULN"
If you remember one thing.
@fastify/reply-from 8.3.1–12.6.3 (including transitive pulls via @fastify/http-proxy). For any hit that *also* uses getUpstream() or multiple reply.from() targets, ship disableCache: true as a config hotfix and audit the upstream URL list for prefix collisions — that is your real risk cohort. Verdict is MEDIUM, so per the noisgate mitigation SLA there is no mitigation deadline (MEDIUM has none — go straight to remediation), and per the noisgate remediation SLA you have up to 365 days to complete the version bump to 12.6.4+; realistically fold it into the next dependency sweep this quarter since it is a semver-safe patch. Do not treat this as a fire drill — the preconditions are too narrow — but do not let it rot in the backlog if you run a multi-tenant gateway.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.