Internal Redirect Content-Length Mismatch Enables Request Smuggling
Affected commit: c33d01624d
Sink: source/common/router/router.cc:2282
Summary
On routes that copy content-length during internal redirects, Envoy recreates a request with the upstream response's content-length value while replaying the original buffered body. An HTTP/1.1 upstream parses the leftover bytes as a second (smuggled) request, bypassing downstream RBAC.
Detail
Lines 2267-2284 iterate the responseHeadersToCopy() list from the internal redirect policy. If content-length is in that list, the upstream response's content-length overwrites the original. The buffered body remains unchanged, creating a framing mismatch.
Reproduce
#!/bin/bash
set -e
# Clone and identify HEAD
git clone --depth 1 https://github.com/envoyproxy/envoy.git /tmp/envoy-repro
cd /tmp/envoy-repro
echo "HEAD: $(git rev-parse HEAD)"
# Verify responseHeadersToCopy loop overwrites downstream headers
echo "--- Internal redirect header copy loop ---"
sed -n '2267,2286p' source/common/router/router.cc
The vulnerable code at router.cc:2267-2284:
for (const Http::LowerCaseString& header :
route_entry_->internalRedirectPolicy().responseHeadersToCopy()) {
Http::HeaderMap::GetResult result = upstream_headers.get(header);
Http::HeaderMap::GetResult downstream_result = downstream_headers.get(header);
if (result.empty()) {
if (downstream_result.empty()) { continue; }
downstream_headers.remove(header);
} else {
if (!downstream_result.empty()) {
downstream_headers.remove(header);
}
for (size_t idx = 0; idx < result.size(); idx++) {
downstream_headers.addCopy(header, result[idx]->value().getStringView());
}
}
}
When content-length is included in response_headers_to_copy, the upstream response's content-length header overwrites the downstream request's content-length. The buffered request body is unchanged. If the upstream returns a 302 redirect with content-length: 0 while the original request had a 100-byte body, Envoy re-sends the request to the redirect target with content-length: 0 but still writes the 100-byte body. An HTTP/1.1 upstream receiving this sees a 0-length first request followed by 100 bytes it parses as a second, smuggled request.
Expected output:
HEAD: <resolved-commit>
--- Internal redirect header copy loop ---
for (const Http::LowerCaseString& header :
route_entry_->internalRedirectPolicy().responseHeadersToCopy()) {
Http::HeaderMap::GetResult result = upstream_headers.get(header);
...
downstream_headers.addCopy(header, result[idx]->value().getStringView());
Credit
Zheng Yu @ Depthfirst