Skip to content

Fix various HTTP header parsing issues #116323

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/libraries/Fuzzing/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ publish
deployment
inputs
crash-*
timeout-*
inputs-*
17 changes: 16 additions & 1 deletion src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/HttpHeadersFuzzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,22 @@ static void Test(HttpHeaders headers, string name, string value)
}
catch (FormatException) { }

foreach (var _ in headers) { }
// If values were added with validation, they should not contain CR or LF characters.
foreach ((_, HeaderStringValues values) in headers.NonValidated)
{
foreach (string headerValue in values)
{
Assert.False(headerValue.ContainsAny('\r', '\n'));
}
}

foreach ((_, IEnumerable<string> values) in headers)
{
foreach (string headerValue in values)
{
Assert.False(headerValue.ContainsAny('\r', '\n'));
}
}
}
}
}
Expand Down
6 changes: 3 additions & 3 deletions src/libraries/Fuzzing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Build the runtime with the desired configuration if you haven't already:
```

> [!TIP]
> The `-rc release` configuration here builds runime in `Release` and libraries in `Debug` mode.
> The `-rc release` configuration here builds runtime in `Release` and libraries in `Debug` mode.
> Automated fuzzing runs use a `Checked` runtime + `Debug` libraries configuration by default.
> You can use any configuration locally, but `Checked` is recommended when testing changes in `System.Private.CoreLib`.

Expand All @@ -44,10 +44,10 @@ dotnet build
```

Run `run.bat`, which will create separate directories for each fuzzing target, instrument the relevant assemblies, and generate a helper script for running them locally.
When iterating on changes, remember to rebuild the project again: `dotnet build; .\run.bat`.
When iterating on changes, remember to rebuild the project again.

```cmd
run.bat
dotnet build; .\run.bat
```

Start fuzzing by running the `local-run.bat` script in the folder of the fuzzer you are interested in.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,14 +259,14 @@ private static bool TryReadUnknownPercentEncodedAlpnProtocolName(ReadOnlySpan<ch
builder.Append(value.Slice(0, idx));
}

if ((value.Length - idx) < 3 || !TryReadAlpnHexDigit(value[1], out int hi) || !TryReadAlpnHexDigit(value[2], out int lo))
if ((value.Length - idx) < 3 || !TryReadAlpnHexDigit(value[idx + 1], out int hi) || !TryReadAlpnHexDigit(value[idx + 2], out int lo))
{
builder.Dispose();
result = null;
return false;
}

builder.Append((char)((hi << 8) | lo));
builder.Append((char)((hi << 4) | lo));

value = value.Slice(idx + 3);
idx = value.IndexOf('%');
Expand All @@ -279,7 +279,7 @@ private static bool TryReadUnknownPercentEncodedAlpnProtocolName(ReadOnlySpan<ch
}

result = builder.ToString();
return true;
return !HttpRuleParser.ContainsNewLine(result);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;

namespace System.Net.Http.Headers
Expand All @@ -18,8 +19,10 @@ private CookieHeaderParser()

