Skip to content

Commit 84530fa

Browse files
chrisvestadwsingh
andauthored
Merge commit from fork
* Bring Transfer-Encoding/Content-Length in line with RFC 9112 Motivation: RFC 9112 makes it clear than sending an HTTP/1.1 message with both Transfer-Encoding and Content-Length is not allowed. Modification: Make HttpObjectDecoder throw TransferEncodingNotAllowedException if the HTTP version is not HTTP/1.1. Make HttpObjectDecoder throw ContentLengthNotAllowedException by default if an HTTP/1.1 request contain both Transfer-Encoding and Content-Length headers. Previously, RFC 7230 permitted us to just remove the Content-Length header, but this is now discouraged practice. Result: More up to date HTTP decoding behavior. * Add overrides and documentation to revert to RFC 7230 behavior * Add HttpDecoderConfig options and server Keep-Alive: close behavior Co-authored-by: Adwait Kumar Singh <[email protected]> --------- Co-authored-by: Adwait Kumar Singh <[email protected]>
1 parent e62d403 commit 84530fa

10 files changed

Lines changed: 350 additions & 19 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2026 The Netty Project
3+
*
4+
* The Netty Project licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
package io.netty.handler.codec.http;
17+
18+
import io.netty.handler.codec.DecoderException;
19+
20+
/**
21+
* Thrown by {@link HttpObjectDecoder#handleTransferEncodingChunkedWithContentLength(HttpMessage)} by default.
22+
* <p>
23+
* The HTTP/1.1 specification, RFC 9112, disallow senders from including both {@code Tranfer-Encoding} and
24+
* {@code Content-Length headers in the same message, and permits servers to reject such requests.
25+
*/
26+
public final class ContentLengthNotAllowedException extends DecoderException {
27+
/**
28+
* Create a new instance with the given message.
29+
* @param message The exception message.
30+
*/
31+
public ContentLengthNotAllowedException(String message) {
32+
super(message);
33+
}
34+
}

