Http1FramingEnforcingHandler misses whitespace-padded framing headers when validation is off
Summary
Http1FramingEnforcingHandler resolves the framing headers by canonical name, so it only sees headers the decoder has normalised. With server.http.request.headers.validation.enabled=false, the Netty codec accepts header names such as "Transfer-Encoding " (trailing space) and values containing a bare CR. Those headers are present on the decoded request but are invisible to getAll(HttpHeaderNames.TRANSFER_ENCODING) / getAll(HttpHeaderNames.CONTENT_LENGTH), so none of the enforcer's checks trip.
The default configuration is not affected, and nothing in this repository sets the property to false. zuul.http1.framing.enforcement.enabled defaults to true (zuul-core/src/main/java/com/netflix/zuul/netty/server/BaseZuulChannelInitializer.java:102-103) and server.http.request.headers.validation.enabled defaults to true (BaseZuulChannelInitializer.java:105-106). With validation on, every one of these inputs is a decoder failure and is rejected before the enforcer sees it. This only reaches an operator who deliberately turns validation off, and I am not arguing that doing so is otherwise safe — it plainly widens what the codec accepts. The narrow point is that the coupling is not visible where an operator would look for it.
What already covers this, and what does not
The coupling is partly documented already. Http1DecoderFailureRejectingHandler says so in its javadoc (Http1DecoderFailureRejectingHandler.java:28-31):
Rejects HTTP/1.1 requests that the HttpServerCodec marked as a decoder failure - for example a malformed header name surfaced when
server.http.request.headers.validation.enabled=true.
That handler sits between the codec and the enforcer (BaseZuulChannelInitializer.java:259-271), and ClientRequestReceiver.java:138 independently rejects decoder failures as a second layer. There is also a test for exactly this input shape: BaseZuulChannelInitializerTest#malformedHeaderNameIsRejectedEndToEnd (zuul-core/src/test/java/com/netflix/zuul/netty/server/BaseZuulChannelInitializerTest.java:236-270) writes Content-Length : 4 with validation true and asserts the connection closes.
So the mechanism is understood in the codebase. What appears to be missing is narrower:
Http1FramingEnforcingHandler's own javadoc (Http1FramingEnforcingHandler.java:33-42) presents it as an RFC 9112 section 6.3 control with no mention of the dependency on header-name validation.- No test pins the
validateHeaders=falsecase; the existing test only pinstrue. - The property itself has no user-facing documentation of the side effect on framing enforcement.
Mechanism
The enforcer resolves both framing headers by canonical name:
// zuul-core/src/main/java/com/netflix/zuul/netty/server/Http1FramingEnforcingHandler.java:57-58
List<String> contentLengthHeaders = req.headers().getAll(HttpHeaderNames.CONTENT_LENGTH);
List<String> transferEncodingHeaders = req.headers().getAll(HttpHeaderNames.TRANSFER_ENCODING);The codec that produces those headers is built with the validation flag:
// zuul-core/src/main/java/com/netflix/zuul/netty/server/BaseZuulChannelInitializer.java:273-279
protected HttpServerCodec createHttpServerCodec() {
return new HttpServerCodec(
MAX_INITIAL_LINE_LENGTH.get(),
MAX_HEADER_SIZE.get(),
MAX_CHUNK_SIZE.get(),
HTTP_REQUEST_HEADERS_VALIDATION_ENABLED.get());
}With validation off, this request:
POST /p HTTP/1.1\r\n
Host: x\r\n
Content-Length: 6\r\n
Transfer-Encoding : chunked\r\n
\r\n
0\r\n
\r\n
GET /SMUGGLED HTTP/1.1\r\n
Host: x\r\n
\r\ndecodes into a request whose headers are Host: x, Content-Length: 6 and Transfer-Encoding (with the trailing space) : chunked. The decoder does not use the padded header for framing either — it frames by Content-Length: 6, reads exactly six body bytes (0\r\n\r\nG, which swallows the G of the following request line), and then parses the remaining bytes as a second request, ET /SMUGGLED. That is where the odd-looking ET comes from; it is not a typo.
So this is not Zuul disagreeing with itself. Zuul's framing is self-consistent. The risk is disagreement with an upstream or downstream parser that does accept the padded name and therefore frames the same bytes as chunked.
Applying the enforcer's checks (Http1FramingEnforcingHandler.java:57-85) to those decoded headers by hand: contentLengthHeaders is [6], transferEncodingHeaders is empty, so no branch trips and the request would be passed on. That part is a code reading against the decoded headers, not an observation — the measurements below were run against a standalone HttpServerCodec in an EmbeddedChannel, not through a Zuul pipeline, and I did not test end-to-end behaviour on a running Zuul with validation disabled.
Measurements
Environment: master at 4c756238, Netty 4.2.18.Final as pinned in gradle.properties:2. Twenty-four HTTP/1.1 framing variants were written to an EmbeddedChannel holding new HttpServerCodec(16384, 32768, 32768, validateHeaders) — Zuul's default size limits, with the validation flag as the only variable. Each variant is an ambiguous or obfuscated framing followed by a trailing GET /SMUGGLED HTTP/1.1 request.
With validateHeaders=true: 21 of 24 are decoder failures. The other three (0;a=b chunk extension, a chunk size with a trailing space, and a Transfer-Encoding field in the trailer) are accepted, but they are correctly framed messages — the trailing GET /SMUGGLED that follows them is ordinary HTTP/1.1 pipelining, not a desync. None of the 24 reached a state the enforcer would need to catch.
With validateHeaders=false: those same three behave identically, and four more are accepted that validation rejects:
| Variant (with validation off) | Decoded result | What the enforcer sees |
|---|---|---|
Content-Length: 6 + Transfer-Encoding : chunked (space before the colon) |
[POST /p, ET /SMUGGLED]; framed by Content-Length, body 0\r\n\r\nG |
CL=[6], TE=[] — no branch trips |
Content-Length: 6 + Transfer-Encoding\t: chunked (tab before the colon) |
[POST /p, ET /SMUGGLED], as above |
CL=[6], TE=[] — no branch trips |
Content-Length : 6 + Transfer-Encoding: chunked |
[POST /p, GET /SMUGGLED]; framed as chunked, second request is legitimate pipelining |
CL=[], TE=[chunked] — no branch trips |
X-a: b\r\rContent-Length: 6 + Transfer-Encoding: chunked (bare CRs) |
[POST /p, GET /SMUGGLED]; the bare CRs keep Content-Length: 6 inside the X-a value |
CL=[], TE=[chunked] — no branch trips |
The remaining 17 are decoder failures with validation off as well: the Netty 4.2.18 codec enforces RFC 9112 section 6.3 independently of validateHeaders (both-headers-present, multiple Content-Length, non-chunked final coding, and so on).
All four rows share one shape: the codec preserves bytes that another parser may read as a framing header, while the enforcer's canonical lookup cannot see them.
Impact
The class javadoc on Http1FramingEnforcingHandler describes it as a control against ambiguous framing, and zuul.http1.framing.enforcement.enabled reads like an independent switch. For these inputs its coverage is entirely inherited from server.http.request.headers.validation.enabled. An operator who turns header validation off for compatibility reasons has no signal at the enforcer — or on the property — that framing enforcement narrows as a side effect.
Scope: HTTP/2 is unaffected. The property is only used to build the HTTP/1.1 HttpServerCodec (BaseZuulChannelInitializer.java:273-279); h2 streams never go through it.
Reproduction
Runs green as written against master, in the EmbeddedChannel style of BaseZuulChannelInitializerTest:
@Test
void whitespacePaddedTransferEncodingIsInvisibleToTheEnforcer() {
EmbeddedChannel channel = new EmbeddedChannel(new HttpServerCodec(16384, 32768, 32768, false));
String payload = "POST /p HTTP/1.1\r\n"
+ "Host: x\r\n"
+ "Content-Length: 6\r\n"
+ "Transfer-Encoding : chunked\r\n"
+ "\r\n"
+ "0\r\n\r\n"
+ "GET /SMUGGLED HTTP/1.1\r\nHost: x\r\n\r\n";
channel.writeInbound(Unpooled.copiedBuffer(payload, StandardCharsets.ISO_8859_1));
HttpRequest first = channel.readInbound();
assertThat(first.decoderResult().isSuccess()).isTrue();
// what Http1FramingEnforcingHandler:57-58 would look at
assertThat(first.headers().getAll(HttpHeaderNames.TRANSFER_ENCODING)).isEmpty();
assertThat(first.headers().getAll(HttpHeaderNames.CONTENT_LENGTH)).containsExactly("6");
// the header is there, just not under its canonical name
assertThat(first.headers().get("Transfer-Encoding ")).isEqualTo("chunked");
}Flipping the fourth argument to true makes first.decoderResult() a failure, which Http1DecoderFailureRejectingHandler then rejects.
Question for maintainers
If the intended contract is that Http1FramingEnforcingHandler only ever operates on decoder-validated headers — which is what Http1DecoderFailureRejectingHandler's javadoc implies — then the code is working as designed and this is purely a documentation gap: the enforcer's own javadoc and the server.http.request.headers.validation.enabled property could say that disabling validation narrows what framing enforcement can catch.
If that is not the intended contract, the alternative is to have the enforcer reject requests whose header names are malformed — for example a name containing whitespace, or specifically a name that differs from HttpHeaderNames.CONTENT_LENGTH / HttpHeaderNames.TRANSFER_ENCODING only by surrounding whitespace — so it holds regardless of the codec's validation setting.
I am happy to send a patch either way, including a test that pins the behaviour with validateHeaders=false.
Source: Netflix/zuul