Skip to content

Commit df64ad2

Browse files
yawkatclaude
andcommitted
Do not percent-decode url-encoded data in place
UrlEncodedDecoder.decodeComponent rewrote its input buffer with setByte. That input could be caller-visible memory: the key is a retained slice of the buffer passed to add(), an unterminated value is handed out as the added buffer itself, and the vintage HttpPostStandardRequestDecoder retains the offered HttpContent buffer and stores it in the attribute before decoding it. Percent or '+' decoding therefore changed the caller's bytes (and writerIndex), and failed with ReadOnlyBufferException for read-only input. The legacy implementation left offered buffers unchanged. decodeComponent now uses copy-on-write: it scans for '%' or '+' and returns the input unchanged if there is none; otherwise it writes the decoded bytes into a newly allocated buffer. It takes ownership of the input and returns a buffer owned by the caller, so the VintageAccess.UrlEncodedDecoder bridge method now returns the decoded ByteBuf instead of void. The key, decodedContent() and vintage call sites are updated for the new ownership contract. Regression tests check that offered buffers (keys and values, with and without endInput, single and multiple chunks) are unchanged after decoding and that read-only input decodes, for both the core decoder and the vintage HttpPostStandardRequestDecoder. Fixes #38 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7bc0fda commit df64ad2

5 files changed

Lines changed: 199 additions & 42 deletions

File tree

‎multipart-core/src/main/java/io/netty/contrib/multipart/UrlEncodedDecoder.java‎

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
final class UrlEncodedDecoder extends AbstractDecoder implements VintageAccess.UrlEncodedDecoder {
3030
private static final ByteProcessor FIND_KEY_END = value -> value != '=' && value != '&';
3131
private static final ByteProcessor FIND_VALUE_END = value -> value != '&' && value != '\r' && value != '\n';
32+
private static final ByteProcessor FIND_ESCAPE = value -> value != '%' && value != '+';
3233

3334
private State state = State.KEY;
3435

@@ -70,11 +71,20 @@ public Event next() {
7071
key = decodeAttribute(keyByteBuf.toString(charset), charset);
7172
} else {
7273
// whatwg spec first does percent decoding, then utf-8 decoding
73-
decodeComponent(keyByteBuf, true);
74-
key = keyByteBuf.toString(charset);
74+
ByteBuf undecodedKey = keyByteBuf;
75+
// decodeComponent takes ownership
76+
keyByteBuf = null;
77+
ByteBuf decodedKey = decodeComponent(undecodedKey, true);
78+
try {
79+
key = decodedKey.toString(charset);
80+
} finally {
81+
decodedKey.release();
82+
}
7583
}
7684
} finally {
77-
keyByteBuf.release();
85+
if (keyByteBuf != null) {
86+
keyByteBuf.release();
87+
}
7888
}
7989
if (!hasValue && !noValueAtEof) {
8090
// go to just before the '&', it will read as an empty value
@@ -247,14 +257,8 @@ public ByteBuf decodedContent() {
247257
throw new IllegalStateException("Not in CONTENT event");
248258
}
249259
ByteBuf b = undecodedContent;
250-
try {
251-
undecodedContent = null;
252-
decodeComponent(b, false);
253-
return b;
254-
} catch (Exception e) {
255-
b.release();
256-
throw e;
257-
}
260+
undecodedContent = null;
261+
return decodeComponent(b, false);
258262
}
259263

260264
@Override
@@ -264,37 +268,55 @@ public ByteBuf undecodedContent() {
264268
return b;
265269
}
266270