codec-http/src/main/java/io/netty/handler/codec/http/HttpDecoderConfig.java

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ public final class HttpDecoderConfig implements Cloneable {
3535
private int maxHeaderSize = HttpObjectDecoder.DEFAULT_MAX_HEADER_SIZE;
3636
private int initialBufferSize = HttpObjectDecoder.DEFAULT_INITIAL_BUFFER_SIZE;
3737
private boolean strictLineParsing = HttpObjectDecoder.DEFAULT_STRICT_LINE_PARSING;
38+
private boolean useRfc9112TransferEncoding = HttpObjectDecoder.RFC9112_TRANSFER_ENCODING;
3839

3940
public int getInitialBufferSize() {
4041
return initialBufferSize;
@@ -247,6 +248,28 @@ public HttpDecoderConfig setStrictLineParsing(boolean strictLineParsing) {
247248
return this;
248249
}
249250

251+
public boolean isUseRfc9112TransferEncoding() {
252+
return useRfc9112TransferEncoding;
253+
}
254+
255+
/**
256+
* The RFC 9112 specification is more strict than RFC 7230 with regards to having {@code Transfer-Encoding} and
257+
* {@code Content-Length} headers in the same HTTP message. Senders are now forbidden from including both headers
258+
* in the same message, while servers may reject such requests. When this setting is set to {@code true}, which
259+
* is the default, then such messages will be <em>rejected.</em>
260+
* <p>
261+
* When this setting is set to {@code false}, it restores the RFC 7230 behavior of instead removing any
262+
* {@code Content-Length} headers when {@code Transfer-Encoding} headers are present.
263+
* @param useRfc9112TransferEncoding Whether to reject messages with both {@code Transfer-Encoding} and
264+
* {@code Content-Length} headers.
265+
* @return This decoder config.
266+
* @see HttpObjectDecoder#handleTransferEncodingChunkedWithContentLength(HttpMessage)
267+
*/
268+
public HttpDecoderConfig setUseRfc9112TransferEncoding(boolean useRfc9112TransferEncoding) {
269+
this.useRfc9112TransferEncoding = useRfc9112TransferEncoding;
270+
return this;
271+
}
272+
250273
@Override
251274
public HttpDecoderConfig clone() {
252275
try {

codec-http/src/main/java/io/netty/handler/codec/http/HttpObjectDecoder.java

Lines changed: 70 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
import io.netty.util.ByteProcessor;
2828
import io.netty.util.internal.StringUtil;
2929
import io.netty.util.internal.SystemPropertyUtil;
30+
import io.netty.util.internal.ThrowableUtil;
3031

3132
import java.util.List;
3233
import java.util.concurrent.atomic.AtomicBoolean;
@@ -154,6 +155,9 @@ public abstract class HttpObjectDecoder extends ByteToMessageDecoder {
154155
public static final boolean DEFAULT_ALLOW_DUPLICATE_CONTENT_LENGTHS = false;
155156
public static final boolean DEFAULT_STRICT_LINE_PARSING =
156157
SystemPropertyUtil.getBoolean("io.netty.handler.codec.http.defaultStrictLineParsing", true);
158+
public static final String PROP_RFC9112_TRANSFER_ENCODING = "io.netty.handler.codec.http.rfc9112TransferEncoding";
159+
public static final boolean RFC9112_TRANSFER_ENCODING =
160+
SystemPropertyUtil.getBoolean(PROP_RFC9112_TRANSFER_ENCODING, true);
157161

158162
private static final Runnable THROW_INVALID_CHUNK_EXTENSION = new Runnable() {
159163
@Override
@@ -168,6 +172,12 @@ public void run() {
168172
throw new InvalidLineSeparatorException();
169173
}
170174
};
175+
private static final TransferEncodingNotAllowedException TRANSFER_ENCODING_NOT_ALLOWED =
176+
ThrowableUtil.unknownStackTrace(
177+
new TransferEncodingNotAllowedException(
178+
"The Transfer-Encoding header is only allowed in HTTP/1.1 or newer"),
179+
HttpObjectDecoder.class,
180+
"readHeaders(ByteBuf)");
171181

172182
private final int maxChunkSize;
173183
private final boolean chunkedSupported;
@@ -180,6 +190,7 @@ public void run() {
180190
protected final HttpHeadersFactory headersFactory;
181191
protected final HttpHeadersFactory trailersFactory;
182192
private final boolean allowDuplicateContentLengths;
193+
private final boolean useRfc9112TransferEncoding;
183194
private final ByteBuf parserScratchBuffer;
184195
private final Runnable defaultStrictCRLFCheck;
185196
private final HeaderParser headerParser;
@@ -344,6 +355,7 @@ protected HttpObjectDecoder(HttpDecoderConfig config) {
344355
validateHeaders = isValidating(headersFactory);
345356
allowDuplicateContentLengths = config.isAllowDuplicateContentLengths();
346357
allowPartialChunks = config.isAllowPartialChunks();
358+
useRfc9112TransferEncoding = config.isUseRfc9112TransferEncoding();
347359
}
348360

349361
protected boolean isValidating(HttpHeadersFactory headersFactory) {
@@ -692,7 +704,7 @@ private void resetNow() {
692704
message = null;
693705
name = null;
694706
value = null;
695-
contentLength = Long.MIN_VALUE;
707+
clearContentLength();
696708
chunked = false;
697709
lineParser.reset();
698710
headerParser.reset();
@@ -825,6 +837,13 @@ private State readHeaders(ByteBuf buffer) {
825837
HttpUtil.setTransferEncodingChunked(message, false);
826838
return State.SKIP_CONTROL_CHARS;
827839
}
840+
if (message.headers().contains(HttpHeaderNames.TRANSFER_ENCODING) &&
841+
message.protocolVersion() != HttpVersion.HTTP_1_1 &&
842+
useRfc9112TransferEncoding) {
843+
// The Transfer-Encoding header is not permitted at all with HTTP protocols older than 1.1,
844+
// and such requests must be rejected.
845+
throw TRANSFER_ENCODING_NOT_ALLOWED;
846+
}
828847
if (HttpUtil.isTransferEncodingChunked(message)) {
829848
this.chunked = true;
830849
if (!contentLengthFields.isEmpty() && message.protocolVersion() == HttpVersion.HTTP_1_1) {
@@ -848,27 +867,61 @@ private static boolean isLengthEqual(String lengthValue, long contentLength) {
848867

849868
/**
850869
* Invoked when a message with both a "Transfer-Encoding: chunked" and a "Content-Length" header field is detected.
851-
* The default behavior is to <i>remove</i> the Content-Length field, but this method could be overridden
852-
* to change the behavior (to, e.g., throw an exception and produce an invalid message).
870+
* The default behavior is to throw a {@link ContentLengthNotAllowedException} exception, but this method could
871+
* be overridden to change the behavior (to, e.g., remove the {@code Content-Length} header value.
853872
* <p>
854-
* See: https://tools.ietf.org/html/rfc7230#section-3.3.3
873+
* See: <a href="https://www.rfc-editor.org/rfc/rfc9112.html#section-6.1-15">RFC 9112, Section 6.1-15</a>.
855874
* <pre>
856-
* If a message is received with both a Transfer-Encoding and a
857-
* Content-Length header field, the Transfer-Encoding overrides the
858-
* Content-Length. Such a message might indicate an attempt to
859-
* perform request smuggling (Section 9.5) or response splitting
860-
* (Section 9.4) and ought to be handled as an error. A sender MUST
861-
* remove the received Content-Length field prior to forwarding such
862-
* a message downstream.
875+
* A server MAY reject a request that contains both Content-Length and Transfer-Encoding
876+
* or process such a request in accordance with the Transfer-Encoding alone.
877+
* Regardless, the server MUST close the connection after responding to such a request
878+
* to avoid the potential attacks.
863879
* </pre>
864-
* Also see:
865-
* https://github.com/apache/tomcat/blob/b693d7c1981fa7f51e58bc8c8e72e3fe80b7b773/
866-
* java/org/apache/coyote/http11/Http11Processor.java#L747-L755
867-
* https://github.com/nginx/nginx/blob/0ad4393e30c119d250415cb769e3d8bc8dce5186/
868-
* src/http/ngx_http_request.c#L1946-L1953
880+
* Since Netty itself cannot track the request/response pairing, it cannot guarantee that the connection is closed
881+
* immediately after the response is sent. As such, it is safer to immediately reject the request.
882+
* <p>
883+
* <strong>Note:</strong> RFC 7230 (the previous HTTP/1.1 RFC) allowed the {@code Content-Length} header to simply
884+
* be ignored, in the presence of a {@code Transfer-Encoding} header, but this practice is now obsolete
885+
* and considered unsafe.
886+
* The RFC 7230 behavior can be restored in the following ways:
887+
* <ul>
888+
* <li>
889+
* Process-wide, by setting the {@value PROP_RFC9112_TRANSFER_ENCODING} system property to {@code false}.
890+
* </li>
891+
* <li>
892+
* Configured for a specific decoder, by setting
893+
* {@link HttpDecoderConfig#setUseRfc9112TransferEncoding(boolean)} to {@code false}.
894+
* </li>
895+
* <li>
896+
* Hard-coded for a specific decoder, by overriding this method with an implementation like the following:
897+
* <pre>{@code
898+
* @Override
899+
* protected void handleTransferEncodingChunkedWithContentLength(HttpMessage message) {
900+
* clearContentLength();
901+
* message.headers().remove(HttpHeaderNames.CONTENT_LENGTH);
902+
* }
903+
* }</pre>
904+
* </li>
905+
* </ul>
906+
* <p>
907+
* <strong>Note:</strong> This method is only called for {@code HTTP/1.1} requests. Earlier HTTP protocol versions
908+
* do not support the {@code Transfer-Encoding} header, and will reject requests that include it.
869909
*/
910+
@SuppressWarnings("unused")
870911
protected void handleTransferEncodingChunkedWithContentLength(HttpMessage message) {
871-
message.headers().remove(HttpHeaderNames.CONTENT_LENGTH);
912+
clearContentLength();
913+
if (useRfc9112TransferEncoding) {
914+
throw new ContentLengthNotAllowedException(
915+
"Content-Length are not allowed in HTTP/1.1 messages that contains a Transfer-Encoding header.");
916+
} else {
917+
message.headers().remove(HttpHeaderNames.CONTENT_LENGTH);
918+
if (isDecodingRequest()) {
919+
HttpUtil.setKeepAlive(message, false);
920+
}
921+
}
922+
}
923+
924+
protected final void clearContentLength() {
872925
contentLength = Long.MIN_VALUE;
873926
}
874927

codec-http/src/main/java/io/netty/handler/codec/http/HttpServerCodec.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
package io.netty.handler.codec.http;
1717

1818
import io.netty.buffer.ByteBuf;
19+
import io.netty.channel.ChannelFutureListener;
1920
import io.netty.channel.ChannelHandlerContext;
21+
import io.netty.channel.ChannelPromise;
2022
import io.netty.channel.CombinedChannelDuplexHandler;
2123

2224
import java.util.ArrayDeque;
@@ -70,6 +72,11 @@ public final class HttpServerCodec extends CombinedChannelDuplexHandler<HttpRequ
7072
private int methodQueueSize;
7173
private Queue<Byte> methodOverflowQueue;
7274

75+
/**
76+
* When set, the connection will be closed after the next response is written.
77+
*/
78+
private boolean mustCloseAfterResponse;
79+
7380
/**
7481
* Creates a new instance with the default decoder options
7582
* ({@code maxInitialLineLength (4096)}, {@code maxHeaderSize (8192)}, and
@@ -239,12 +246,27 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf buffer, List<Object> ou
239246
}
240247
}
241248
}
249+
250+
@Override
251+
protected void handleTransferEncodingChunkedWithContentLength(HttpMessage message) {
252+
super.handleTransferEncodingChunkedWithContentLength(message);
253+
mustCloseAfterResponse = true;
254+
}
242255
}
243256

244257
private final class HttpServerResponseEncoder extends HttpResponseEncoder {
245258

246259
private byte methodFlag;
247260

261+
@Override
262+
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) throws Exception {
263+
if (mustCloseAfterResponse && msg instanceof LastHttpContent) {
264+
mustCloseAfterResponse = false;
265+
promise = promise.unvoid().addListener(ChannelFutureListener.CLOSE);
266+
}
267+
super.write(ctx, msg, promise);
268+
}
269+
248270
@Override
249271
protected void sanitizeHeadersBeforeEncode(HttpResponse msg, boolean isAlwaysEmpty) {
250272
if (!isAlwaysEmpty && methodFlag == METHOD_FLAG_CONNECT
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2026 The Netty Project
3+
*
4+
* The Netty Project licenses this file to you under the Apache License,
5+
* version 2.0 (the "License"); you may not use this file except in compliance
6+
* with the License. You may obtain a copy of the License at:
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations
14+
* under the License.
15+
*/
16+
package io.netty.handler.codec.http;
17+
18+
import io.netty.handler.codec.DecoderException;
19+
20+
/**
21+
* Thrown by {@link HttpObjectDecoder} when an HTTP message uses a protocol version older than {@code HTTP/1.1}
22+
* and includes an {@code Transfer-Encoding} header.
23+
*/
24+
public final class TransferEncodingNotAllowedException extends DecoderException {
25+
/**
26+
* Create a new instance with the given message.
27+
* @param message The exception message.
28+
*/
29+
public TransferEncodingNotAllowedException(String message) {
30+
super(message);
31+
}
32+
}

codec-http/src/test/java/io/netty/handler/codec/http/HttpInvalidMessageTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public void testResponseWithBadHeader() throws Exception {
9191
@Test
9292
public void testBadChunk() throws Exception {
9393
EmbeddedChannel ch = new EmbeddedChannel(new HttpRequestDecoder());
94-
ch.writeInbound(Unpooled.copiedBuffer("GET / HTTP/1.0\r\n", CharsetUtil.UTF_8));
94+
ch.writeInbound(Unpooled.copiedBuffer("GET / HTTP/1.1\r\n", CharsetUtil.UTF_8));
9595
ch.writeInbound(Unpooled.copiedBuffer("Transfer-Encoding: chunked\r\n\r\n", CharsetUtil.UTF_8));
9696
ch.writeInbound(Unpooled.copiedBuffer("BAD_LENGTH\r\n", CharsetUtil.UTF_8));
9797

codec-http/src/test/java/io/netty/handler/codec/http/HttpRequestDecoderTest.java

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -702,7 +702,7 @@ private static void testContentLengthAndTransferEncodingHeadersWithInvalidSepara
702702
}
703703

704704
@Test
705-
public void testContentLengthHeaderAndChunked() {
705+
public void testContentLengthHeaderAndChunkedHttp11() {
706706
String requestStr = "POST / HTTP/1.1\r\n" +
707707
"Host: example.com\r\n" +
708708
"Connection: close\r\n" +
@@ -712,15 +712,48 @@ public void testContentLengthHeaderAndChunked() {
712712
EmbeddedChannel channel = new EmbeddedChannel(new HttpRequestDecoder());
713713
assertTrue(channel.writeInbound(Unpooled.copiedBuffer(requestStr, CharsetUtil.US_ASCII)));
714714
HttpRequest request = channel.readInbound();
715+
assertTrue(request.decoderResult().isFailure());
716+
assertThat(request.decoderResult().cause()).isInstanceOf(ContentLengthNotAllowedException.class);
717+
assertFalse(channel.finish());
718+
}
719+
720+
@Test
721+
public void testContentLengthHeaderAndChunkedHttp11RFC7230() {
722+
String requestStr = "POST / HTTP/1.1\r\n" +
723+
"Host: example.com\r\n" +
724+
"Content-Length: 5\r\n" +
725+
"Transfer-Encoding: chunked\r\n\r\n" +
726+
"0\r\n\r\n";
727+
EmbeddedChannel channel = new EmbeddedChannel(new HttpRequestDecoder(
728+
new HttpDecoderConfig().setUseRfc9112TransferEncoding(false)));
729+
assertTrue(channel.writeInbound(Unpooled.copiedBuffer(requestStr, CharsetUtil.US_ASCII)));
730+
HttpRequest request = channel.readInbound();
715731
assertFalse(request.decoderResult().isFailure());
716732
assertTrue(request.headers().names().contains("Transfer-Encoding"));
717733
assertTrue(request.headers().contains("Transfer-Encoding", "chunked", false));
718734
assertFalse(request.headers().contains("Content-Length"));
735+
assertEquals("close", request.headers().get("Connection"));
719736
LastHttpContent c = channel.readInbound();
720737
c.release();
721738
assertFalse(channel.finish());
722739
}
723740

741+
@Test
742+
public void testContentLengthHeaderAndChunkedHttp10() {
743+
String requestStr = "POST / HTTP/1.0\r\n" +
744+
"Host: example.com\r\n" +
745+
"Connection: close\r\n" +
746+
"Content-Length: 5\r\n" +
747+
"Transfer-Encoding: chunked\r\n\r\n" +
748+
"0\r\n\r\n";
749+
EmbeddedChannel channel = new EmbeddedChannel(new HttpRequestDecoder());
750+
assertTrue(channel.writeInbound(Unpooled.copiedBuffer(requestStr, CharsetUtil.US_ASCII)));
751+
HttpRequest request = channel.readInbound();
752+
assertTrue(request.decoderResult().isFailure());
753+
assertThat(request.decoderResult().cause()).isInstanceOf(TransferEncodingNotAllowedException.class);
754+
assertFalse(channel.finish());
755+
}
756+
724757
@Test
725758
void mustRejectImproperlyTerminatedChunkExtensions() throws Exception {
726759
// See full explanation: https://w4ke.info/2025/06/18/funky-chunks.html

0 commit comments

Comments
 (0)