Skip to content

Commit 3b76df1

Browse files
authored
Merge commit from fork
* Stricter HTTP/1.1 chunk extension parsing Motivation: Chunk extensions can include quoted string values, which themselves can include linebreaks and escapes for quotes. We need to parse these properly to ensure we find the correct start of the chunk data. Modification: - Implement full RFC 9112 HTTP/1.1 compliant parsing of chunk start lines. - Add test cases from the Funky Chunks research: https://w4ke.info/2025/10/29/funky-chunks-2.html - This inclues chunk extensions with quoted strings that have linebreaks in them, and quoted strings that use escape codes. - Remove a test case that asserted support for control characters in the middle of chunk start lines, including after a naked chunk length field. Such control characters are not permitted by the standard. Result: Prevents HTTP message smuggling through carefully crafted chunk extensions. * Revert the ByteProcessor changes * Add a benchmark for HTTP/1.1 chunk decoding * Fix chunk initial line decoding The initial line was not correctly truncated at its line break and ended up including some of the chunk contents. * Failing to parse chunk size must throw NumberFormatException * Line breaks are completely disallowed within chunk extensions Change the chunk parsing back to its original code, because we know that line breaks are not supposed to occur within chunk extensions at all. This means doing the SWAR search should be suitable. Modify the byte processor and add it as a validation step of the parsed chunk start line. Update the tests to match. * Fix checkstyle
1 parent aae944a commit 3b76df1

7 files changed

Lines changed: 673 additions & 30 deletions