271+
/**
272+
* Percent-decode the given buffer. The input buffer is never modified, because it may be shared with the caller
273+
* (e.g. a slice of a buffer passed to {@link #add(ByteBuf)}) or be read-only. If the input contains no escape
274+
* sequences, it is returned as-is. Otherwise, the decoded data is written to a newly allocated buffer.
275+
*
276+
* @param buffer The buffer to decode. Ownership is transferred to this method, also on failure
277+
* @param key Whether this is a key, for the error message
278+
* @return The decoded buffer, owned by the caller
279+
*/
267280
@Override
268-
public void decodeComponent(ByteBuf buffer, boolean key) {
269-
int wi = buffer.readerIndex();
270-
for (int ri = wi; ri < buffer.writerIndex(); wi++, ri++) {
271-
byte b = buffer.getByte(ri);
272-
if (b == '%') {
273-
if (ri < buffer.writerIndex() - 2) {
274-
int hi = StringUtil.decodeHexNibble((char) buffer.getByte(ri + 1));
275-
int lo = StringUtil.decodeHexNibble((char) buffer.getByte(ri + 2));
276-
if (hi != -1 && lo != -1) {
277-
buffer.setByte(wi, (byte) ((hi << 4) + lo));
278-
ri += 2;
279-
continue;
280-
} else if (quirks.contains(DecoderQuirk.REFUSE_NON_HEX_PERCENT_DECODE)) {
281+
public ByteBuf decodeComponent(ByteBuf buffer, boolean key) {
282+
int firstEscape = buffer.forEachByte(FIND_ESCAPE);
283+
if (firstEscape == -1) {
284+
return buffer;
285+
}
286+
ByteBuf decoded = null;
287+
try {
288+
decoded = buffer.alloc().buffer(buffer.readableBytes());
289+
decoded.writeBytes(buffer, buffer.readerIndex(), firstEscape - buffer.readerIndex());
290+
for (int ri = firstEscape; ri < buffer.writerIndex(); ri++) {
291+
byte b = buffer.getByte(ri);
292+
if (b == '%') {
293+
if (ri < buffer.writerIndex() - 2) {
294+
int hi = StringUtil.decodeHexNibble((char) buffer.getByte(ri + 1));
295+
int lo = StringUtil.decodeHexNibble((char) buffer.getByte(ri + 2));
296+
if (hi != -1 && lo != -1) {
297+
decoded.writeByte((hi << 4) + lo);
298+
ri += 2;
299+
continue;
300+
} else if (quirks.contains(DecoderQuirk.REFUSE_NON_HEX_PERCENT_DECODE)) {
301+
// whatwg URL spec allows this
302+
failPercentDecode(key);
303+
}
304+
} else if (quirks.contains(DecoderQuirk.REFUSE_SHORT_PERCENT_DECODE)) {
281305
// whatwg URL spec allows this
282306
failPercentDecode(key);
283307
}
284-
} else if (quirks.contains(DecoderQuirk.REFUSE_SHORT_PERCENT_DECODE)) {
285-
// whatwg URL spec allows this
286-
failPercentDecode(key);
287308
}
309+
decoded.writeByte(b == '+' ? ' ' : b);
288310
}
289-
if (b == '+') {
290-
buffer.setByte(wi, (byte) ' ');
291-
continue;
292-
}
293-
if (ri != wi) {
294-
buffer.setByte(wi, b);
311+
ByteBuf result = decoded;
312+
decoded = null;
313+
return result;
314+
} finally {
315+
buffer.release();
316+
if (decoded != null) {
317+
decoded.release();
295318
}
296319
}
297-
buffer.writerIndex(wi);
298320
}
299321

300322
private void failPercentDecode(boolean key) {

‎multipart-core/src/main/java/io/netty/contrib/multipart/VintageAccess.java‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ public interface MultipartDecoder extends PostBodyDecoder {
6363
public interface UrlEncodedDecoder extends PostBodyDecoder {
6464
ByteBuf undecodedContent();
6565

66-
void decodeComponent(ByteBuf buffer, boolean key);
66+
/**
67+
* Percent-decode the given buffer without modifying it. Takes ownership of the input buffer (also on
68+
* failure) and returns a buffer owned by the caller, which may be the same instance if nothing needed
69+
* decoding.
70+
*/
71+
ByteBuf decodeComponent(ByteBuf buffer, boolean key);
6772

6873
int getCompactionThreshold();
6974

‎multipart-core/src/test/java/io/netty/contrib/multipart/UrlEncodedDecoderTest.java‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515
*/
1616
package io.netty.contrib.multipart;
1717

18+
import io.netty.buffer.ByteBuf;
1819
import io.netty.buffer.Unpooled;
1920
import io.netty.handler.codec.http.HttpHeaderNames;
2021
import org.junit.jupiter.api.Assertions;
2122
import org.junit.jupiter.api.Test;
23+
import org.junit.jupiter.params.ParameterizedTest;
24+
import org.junit.jupiter.params.provider.ValueSource;
2225

2326
import java.io.IOException;
2427
import java.nio.charset.StandardCharsets;
@@ -119,6 +122,69 @@ public void spacesValue() {
119122
}
120123
}
121124

125+
@ParameterizedTest
126+
@ValueSource(strings = {"foo=b%41r", "f%41o=bar", "f%41o=b%41r", "foo=b+r", "f+o=bar", "f%41o=b+r&x%41=y%41"})
127+
public void percentDecodingDoesNotModifyCallerBuffer(String input) {
128+
ByteBuf first = Unpooled.copiedBuffer(input, StandardCharsets.UTF_8);
129+
try (PostBodyDecoder decoder = PostBodyDecoder.builder().forUrlEncodedData()) {
130+
decoder.add(first.retain());
131+
decoder.endInput();
132+
drainAndDecode(decoder);
133+
Assertions.assertEquals(input.length(), first.writerIndex());
134+
Assertions.assertEquals(input, first.toString(0, input.length(), StandardCharsets.UTF_8));
135+
} finally {
136+
first.release();
137+
}
138+
}
139+
140+
@ParameterizedTest
141+
@ValueSource(strings = {"foo=b%41r", "f%41o=bar", "foo=b+r", "f+o=bar", "f%41o=b+r&x%41=y%41"})
142+
public void percentDecodingDoesNotModifyCallerBufferWithoutEof(String input) {
143+
// without endInput, an unterminated value is handed out as the whole remaining input buffer
144+
ByteBuf first = Unpooled.copiedBuffer(input, StandardCharsets.UTF_8);
145+
try (PostBodyDecoder decoder = PostBodyDecoder.builder().forUrlEncodedData()) {
146+
decoder.add(first.retain());
147+
drainAndDecode(decoder);
148+
Assertions.assertEquals(input.length(), first.writerIndex());
149+
Assertions.assertEquals(input, first.toString(0, input.length(), StandardCharsets.UTF_8));
150+
} finally {
151+
first.release();
152+
}
153+
}
154+
155+
@ParameterizedTest
156+
@ValueSource(strings = {"foo=b%41r", "f%41o=bar", "foo=b+r", "f+o=bar", "f%41o=b+r&x%41=y%41"})
157+
public void percentDecodingReadOnlyBuffer(String input) {
158+
try (PostBodyDecoder decoder = PostBodyDecoder.builder().forUrlEncodedData()) {
159+
decoder.add(Unpooled.copiedBuffer(input, StandardCharsets.UTF_8).asReadOnly());
160+
decoder.endInput();
161+
drainAndDecode(decoder);
162+
}
163+
}
164+
165+
@Test
166+
public void percentDecodingReadOnlyBufferValues() {
167+
try (PostBodyDecoder decoder = PostBodyDecoder.builder().forUrlEncodedData()) {
168+
decoder.add(Unpooled.copiedBuffer("f%41o=b%41r&x+y=1+2", StandardCharsets.UTF_8).asReadOnly());
169+
decoder.endInput();
170+
171+
expectField(decoder, "fAo", "bAr");
172+
expectField(decoder, "x y", "1 2");
173+
Assertions.assertNull(decoder.next());
174+
}
175+
}
176+
177+
private static void drainAndDecode(PostBodyDecoder decoder) {
178+
PostBodyDecoder.Event event;
179+
while ((event = decoder.next()) != null) {
180+
if (event == PostBodyDecoder.Event.HEADER) {
181+
Assertions.assertNotNull(decoder.parsedHeaderValue());
182+
} else if (event == PostBodyDecoder.Event.CONTENT) {
183+
decoder.decodedContent().release();
184+
}
185+
}
186+
}
187+
122188
@Test
123189
public void bufferCompaction() throws IOException {
124190
byte[] fullData = new byte[10 * 1024 * 1024];

‎multipart-vintage/src/main/java/io/netty/contrib/multipart/vintage/HttpPostStandardRequestDecoder.java‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -428,13 +428,9 @@ private void parseBodyAttributesStandard() {
428428
} else if (event == PostBodyDecoder.Event.FIELD_COMPLETE) {
429429
currentAttribute.addContent(Unpooled.EMPTY_BUFFER, true);
430430
// in netty 4, decoding happens late
431-
ByteBuf bb = currentAttribute.getByteBuf().retain();
432-
try {
433-
decoder.decodeComponent(bb, false);
434-
} catch (Exception e) {
435-
bb.release();
436-
throw e;
437-
}
431+
// decodeComponent does not modify the attribute content, which may be shared with the
432+
// caller's HttpContent buffer. It takes ownership of this retained reference.
433+
ByteBuf bb = decoder.decodeComponent(currentAttribute.getByteBuf().retain(), false);
438434
currentAttribute.setContent(bb);
439435

440436
addHttpData(currentAttribute);

‎multipart-vintage/src/test/java/io/netty/contrib/multipart/vintage/HttpPostStandardRequestDecoderTest.java‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,74 @@ void testDecodeZeroAttributesWithAmpersandPrefix() {
233233
decoder.destroy();
234234
}
235235

236+
@Test
237+
void testPercentDecodingDoesNotModifyOfferedBuffers() throws Exception {
238+
String firstBody = "k%41=v%41&x";
239+
String secondBody = "=y%42+z";
240+
HttpRequest request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/upload");
241+
HttpPostStandardRequestDecoder decoder = HttpPostRequestDecoder.builder()
242+
.dataFactory(httpDiskDataFactory())
243+
.buildStandard(request);
244+
ByteBuf first = Unpooled.copiedBuffer(firstBody, CharsetUtil.UTF_8);
245+
ByteBuf second = Unpooled.copiedBuffer(secondBody, CharsetUtil.UTF_8);
246+
try {
247+
decoder.offer(new DefaultHttpContent(first));
248+
decoder.offer(new DefaultLastHttpContent(second));
249+
250+
assertEquals(2, decoder.getBodyHttpDatas().size());
251+
assertMemoryAttribute(decoder.getBodyHttpData("kA"), "vA");
252+
assertMemoryAttribute(decoder.getBodyHttpData("x"), "yB z");
253+
254+
// absolute indices: the caller buffer content must not be changed by percent decoding
255+
assertEquals(firstBody, first.toString(0, firstBody.length(), CharsetUtil.UTF_8));
256+
assertEquals(secondBody, second.toString(0, secondBody.length(), CharsetUtil.UTF_8));
257+
} finally {
258+
decoder.destroy();
259+
first.release();
260+
second.release();
261+
}
262+
}
263+
264+
@Test
265+
void testPercentDecodingSingleChunkDoesNotModifyOfferedBuffer() {
266+
String body = "k%41+x=v%41+";
267+
HttpRequest request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/upload");
268+
HttpPostStandardRequestDecoder decoder = HttpPostRequestDecoder.builder()
269+
.dataFactory(httpDiskDataFactory())
270+
.buildStandard(request);
271+
ByteBuf buf = Unpooled.copiedBuffer(body, CharsetUtil.UTF_8);
272+
try {
273+
decoder.offer(new DefaultLastHttpContent(buf));
274+
275+
assertEquals(1, decoder.getBodyHttpDatas().size());
276+
assertMemoryAttribute(decoder.getBodyHttpData("kA x"), "vA ");
277+
assertEquals(body.length(), buf.writerIndex());
278+
assertEquals(body, buf.toString(0, body.length(), CharsetUtil.UTF_8));
279+
} finally {
280+
decoder.destroy();
281+
buf.release();
282+
}
283+
}
284+
285+
@Test
286+
void testPercentDecodingReadOnlyBuffer() {
287+
HttpRequest request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/upload");
288+
HttpPostStandardRequestDecoder decoder = HttpPostRequestDecoder.builder()
289+
.dataFactory(httpDiskDataFactory())
290+
.buildStandard(request);
291+
ByteBuf buf = Unpooled.copiedBuffer("k%41=v%41&a+b=c+d", CharsetUtil.UTF_8).asReadOnly();
292+
try {
293+
decoder.offer(new DefaultLastHttpContent(buf));
294+
295+
assertEquals(2, decoder.getBodyHttpDatas().size());
296+
assertMemoryAttribute(decoder.getBodyHttpData("kA"), "vA");
297+
assertMemoryAttribute(decoder.getBodyHttpData("a b"), "c d");
298+
} finally {
299+
decoder.destroy();
300+
buf.release();
301+
}
302+
}
303+
236304
private static DefaultHttpDataFactory httpDiskDataFactory() {
237305
return new DefaultHttpDataFactory(false);
238306
}

0 commit comments

Comments
 (0)