Skip to content

Commit 61794b7

Browse files
BrennanConroywtgodbe
authored andcommitted
Merged PR 52770: Fix chunked request parsing
1 parent 0f5ae9a commit 61794b7

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
@@ -737,4 +737,7 @@ For more information on configuring HTTPS see https://go.microsoft.com/fwlink/?l
737737
<data name="Http3ControlStreamFrameTooLarge" xml:space="preserve">
738738
<value>The client sent a {frameType} frame to a control stream that was too large.</value>
739739
</data>
740+
<data name="BadRequest_BadChunkExtension" xml:space="preserve">
741+
<value>Bad chunk extension.</value>
742+
</data>
740743
</root>

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

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

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

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

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

352362
do
353363
{
354-
SequencePosition? extensionCursorPosition = buffer.PositionOf(ByteCR);
364+
SequencePosition? extensionCursorPosition;
365+
if (InsecureChunkedParsing)
366+
{
367+
extensionCursorPosition = buffer.PositionOf(ByteCR);
368+
}
369+
else
370+
{
371+
extensionCursorPosition = buffer.PositionOfAny(ByteCR, ByteLF);
372+
}
373+
355374
if (extensionCursorPosition == null)
356375
{
357376
// End marker not found yet
358377
consumed = buffer.End;
359378
examined = buffer.End;
360379
AddAndCheckObservedBytes(buffer.Length);
361380
return;
362-
};
381+
}
363382

364383
var extensionCursor = extensionCursorPosition.Value;
384+
365385
var charsToByteCRExclusive = buffer.Slice(0, extensionCursor).Length;
366386

367387
var suffixBuffer = buffer.Slice(extensionCursor);
@@ -376,7 +396,9 @@ private void ParseExtension(ReadOnlySequence<byte> buffer, out SequencePosition
376396
suffixBuffer = suffixBuffer.Slice(0, 2);
377397
var suffixSpan = suffixBuffer.ToSpan();
378398

379-
if (suffixSpan[1] == '\n')
399+
if (InsecureChunkedParsing
400+
? (suffixSpan[1] == ByteLF)
401+
: (suffixSpan[0] == ByteCR && suffixSpan[1] == ByteLF))
380402
{
381403
// We consumed the \r\n at the end of the extension, so switch modes.
382404
_mode = _inputLength > 0 ? Mode.Data : Mode.Trailer;
@@ -385,13 +407,22 @@ private void ParseExtension(ReadOnlySequence<byte> buffer, out SequencePosition
385407
examined = suffixBuffer.End;
386408
AddAndCheckObservedBytes(charsToByteCRExclusive + 2);
387409
}
388-
else
410+
else if (InsecureChunkedParsing)
389411
{
412+
examined = buffer.Start;
390413
// Don't consume suffixSpan[1] in case it is also a \r.
391414
buffer = buffer.Slice(charsToByteCRExclusive + 1);
392415
consumed = extensionCursor;
393416
AddAndCheckObservedBytes(charsToByteCRExclusive + 1);
394417
}
418+
else
419+
{
420+
consumed = suffixBuffer.End;
421+
examined = suffixBuffer.End;
422+
423+
// We have \rX or \nX, that's an invalid extension.
424+
KestrelBadHttpRequestException.Throw(RequestRejectionReason.BadChunkExtension);
425+
}
395426
} while (_mode == Mode.Extension);
396427
}
397428

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,
@@ -32,5 +33,5 @@ internal enum RequestRejectionReason
3233
MissingHostHeader,
3334
MultipleHostHeaders,
3435
InvalidHostHeader,
35-
RequestBodyExceedsContentLength
36+
RequestBodyExceedsContentLength,
3637
}

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
@@ -9,6 +9,7 @@
99
using System.Linq;
1010
using System.Text;
1111
using System.Threading.Tasks;
12+
using Microsoft.AspNetCore.Hosting.Server;
1213
using Microsoft.AspNetCore.Http;
1314
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http;
1415
using Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests.TestTransport;
@@ -21,6 +22,70 @@ namespace Microsoft.AspNetCore.Server.Kestrel.InMemory.FunctionalTests;
2122

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

0 commit comments

Comments
 (0)