public override bool TryParseValue(string? value, object? storeValue, ref int index, [NotNullWhen(true)] out object? parsedValue)
{
Debug.Assert(index == 0);

// Some headers support empty/null values. This one doesn't.
if (string.IsNullOrEmpty(value) || (index == value.Length))
if (string.IsNullOrEmpty(value) || HttpRuleParser.ContainsNewLine(value))
{
parsedValue = null;
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ internal static class HttpRuleParser
private static readonly SearchValues<char> s_hostDelimiterChars =
SearchValues.Create("/ \t\r,");

// Characters such as '?' or '#' are interpreted as an end of the host part of the URI, so they will not be validated in the same way.
private static readonly SearchValues<char> s_disallowedHostChars =
SearchValues.Create("/\\?#@");

private const int MaxNestedCount = 5;

internal const char CR = (char)13;
Expand Down Expand Up @@ -193,8 +197,8 @@ internal static HttpParseResult GetQuotedPairLength(string input, int startIndex
}

// Quoted-char has 2 characters. Check whether there are 2 chars left ('\' + char)
// If so, check whether the character is in the range 0-127. If not, it's an invalid value.
if ((startIndex + 2 > input.Length) || (input[startIndex + 1] > 127))
// If so, check whether the character is in the range 0-127 and not a new line. Otherwise, it's an invalid value.
if ((startIndex + 2 > input.Length) || (input[startIndex + 1] is > (char)127 or '\r' or '\n'))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or '\0' ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not currently blocking null specifically elsewhere. If we want to start also filtering those out, yes, this would be one of the places to update.
But there are more places we'd also have to update (essentially anywhere we're checking for new lines right now). I'd leave that to a follow-up PR.

We could choose to move such validation to a more central place that runs for every header instead of having it split and hoping that all of the implementations are doing similar things.

{
return HttpParseResult.InvalidFormat;
}
Expand Down Expand Up @@ -303,8 +307,19 @@ private static HttpParseResult GetExpressionLength(string input, int startIndex,

private static bool IsValidHostName(ReadOnlySpan<char> host)
{
// Also add user info (u@) to make sure 'host' doesn't include user info.
return Uri.TryCreate($"http://u@{host}/", UriKind.Absolute, out _);
if (host.ContainsAny(s_disallowedHostChars))
{
return false;
}

// Using a trailing slash as Uri ignores trailing whitespace otherwise.
if (!Uri.TryCreate($"http://{host}/", UriKind.Absolute, out _))
{
return false;
}

Debug.Assert(!ContainsNewLine(host.ToString()));
return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ namespace System.Net.Http.Tests
{
public class AltSvcHeaderParserTest
{
[Fact]
public void TryParse_InvalidValueString_ReturnsFalse()
[Theory]
[InlineData("a=")]
[InlineData("%aa=\":123\"")] // Only uppercase hex is allowed
[InlineData("%0A=\":123\"")] // Encoded new line
public void TryParse_InvalidValueString_ReturnsFalse(string value)
{
HttpHeaderParser parser = AltSvcHeaderParser.Parser;
string invalidInput = "a=";
int startIndex = 0;

Assert.False(parser.TryParseValue(invalidInput, null, ref startIndex, out var _));
Assert.False(parser.TryParseValue(value, null, ref startIndex, out object? parsedValue));
Assert.Equal(0, startIndex);
Assert.Null(parsedValue);
}

[Theory]
Expand Down Expand Up @@ -117,6 +121,15 @@ public static IEnumerable<object[]> SuccessfulParseData()
AltSvcHeaderValue.Clear
}
};

// Encoded protocol name
yield return new object[]
{
"AB%43%44%EF=\":123\"", new[]
{
new AltSvcHeaderValue("ABCD\u00EF", host: null, 123, TimeSpan.FromTicks(AltSvcHeaderParser.DefaultMaxAgeTicks), persist: false)
}
};
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Net.Http.Headers;

using Xunit;

namespace System.Net.Http.Tests
{
public class CookieHeaderParserTest
{
[Fact]
public void TryParse_ValidValueString_ReturnsTrue()
{
HttpHeaderParser parser = CookieHeaderParser.Parser;
string validInput = "Hello World";
int startIndex = 0;

Assert.True(parser.TryParseValue(validInput, null, ref startIndex, out object? parsedValue));
Assert.Equal(validInput.Length, startIndex);
Assert.Same(validInput, parsedValue);
}

[Fact]
public void TryParse_InvalidValueString_ReturnsFalse()
{
HttpHeaderParser parser = CookieHeaderParser.Parser;
string invalidInput = "Hello\r\nWorld";
int startIndex = 0;

Assert.False(parser.TryParseValue(invalidInput, null, ref startIndex, out object? parsedValue));
Assert.Equal(0, startIndex);
Assert.Null(parsedValue);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,15 @@ public void TryParse_SetOfInvalidValueStrings_ReturnsFalse()
CheckInvalidParsedValue("host host", 0);
CheckInvalidParsedValue("host,", 0);
CheckInvalidParsedValue("host ,", 0);
CheckInvalidParsedValue("host/", 0);
CheckInvalidParsedValue("/", 0);
CheckInvalidParsedValue(" , ", 0);
CheckInvalidParsedValue(" host\r\n ", 0);
CheckInvalidParsedValue(" host\r\n ", 1);
CheckInvalidParsedValue("host#\r\n", 0);
CheckInvalidParsedValue(" host?\r\n", 0);
CheckInvalidParsedValue("foo:bar@host", 0);
CheckInvalidParsedValue("host\n", 0);
}

#region Helper methods
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ public void GetQuotedPairLength_SetOfInvalidQuotedPairs_AllConsideredInvalid()
// only ASCII chars allowed in quoted-pair
AssertGetQuotedPairLength("\\\u00FC", 0, 0, HttpParseResult.InvalidFormat);

// New lines are not allowed
AssertGetQuotedPairLength("\\\r", 0, 0, HttpParseResult.InvalidFormat);
AssertGetQuotedPairLength("\\\n", 0, 0, HttpParseResult.InvalidFormat);

// a quoted-pair needs 1 char after '\'
AssertGetQuotedPairLength("\\", 0, 0, HttpParseResult.InvalidFormat);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@
<Compile Include="Headers\ContentDispositionHeaderValueTest.cs" />
<Compile Include="Headers\ContentRangeHeaderValueTest.cs" />
<Compile Include="Headers\DateHeaderParserTest.cs" />
<Compile Include="Headers\CookieHeaderParserTest.cs" />
<Compile Include="Headers\EntityTagHeaderParserTest.cs" />
<Compile Include="Headers\EntityTagHeaderValueTest.cs" />
<Compile Include="Headers\GenericHeaderParserTest\AuthenticationParserTest.cs" />
Expand Down
Loading