Like a courier who staples the old shipping label onto a repackaged box, so downstream reads the wrong weight
CVE-2026-16728 is a response desynchronization flaw in undici's retry interceptor (interceptors.retry()). When an upstream server returns a partial (206) response with a Content-Length that doesn't match the bytes actually delivered and then drops the connection, the retry interceptor reassembles the body from the resumed transfer — but leaves the original, now-stale Content-Length header in place. Any application that forwards that response downstream (proxies, API gateways, BFF layers) emits an HTTP message whose framing metadata contradicts the actual payload. Affected versions: undici < 6.28.0, 7.0.0 – 7.28.x, and 8.0.0 – 8.8.x. Fixed in 6.28.0, 7.29.0, and 8.9.0, which also ship inside Node.js 22.23.2, 24.18.1, and 26.5.1 respectively.
The vendor's MEDIUM / 4.8 rating is honest and, unusually, already accounts for the real-world friction. The CVSS vector specifies AC:H (high attack complexity) — the attacker must control or compromise an upstream origin, the victim application must have explicitly enabled the retry interceptor (it is *not* on by default), and the application must forward raw headers without recalculating Content-Length. That triple gate narrows the exploitable population to a small slice of Node.js proxy/gateway deployments. Impact is capped at C:L / I:L / A:N: response confusion, possible cache poisoning, and connection hangs — not code execution, not credential theft, not lateral movement. There is no reason to override the vendor here.
4 steps from start to impact.
Attacker controls or compromises an upstream origin
Content-Length + early socket close cannot be injected.- Attacker controls or has compromised an HTTP origin the undici client fetches from
- In most enterprise deployments, upstream origins are internal microservices or trusted third-party APIs — not attacker-controlled endpoints
- If the upstream is behind a service mesh or mTLS, compromise is an additional step
Undici retry interceptor is enabled on the client
interceptors.retry() via the undici Dispatcher API. Node.js's built-in fetch() does not enable the retry interceptor by default; the developer must wire it up manually. This is a hard prerequisite — without it, the partial-response-reassembly logic never fires.- Application code explicitly configures
interceptors.retry()on the Dispatcher
- Retry interceptor is opt-in, not default — the vast majority of undici /
fetch()consumers never enable it - Even when retry is desired, many teams use higher-level retry wrappers (axios-retry, got retry, etc.) rather than undici's native interceptor
interceptors.retry in the codebaseUpstream returns partial response with stale Content-Length and drops connection
Content-Length: 300 header but delivers only 99 bytes before closing the socket. The retry interceptor detects the incomplete transfer and retries, fetching the remaining bytes from the upstream's resumed offset — but preserves the original Content-Length: 300 header on the reassembled response.- Upstream cooperates with the retry by serving resumable partial content
- Socket close happens at a point that triggers undici's retry logic rather than a hard error
- Timing and byte-level control over the partial response are non-trivial
- Many upstreams don't support Range/resume, causing the retry to restart from scratch (which may not trigger the bug)
Application forwards response with stale Content-Length downstream
Content-Length. The downstream client or intermediary now sees a mismatch between the declared and actual body length, causing response desynchronization — connection hangs, truncated responses, or (in a persistent-connection scenario) reading the next response's bytes as part of the current one.- Application is a forwarding proxy / API gateway that pipes headers verbatim
- Downstream connection is persistent (HTTP/1.1 keep-alive) so desync actually matters
- Many Node.js proxy frameworks (http-proxy, fastify-reply-from) recalculate Content-Length or use Transfer-Encoding: chunked
- HTTP/2 downstream connections use their own framing and are not affected by Content-Length desync
interceptors.retry usage — Run grep -r 'interceptors.retry\|RetryAgent\|RetryHandler' --include='*.js' --include='*.ts' across your Node.js services. If no hits, you are not exposed to this CVE. This is the fastest triage step and should be done immediately to scope the problem.Content-Length header and let the framework recalculate it or switch to Transfer-Encoding: chunked. This neutralizes the stale-header vector regardless of undici version. Deploy as standard practice.- WAF rules for HTTP smuggling — most WAF smuggling signatures target *request*-side CL/TE mismatches between client and server. This bug produces a *response*-side Content-Length desync between the proxy and its downstream client, which standard WAF rulesets do not inspect.
- Upgrading Node.js without checking npm-installed undici — if your application installs undici as a direct dependency (not using the Node.js-bundled version), upgrading Node.js alone won't fix the vulnerability. You must also update the npm package.
- Rate-limiting or IP blocking — the attacker is the *upstream* origin, not a client sending requests. Inbound rate limits and IP ACLs are irrelevant to this attack vector.
The supporting signals.
| In-the-wild exploitation | None observed. No campaigns, no CISA KEV listing, no vendor acknowledgment of active exploitation. Disclosed 2026-07-29 — less than 24 hours old at time of assessment. |
|---|---|
| Proof-of-concept | No public PoC. The advisory credits samuel871211 as the reporter. No exploit code in GitHub, ExploitDB, or nuclei-templates. Reproduction requires a custom upstream server + retry-interceptor-enabled client + forwarding proxy — non-trivial to weaponize. |
| EPSS | Not yet scored — CVE was published 2026-07-29; FIRST EPSS model has not ingested it. Expected to land in the low single-digit percentile given AC:H and no PoC. |
| KEV status | Not listed. No CISA KEV entry as of 2026-07-30. |
| CVSS vector | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N — Network-reachable but high complexity, no privileges or user interaction needed, unchanged scope, low confidentiality and integrity impact, no availability impact. The AC:H is the dominant friction factor. |
| Affected versions | undici < 6.28.0 · 7.0.0 – 7.28.x · 8.0.0 – 8.8.x. Also affects Node.js bundled undici in Node 22.x < 22.23.2, 24.x < 24.18.1, 26.x < 26.5.1. |
| Fixed versions | undici 6.28.0, 7.29.0, 8.9.0. Node.js 22.23.2, 24.18.1, 26.5.1 (all released 2026-07-29). |
| Scanning / exposure data | Undici is not directly fingerprinted by Shodan/Censys — it is a client-side library, not a listening service. ~113–139M weekly npm downloads, but the retry interceptor is opt-in. No reliable way to measure externally exposed population. |
| Disclosure date | 2026-07-29 via GitHub Security Advisory GHSA-8xcm-r25x-g524. |
| Reporter / credits | samuel871211 (reporter), mcollina (Matteo Collina, remediation developer), UlisesGascon (remediation reviewer). |
Sources.
Why this verdict
- Opt-in feature gate:
interceptors.retry()must be explicitly wired up by the developer. Node.js's built-infetch()does not enable it. This single prerequisite eliminates the overwhelming majority of undici installs from the vulnerable population. - Triple-prerequisite chain: Exploitation requires (1) attacker-controlled upstream, (2) retry interceptor enabled, AND (3) a forwarding proxy pattern that pipes headers verbatim. Each gate compounds the difficulty.
- Impact ceiling is low: The worst outcome is HTTP response desynchronization — stale
Content-Lengthcausing downstream connection confusion, truncation, or cache poisoning on individual connections. No code execution, no privilege escalation, no data exfiltration path. - Role multiplier: Undici is a general-purpose HTTP client library used across all tiers. In the *high-value role* of a Node.js API gateway or reverse proxy: the chain can succeed IF the gateway uses undici's retry interceptor and pipes headers verbatim (unusual — most gateway frameworks use chunked encoding or recalculate Content-Length). Blast radius on such a gateway would be per-connection response corruption, not fleet compromise or domain takeover. This does not cross the HIGH floor threshold because the outcome does not reach identity/fleet/supply-chain scale even in the worst plausible role.
- No exploitation evidence: Zero days in the wild, no PoC, no KEV listing, no scanner detections. The CVE is < 24 hours old with high attack complexity — weaponization pressure is low.
Why not higher?
Upgrading to HIGH would require either active exploitation evidence, a public weaponized PoC, or a blast radius that reaches fleet/domain/supply-chain scale. None of those conditions exist. The opt-in nature of the retry interceptor and the per-connection (not per-fleet) impact ceiling make HIGH unjustifiable. Even in a Node.js gateway role, the chain ends at response confusion, not system compromise.
Why not lower?
Downgrading to LOW would undervalue the real (if narrow) HTTP smuggling / desync risk for teams that *do* run Node.js forwarding proxies with the retry interceptor enabled. CWE-444 desync bugs have historically been chained for cache poisoning and request routing attacks. The network-reachable vector (AV:N) and no-auth requirement (PR:N) mean the attacker doesn't need prior access — only upstream control. MEDIUM correctly reflects the conditional-but-real risk.
Crowdsourced verification payload.
Run this on each host or in CI where your Node.js applications are deployed. It checks both npm-installed undici packages and the Node.js-bundled version. No special privileges required — just the node and npm (or npx) binaries in PATH. Example: bash check_cve_2026_16728.sh /path/to/your/app
#!/usr/bin/env bash
# check_cve_2026_16728.sh — Detect CVE-2026-16728 (undici retry interceptor Content-Length desync)
# Usage: bash check_cve_2026_16728.sh [app_directory]
# Exit codes: 0 = PATCHED, 1 = VULNERABLE, 2 = UNKNOWN
set -euo pipefail
APP_DIR="${1:-.}"
VULNERABLE=0
version_gte() {
# Returns 0 if $1 >= $2 using semantic versioning
printf '%s\n%s' "$2" "$1" | sort -V -C
}
echo "=== CVE-2026-16728 Check ==="
echo
# 1. Check Node.js bundled undici
if command -v node &>/dev/null; then
NODE_VER=$(node -v | sed 's/^v//')
BUNDLED_UNDICI=$(node -e "try{console.log(process.versions.undici||'none')}catch(e){console.log('none')}" 2>/dev/null || echo 'none')
echo "[*] Node.js version: $NODE_VER"
echo "[*] Bundled undici: $BUNDLED_UNDICI"
if [ "$BUNDLED_UNDICI" != "none" ]; then
MAJOR=$(echo "$BUNDLED_UNDICI" | cut -d. -f1)
case $MAJOR in
6) if version_gte "$BUNDLED_UNDICI" "6.28.0"; then echo " -> Bundled undici PATCHED"; else echo " -> Bundled undici VULNERABLE"; VULNERABLE=1; fi ;;
7) if version_gte "$BUNDLED_UNDICI" "7.29.0"; then echo " -> Bundled undici PATCHED"; else echo " -> Bundled undici VULNERABLE"; VULNERABLE=1; fi ;;
8) if version_gte "$BUNDLED_UNDICI" "8.9.0"; then echo " -> Bundled undici PATCHED"; else echo " -> Bundled undici VULNERABLE"; VULNERABLE=1; fi ;;
*) echo " -> Bundled undici version $MAJOR.x not in affected range"; ;;
esac
fi
else
echo "[!] node not found in PATH"
fi
echo
# 2. Check npm-installed undici in the app directory
if [ -d "$APP_DIR/node_modules/undici" ]; then
PKG_VER=$(node -e "console.log(require('$APP_DIR/node_modules/undici/package.json').version)" 2>/dev/null || echo 'unknown')
echo "[*] npm undici in $APP_DIR: $PKG_VER"
if [ "$PKG_VER" = "unknown" ]; then
echo " -> UNKNOWN (could not read version)"
else
MAJOR=$(echo "$PKG_VER" | cut -d. -f1)
case $MAJOR in
[0-5]) echo " -> Version $PKG_VER predates affected retry interceptor (likely not vulnerable, but upgrade recommended)"; ;;
6) if version_gte "$PKG_VER" "6.28.0"; then echo " -> npm undici PATCHED"; else echo " -> npm undici VULNERABLE"; VULNERABLE=1; fi ;;
7) if version_gte "$PKG_VER" "7.29.0"; then echo " -> npm undici PATCHED"; else echo " -> npm undici VULNERABLE"; VULNERABLE=1; fi ;;
8) if version_gte "$PKG_VER" "8.9.0"; then echo " -> npm undici PATCHED"; else echo " -> npm undici VULNERABLE"; VULNERABLE=1; fi ;;
*) echo " -> Version $MAJOR.x — check advisory for coverage"; ;;
esac
fi
else
echo "[*] No npm-installed undici found in $APP_DIR/node_modules/"
fi
echo
# 3. Check for retry interceptor usage (exposure gate)
echo "[*] Scanning for retry interceptor usage in $APP_DIR ..."
if grep -r -l 'interceptors\.retry\|RetryAgent\|RetryHandler' --include='*.js' --include='*.ts' --include='*.mjs' --include='*.cjs' "$APP_DIR" 2>/dev/null | grep -v node_modules | head -5; then
echo " -> RETRY INTERCEPTOR IN USE — this app may be exposed"
else
echo " -> No retry interceptor usage found (app likely not exposed even if undici is vulnerable)"
fi
echo
if [ "$VULNERABLE" -eq 1 ]; then
echo "VULNERABLE"
exit 1
else
echo "PATCHED"
exit 0
fi