File tree

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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.util.ByteProcessor;
19+
20+
import java.util.BitSet;
21+
22+
/**
23+
* Validates the chunk start line. That is, the chunk size and chunk extensions, until the CR LF pair.
24+
* See <a href="https://www.rfc-editor.org/rfc/rfc9112#name-chunked-transfer-coding">RFC 9112 section 7.1</a>.
25+
*
26+
* <pre>{@code
27+
* chunked-body = *chunk
28+
* last-chunk
29+
* trailer-section
30+
* CRLF
31+
*
32+
* chunk = chunk-size [ chunk-ext ] CRLF
33+
* chunk-data CRLF
34+
* chunk-size = 1*HEXDIG
35+
* last-chunk = 1*("0") [ chunk-ext ] CRLF
36+
*
37+
* chunk-data = 1*OCTET ; a sequence of chunk-size octets
38+
* chunk-ext = *( BWS ";" BWS chunk-ext-name
39+
* [ BWS "=" BWS chunk-ext-val ] )
40+
*
41+
* chunk-ext-name = token
42+
* chunk-ext-val = token / quoted-string
43+
* quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
44+
* qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
45+
* quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
46+
* obs-text = %x80-FF
47+
* OWS = *( SP / HTAB )
48+
* ; optional whitespace
49+
* BWS = OWS
50+
* ; "bad" whitespace
51+
* VCHAR = %x21-7E
52+
* ; visible (printing) characters
53+
* }</pre>
54+
*/
55+
final class HttpChunkLineValidatingByteProcessor implements ByteProcessor {
56+
private static final int SIZE = 0;
57+
private static final int CHUNK_EXT_NAME = 1;
58+
private static final int CHUNK_EXT_VAL_START = 2;
59+
private static final int CHUNK_EXT_VAL_QUOTED = 3;
60+
private static final int CHUNK_EXT_VAL_QUOTED_ESCAPE = 4;
61+
private static final int CHUNK_EXT_VAL_QUOTED_END = 5;
62+
private static final int CHUNK_EXT_VAL_TOKEN = 6;
63+
64+
static final class Match extends BitSet {
65+
private static final long serialVersionUID = 49522994383099834L;
66+
private final int then;
67+
68+
Match(int then) {
69+
super(256);
70+
this.then = then;
71+
}
72+
73+
Match chars(String chars) {
74+
return chars(chars, true);
75+
}
76+
77+
Match chars(String chars, boolean value) {
78+
for (int i = 0, len = chars.length(); i < len; i++) {
79+
set(chars.charAt(i), value);
80+
}
81+
return this;
82+
}
83+
84+
Match range(int from, int to) {
85+
return range(from, to, true);
86+
}
87+
88+
Match range(int from, int to, boolean value) {
89+
for (int i = from; i <= to; i++) {
90+
set(i, value);
91+
}
92+
return this;
93+
}
94+
}
95+
96+
private enum State {
97+
Size(
98+
new Match(SIZE).chars("0123456789abcdefABCDEF \t"),
99+
new Match(CHUNK_EXT_NAME).chars(";")),
100+
ChunkExtName(
101+
new Match(CHUNK_EXT_NAME)
102+
.range(0x21, 0x7E)
103+
.chars(" \t")
104+
.chars("(),/:<=>?@[\\]{}", false),
105+
new Match(CHUNK_EXT_VAL_START).chars("=")),
106+
ChunkExtValStart(
107+
new Match(CHUNK_EXT_VAL_START).chars(" \t"),
108+
new Match(CHUNK_EXT_VAL_QUOTED).chars("\""),
109+
new Match(CHUNK_EXT_VAL_TOKEN)
110+
.range(0x21, 0x7E)
111+
.chars("(),/:<=>?@[\\]{}", false)),
112+
ChunkExtValQuoted(
113+
new Match(CHUNK_EXT_VAL_QUOTED_ESCAPE).chars("\\"),
114+
new Match(CHUNK_EXT_VAL_QUOTED_END).chars("\""),
115+
new Match(CHUNK_EXT_VAL_QUOTED)
116+
.chars("\t !")
117+
.range(0x23, 0x5B)
118+
.range(0x5D, 0x7E)
119+
.range(0x80, 0xFF)),
120+
ChunkExtValQuotedEscape(
121+
new Match(CHUNK_EXT_VAL_QUOTED)
122+
.chars("\t ")
123+
.range(0x21, 0x7E)
124+
.range(0x80, 0xFF)),
125+
ChunkExtValQuotedEnd(
126+
new Match(CHUNK_EXT_VAL_QUOTED_END).chars("\t "),
127+
new Match(CHUNK_EXT_NAME).chars(";")),
128+
ChunkExtValToken(
129+
new Match(CHUNK_EXT_VAL_TOKEN)
130+
.range(0x21, 0x7E, true)
131+
.chars("(),/:<=>?@[\\]{}", false),
132+
new Match(CHUNK_EXT_NAME).chars(";")),
133+
;
134+
135+
private final Match[] matches;
136+
137+
State(Match... matches) {
138+
this.matches = matches;
139+
}
140+
141+
State match(byte value) {
142+
for (Match match : matches) {
143+
if (match.get(value)) {
144+
return STATES_BY_ORDINAL[match.then];
145+
}
146+
}
147+
if (this == Size) {
148+
throw new NumberFormatException("Invalid chunk size");
149+
} else {
150+
throw new InvalidChunkExtensionException("Invalid chunk extension");
151+
}
152+
}
153+
}
154+
155+
private static final State[] STATES_BY_ORDINAL = State.values();
156+
157+
private State state = State.Size;
158+
159+
@Override
160+
public boolean process(byte value) {
161+
state = state.match(value);
162+
return true;
163+
}
164+
165+
public void finish() {
166+
if (state != State.Size && state != State.ChunkExtName && state != State.ChunkExtValQuotedEnd) {
167+
throw new InvalidChunkExtensionException("Invalid chunk extension");
168+
}
169+
}
170+
}

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,7 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf buffer, List<Object> ou
477477
if (line == null) {
478478
return;
479479
}
480+
checkChunkExtensions(line);
480481
int chunkSize = getChunkSize(line.array(), line.arrayOffset() + line.readerIndex(), line.readableBytes());
481482
this.chunkSize = chunkSize;
482483
if (chunkSize == 0) {
@@ -723,6 +724,16 @@ private HttpMessage invalidMessage(HttpMessage current, ByteBuf in, Exception ca
723724
return current;
724725
}
725726

727+
private static void checkChunkExtensions(ByteBuf line) {
728+
int extensionsStart = line.bytesBefore((byte) ';');
729+
if (extensionsStart == -1) {
730+
return;
731+
}
732+
HttpChunkLineValidatingByteProcessor processor = new HttpChunkLineValidatingByteProcessor();
733+
line.forEachByte(processor);
734+
processor.finish();
735+
}
736+
726737
private HttpContent invalidChunk(ByteBuf in, Exception cause) {
727738
currentState = State.BAD_MESSAGE;
728739
message = null;
@@ -941,7 +952,7 @@ private static int skipWhiteSpaces(byte[] hex, int start, int length) {
941952
}
942953

943954
private static int getChunkSize(byte[] hex, int start, int length) {
944-
// trim the leading bytes if white spaces, if any
955+
// trim the leading bytes of white spaces, if any
945956
final int skipped = skipWhiteSpaces(hex, start, length);
946957
if (skipped == length) {
947958
// empty case

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -808,13 +808,13 @@ private static int validateCharSequenceToken(CharSequence token) {
808808
private static final long TOKEN_CHARS_HIGH = 0x57ffffffc7fffffeL;
809809
private static final long TOKEN_CHARS_LOW = 0x3ff6cfa00000000L;
810810

811-
private static boolean isValidTokenChar(byte bit) {
812-
if (bit < 0) {
811+
static boolean isValidTokenChar(byte octet) {
812+
if (octet < 0) {
813813
return false;
814814
}
815-
if (bit < 64) {
816-
return 0 != (TOKEN_CHARS_LOW & 1L << bit);
815+
if (octet < 64) {
816+
return 0 != (TOKEN_CHARS_LOW & 1L << octet);
817817
}
818-
return 0 != (TOKEN_CHARS_HIGH & 1L << bit - 64);
818+
return 0 != (TOKEN_CHARS_HIGH & 1L << octet - 64);
819819
}
820820
}

0 commit comments

Comments
 (0)