Skip to content

Commit b50ab86

Browse files
Merged PR 51302: Fix chunked request parsing (#64037)
Co-authored-by: Brennan Conroy <[email protected]>
1 parent c299d9c commit b50ab86

File tree

6 files changed

+195
-10
lines changed

6 files changed

+195
-10
lines changed

src/Servers/Kestrel/Core/src/CoreStrings.resx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,4 +740,7 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l
740740
<data name="Http3ControlStreamFrameTooLarge" xml:space="preserve">
741741
<value>The client sent a {frameType} frame to a control stream that was too large.</value>
742742
</data>
743+
<data name="BadRequest_BadChunkExtension" xml:space="preserve">
744+
<value>Bad chunk extension.</value>
745+
</data>
743746
</root>

src/Servers/Kestrel/Core/src/Internal/Http/Http1ChunkedEncodingMessageBody.cs

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ internal sealed class Http1ChunkedEncodingMessageBody : Http1MessageBody
1616
{
1717
// byte consts don't have a data type annotation so we pre-cast it
1818
private const byte ByteCR = (byte)'\r';
19+
private const byte ByteLF = (byte)'\n';
1920
// "7FFFFFFF\r\n" is the largest chunk size that could be returned as an int.
2021
private const int MaxChunkPrefixBytes = 10;
2122

@@ -27,6 +28,8 @@ internal sealed class Http1ChunkedEncodingMessageBody : Http1MessageBody
2728
private readonly Pipe _requestBodyPipe;
2829
private ReadResult _readResult;
2930

31+
private static readonly bool InsecureChunkedParsing = AppContext.TryGetSwitch("Microsoft.AspNetCore.Server.Kestrel.EnableInsecureChunkedRequestParsing", out var value) && value;
32+
3033
public Http1ChunkedEncodingMessageBody(Http1Connection context, bool keepAlive)
3134
: base(context, keepAlive)
3235
{
@@ -345,25 +348,42 @@ private void ParseChunkedPrefix(in ReadOnlySequence<byte> buffer, out SequencePo
345348
KestrelBadHttpRequestException.Throw(RequestRejectionReason.BadChunkSizeData);
346349
}
347350

351+
// https://www.rfc-editor.org/rfc/rfc9112#section-7.1
352+
// chunk = chunk-size [ chunk-ext ] CRLF
353+
// chunk-data CRLF
354+
355+
// https://www.rfc-editor.org/rfc/rfc9112#section-7.1.1
356+
// chunk-ext = *( BWS ";" BWS chunk-ext-name
357+
// [BWS "=" BWS chunk-ext-val] )
358+
// chunk-ext-name = token
359+
// chunk-ext-val = token / quoted-string
348360
private void ParseExtension(ReadOnlySequence<byte> buffer, out SequencePosition consumed, out SequencePosition examined)
349361
{
350-
// Chunk-extensions not currently parsed
351-
// Just drain the data
352-
examined = buffer.Start;
362+
// Chunk-extensions parsed for \r\n and throws for unpaired \r or \n.
353363

354364
do
355365
{
356-
SequencePosition? extensionCursorPosition = buffer.PositionOf(ByteCR);
366+
SequencePosition? extensionCursorPosition;
367+
if (InsecureChunkedParsing)
368+
{
369+
extensionCursorPosition = buffer.PositionOf(ByteCR);
370+
}
371+
else
372+
{
373+
extensionCursorPosition = buffer.PositionOfAny(ByteCR, ByteLF);
374+
}
375+
357376
if (extensionCursorPosition == null)
358377
{
359378
// End marker not found yet
360379
consumed = buffer.End;
361380
examined = buffer.End;
362381
AddAndCheckObservedBytes(buffer.Length);
363382
return;
364-
};
383+
}
365384

366385
var extensionCursor = extensionCursorPosition.Value;
386+
367387
var charsToByteCRExclusive = buffer.Slice(0, extensionCursor).Length;
368388

369389
var suffixBuffer = buffer.Slice(extensionCursor);
@@ -378,7 +398,9 @@ private void ParseExtension(ReadOnlySequence<byte> buffer, out SequencePosition
378398
suffixBuffer = suffixBuffer.Slice(0, 2);
379399
var suffixSpan = suffixBuffer.ToSpan();
380400

381-
if (suffixSpan[1] == '\n')
401+
if (InsecureChunkedParsing
402+
? (suffixSpan[1] == ByteLF)
403+
: (suffixSpan[0] == ByteCR && suffixSpan[1] == ByteLF))
382404
{
383405
// We consumed the \r\n at the end of the extension, so switch modes.
384406
_mode = _inputLength > 0 ? Mode.Data : Mode.Trailer;
@@ -387,13 +409,22 @@ private void ParseExtension(ReadOnlySequence<byte> buffer, out SequencePosition
387409
examined = suffixBuffer.End;
388410
AddAndCheckObservedBytes(charsToByteCRExclusive + 2);
389411
}
390-
else
412+
else if (InsecureChunkedParsing)
391413
{
414+
examined = buffer.Start;
392415
// Don't consume suffixSpan[1] in case it is also a \r.
393416
buffer = buffer.Slice(charsToByteCRExclusive + 1);
394417
consumed = extensionCursor;
395418
AddAndCheckObservedBytes(charsToByteCRExclusive + 1);
396419
}
420+
else
421+
{
422+
consumed = suffixBuffer.End;
423+
examined = suffixBuffer.End;
424+
425+
// We have \rX or \nX, that's an invalid extension.
426+
KestrelBadHttpRequestException.Throw(RequestRejectionReason.BadChunkExtension);
427+
}
397428
} while (_mode == Mode.Extension);
398429
}
399430

src/Servers/Kestrel/Core/src/Internal/Http/RequestRejectionReason.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ internal enum RequestRejectionReason
1616
UnexpectedEndOfRequestContent,
1717
BadChunkSuffix,
1818
BadChunkSizeData,
19+
BadChunkExtension,
1920
ChunkedRequestIncomplete,
2021
InvalidRequestTarget,
2122
InvalidCharactersInHeaderName,
@@ -31,5 +32,5 @@ internal enum RequestRejectionReason
3132
ConnectMethodRequired,
3233
MissingHostHeader,
3334
MultipleHostHeaders,
34-
InvalidHostHeader
35+
InvalidHostHeader,
3536
}

src/Servers/Kestrel/Core/src/KestrelBadHttpRequestException.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ internal static BadHttpRequestException GetException(RequestRejectionReason reas
4949
case RequestRejectionReason.BadChunkSizeData:
5050
ex = new BadHttpRequestException(CoreStrings.BadRequest_BadChunkSizeData, StatusCodes.Status400BadRequest, reason);
5151
break;
52+
case RequestRejectionReason.BadChunkExtension:
53+
ex = new BadHttpRequestException(CoreStrings.BadRequest_BadChunkExtension, StatusCodes.Status400BadRequest, reason);
54+
break;
5255
case RequestRejectionReason.ChunkedRequestIncomplete:
5356
ex = new BadHttpRequestException(CoreStrings.BadRequest_ChunkedRequestIncomplete, StatusCodes.Status400BadRequest, reason);
5457
break;

src/Servers/Kestrel/Core/test/MessageBodyTests.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,14 +338,14 @@ public async Task ReadExitsGivenIncompleteChunkedExtension()
338338
var stream = new HttpRequestStream(Mock.Of<IHttpBodyControlFeature>(), reader);
339339
reader.StartAcceptingReads(body);
340340

341-
input.Add("5;\r\0");
341+
input.Add("5;\r");
342342

343343
var buffer = new byte[1024];
344344
var readTask = stream.ReadAsync(buffer, 0, buffer.Length);
345345

346346
Assert.False(readTask.IsCompleted);
347347

348-
input.Add("\r\r\r\nHello\r\n0\r\n\r\n");
348+
input.Add("\nHello\r\n0\r\n\r\n");
349349

350350
Assert.Equal(5, await readTask.DefaultTimeout());
351351
try

src/Servers/Kestrel/test/InMemory.FunctionalTests/ChunkedRequestTests.cs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Buffers;
55
using System.Globalization;
66
using System.Text;
7+
using Microsoft.AspNetCore.Hosting.Server;
78
using Microsoft.AspNetCore.Http;
89
using Microsoft.AspNetCore.InternalTesting;
910
using Microsoft.AspNetCore.Server.Kestrel.Core;
@@ -18,6 +19,70 @@ namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests;
1819

1920
public class ChunkedRequestTests : LoggedTest
2021
{
22+
[Theory]
23+
[InlineData("2;\rxx\r\nxy\r\n0")] // \r in chunk extensions
24+
[InlineData("2;\nxx\r\nxy\r\n0")] // \n in chunk extensions
25+
public async Task RejectsInvalidChunkExtensions(string invalidChunkLine)
26+
{
27+
var testContext = new TestServiceContext(LoggerFactory);
28+
29+
await using (var server = new TestServer(AppChunked, testContext))
30+
{
31+
using (var connection = server.CreateConnection())
32+
{
33+
await connection.Send(
34+
"POST / HTTP/1.1",
35+
"Host:",
36+
"Transfer-Encoding: chunked",
37+
"Content-Type: text/plain",
38+
"",
39+
invalidChunkLine,
40+
"",
41+
"");
42+
await connection.ReceiveEnd(
43+
"HTTP/1.1 400 Bad Request",
44+
"Content-Length: 0",
45+
"Connection: close",
46+
$"Date: {testContext.DateHeaderValue}",
47+
"",
48+
"");
49+
}
50+
}
51+
}
52+
53+
[Theory]
54+
[InlineData("2;a=b;b=c\r\nxy\r\n0")] // Multiple chunk extensions
55+
[InlineData("2; \r\nxy\r\n0")] // Space in chunk extensions (BWS)
56+
[InlineData("2;;;\r\nxy\r\n0")] // Multiple ';' in chunk extensions
57+
[InlineData("2;novalue\r\nxy\r\n0")] // Name only chunk extension
58+
//[InlineData("2 ;\r\nxy\r\n0")] // Technically allowed per spec, but we never supported it, and no one should be sending it
59+
public async Task AllowsValidChunkExtensions(string chunkLine)
60+
{
61+
var testContext = new TestServiceContext(LoggerFactory);
62+
63+
await using (var server = new TestServer(AppChunked, testContext))
64+
{
65+
using (var connection = server.CreateConnection())
66+
{
67+
await connection.Send(
68+
"POST / HTTP/1.1",
69+
"Host:",
70+
"Transfer-Encoding: chunked",
71+
"Content-Type: text/plain",
72+
"",
73+
chunkLine,
74+
"",
75+
"");
76+
await connection.Receive(
77+
"HTTP/1.1 200 OK",
78+
"Content-Length: 2",
79+
$"Date: {testContext.DateHeaderValue}",
80+
"",
81+
"xy");
82+
}
83+
}
84+
}
85+
2186
private async Task App(HttpContext httpContext)
2287
{
2388
var request = httpContext.Request;
@@ -1120,4 +1185,86 @@ await connection.Receive(
11201185
}
11211186
}
11221187
}
1188+
1189+
[Fact]
1190+
public async Task MultiReadWithInvalidNewlineAcrossReads()
1191+
{
1192+
// Inline so that we know when the first connection.Send has been parsed so we can send the next part
1193+
var testContext = new TestServiceContext(LoggerFactory)
1194+
{ Scheduler = System.IO.Pipelines.PipeScheduler.Inline };
1195+
1196+
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
1197+
1198+
await using (var server = new TestServer(async httpContext =>
1199+
{
1200+
var request = httpContext.Request;
1201+
var readTask = request.BodyReader.ReadAsync();
1202+
tcs.TrySetResult();
1203+
var readResult = await readTask;
1204+
request.BodyReader.AdvanceTo(readResult.Buffer.End);
1205+
}, testContext))
1206+
{
1207+
using (var connection = server.CreateConnection())
1208+
{
1209+
await connection.SendAll(
1210+
"GET / HTTP/1.1",
1211+
"Host:",
1212+
"Transfer-Encoding: chunked",
1213+
"",
1214+
"1;\r");
1215+
await tcs.Task;
1216+
await connection.SendAll(
1217+
"\r");
1218+
1219+
await connection.ReceiveEnd(
1220+
"HTTP/1.1 400 Bad Request",
1221+
"Content-Length: 0",
1222+
"Connection: close",
1223+
$"Date: {testContext.DateHeaderValue}",
1224+
"",
1225+
"");
1226+
}
1227+
}
1228+
}
1229+
1230+
[Fact]
1231+
public async Task InvalidNewlineInFirstReadWithPartialChunkExtension()
1232+
{
1233+
// Inline so that we know when the first connection.Send has been parsed so we can send the next part
1234+
var testContext = new TestServiceContext(LoggerFactory)
1235+
{ Scheduler = System.IO.Pipelines.PipeScheduler.Inline };
1236+
1237+
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
1238+
1239+
await using (var server = new TestServer(async httpContext =>
1240+
{
1241+
var request = httpContext.Request;
1242+
var readTask = request.BodyReader.ReadAsync();
1243+
tcs.TrySetResult();
1244+
var readResult = await readTask;
1245+
request.BodyReader.AdvanceTo(readResult.Buffer.End);
1246+
}, testContext))
1247+
{
1248+
using (var connection = server.CreateConnection())
1249+
{
1250+
await connection.SendAll(
1251+
"GET / HTTP/1.1",
1252+
"Host:",
1253+
"Transfer-Encoding: chunked",
1254+
"",
1255+
"1;\n");
1256+
await tcs.Task;
1257+
await connection.SendAll(
1258+
"t");
1259+
1260+
await connection.ReceiveEnd(
1261+
"HTTP/1.1 400 Bad Request",
1262+
"Content-Length: 0",
1263+
"Connection: close",
1264+
$"Date: {testContext.DateHeaderValue}",
1265+
"",
1266+
"");
1267+
}
1268+
}
1269+
}
11231270
}

0 commit comments

Comments
 (0)