Skip to content

Commit

Permalink
Validate HTTP header characters
Browse files Browse the repository at this point in the history
  • Loading branch information
thohng committed Jun 5, 2024
1 parent af9a739 commit 1aa253b
Show file tree
Hide file tree
Showing 5 changed files with 162 additions and 11 deletions.
1 change: 1 addition & 0 deletions exclusion.dic
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ testhost
urls
yaml
yyyy
Zabcdefghijklmnopqrstuvwxyz
77 changes: 77 additions & 0 deletions src/Hosting/HttpCharacters.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#if NET8_0_OR_GREATER
using System.Buffers;
#endif

namespace NetLah.Extensions.SpaServices.Hosting;

// https://github.com/dotnet/aspnetcore/blob/main/src/Shared/ServerInfrastructure/HttpCharacters.cs
internal static class HttpCharacters
{
#if NET8_0_OR_GREATER
private const string AlphaNumeric = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
private static readonly SearchValues<char> _allowedTokenChars = SearchValues.Create("!#$%&'*+-.^_`|~" + AlphaNumeric);

public static int IndexOfInvalidTokenChar(ReadOnlySpan<char> span) => span.IndexOfAnyExcept(_allowedTokenChars);
#else
private const int _tableSize = 128;
private static readonly bool[] _alphaNumeric = InitializeAlphaNumeric();
private static readonly bool[] _token = InitializeToken();
private static bool[] InitializeAlphaNumeric()
{
// ALPHA and DIGIT https://tools.ietf.org/html/rfc5234#appendix-B.1
var alphaNumeric = new bool[_tableSize];
for (var c = '0'; c <= '9'; c++)
{
alphaNumeric[c] = true;
}
for (var c = 'A'; c <= 'Z'; c++)
{
alphaNumeric[c] = true;
}
for (var c = 'a'; c <= 'z'; c++)
{
alphaNumeric[c] = true;
}
return alphaNumeric;
}

private static bool[] InitializeToken()
{
// tchar https://tools.ietf.org/html/rfc7230#appendix-B
var token = new bool[_tableSize];
Array.Copy(_alphaNumeric, token, _tableSize);
token['!'] = true;
token['#'] = true;
token['$'] = true;
token['%'] = true;
token['&'] = true;
token['\''] = true;
token['*'] = true;
token['+'] = true;
token['-'] = true;
token['.'] = true;
token['^'] = true;
token['_'] = true;
token['`'] = true;
token['|'] = true;
token['~'] = true;
return token;
}

public static int IndexOfInvalidTokenChar(string s)
{
var token = _token;

for (var i = 0; i < s.Length; i++)
{
var c = s[i];
if (c >= (uint)token.Length || !token[c])
{
return i;
}
}

return -1;
}
#endif
}
36 changes: 31 additions & 5 deletions src/Hosting/ResponseHeadersHelper.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using System.Buffers;
using System.Globalization;

namespace NetLah.Extensions.SpaServices.Hosting;

Expand All @@ -23,7 +26,7 @@ internal static class ResponseHeadersHelper

//private static readonly string[] PropertyNames = [.. PropertySet];

public static ResponseHeadersOptions Parse(IConfigurationRoot? configurationRoot, string sectionName)
public static ResponseHeadersOptions Parse(IConfigurationRoot? configurationRoot, string sectionName, ILogger logger)
{
ResponseHandlerEntry? defaultHandlerEntry = null;
var isEnabled = false;
Expand All @@ -37,7 +40,7 @@ public static ResponseHeadersOptions Parse(IConfigurationRoot? configurationRoot
if (configOptions.IsEnabled)
{
isEnabled = true;
defaultHandlerEntry = ParseHandler(configOptions, configuration);
defaultHandlerEntry = ParseHandler(configOptions, configuration, logger);
}

foreach (var item in configuration.GetChildren())
Expand All @@ -47,7 +50,7 @@ public static ResponseHeadersOptions Parse(IConfigurationRoot? configurationRoot
{
var configOptions1 = new BaseResponseHeadersConfigurationOptions();
item.Bind(configOptions1);
var handlerEntry = ParseHandler(configOptions1, item);
var handlerEntry = ParseHandler(configOptions1, item, logger);
if (handlerEntry.Headers.Length > 0)
{
handlers.Add(handlerEntry);
Expand All @@ -64,7 +67,19 @@ public static ResponseHeadersOptions Parse(IConfigurationRoot? configurationRoot
};
}

private static ResponseHandlerEntry ParseHandler(BaseResponseHeadersConfigurationOptions options, IConfigurationSection configuration)
private static string? ValidateHeaderNameCharacters(string headerCharacters)
{
var invalid = HttpCharacters.IndexOfInvalidTokenChar(headerCharacters);
if (invalid >= 0)
{
var character = string.Format(CultureInfo.InvariantCulture, "0x{0:X4}", (ushort)headerCharacters[invalid]);
var message = string.Format("Invalid non-ASCII or control character in header: {0}", character);
return message;
}
return null;
}

private static ResponseHandlerEntry ParseHandler(BaseResponseHeadersConfigurationOptions options, IConfigurationSection configuration, ILogger logger)
{
var headers = new Dictionary<string, string>();

Expand Down Expand Up @@ -137,7 +152,18 @@ private static ResponseHandlerEntry ParseHandler(BaseResponseHeadersConfiguratio
}
}

bool FilterValidHeaderName(KeyValuePair<string, string> pair)
{
var errorMessage = ValidateHeaderNameCharacters(pair.Key);
if (errorMessage != null)
{
logger.LogWarning("Invalid HTTP header '{key}': {error}", pair.Key, errorMessage);
}
return errorMessage == null;
}

var headersStringValues = headers
.Where(FilterValidHeaderName)
.Select(kv => new KeyValuePair<string, StringValues>(kv.Key, new StringValues(kv.Value)))
.ToArray();

Expand All @@ -149,7 +175,7 @@ private static ResponseHandlerEntry ParseHandler(BaseResponseHeadersConfiguratio
options.ContentTypeContain?.Where(v => !string.IsNullOrEmpty(v)).ToArray() ?? [],
options.StatusCode?.Where(v => v > 0).ToHashSet() ?? [],
headersStringValues,
[.. headers.Keys.OrderBy(s => s, DefaultStringComparer)],
[.. headersStringValues.Select(kv => kv.Key).OrderBy(s => s, DefaultStringComparer)],
[.. contentTypesSet.OrderBy(s => s, DefaultStringComparer)]);

return handlerEntry;
Expand Down
8 changes: 4 additions & 4 deletions src/Hosting/WebApplicationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ public static WebApplication UseSpaApp(this WebApplication app, ILogger? logger

var staticFileOptions = new StaticFileOptions();

var responseHeadersOptions = ResponseHeadersHelper.Parse(app.Configuration as IConfigurationRoot, "ResponseHeaders");
var loggerHeader = AppLogReference.GetAppLogLogger(typeof(AppOptions).Namespace + ".ResponseHeaders")
?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;

var responseHeadersOptions = ResponseHeadersHelper.Parse(app.Configuration as IConfigurationRoot, "ResponseHeaders", loggerHeader);

var isResponseHeadersEnabled = responseHeadersOptions != null
&& responseHeadersOptions.IsEnabled
Expand All @@ -56,9 +59,6 @@ public static WebApplication UseSpaApp(this WebApplication app, ILogger? logger

if (isResponseHeadersEnabled && responseHeadersOptions != null)
{
var loggerHeader = AppLogReference.GetAppLogLogger(typeof(AppOptions).Namespace + ".ResponseHeaders")
?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;

if (responseHeadersOptions.DefaultHandler != null)
{
var headerNames = responseHeadersOptions.DefaultHandler.HeaderNames;
Expand Down
51 changes: 49 additions & 2 deletions test/Hosting.Test/ResponseHeadersHelperTest.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Moq;

namespace NetLah.Extensions.SpaServices.Hosting.Test;

public class ResponseHeadersHelperTest
{
private static ResponseHeadersOptions Parse(IConfigurationRoot? configurationRoot, string sectionName)
=> ResponseHeadersHelper.Parse(configurationRoot, sectionName);
private static ResponseHeadersOptions Parse(IConfigurationRoot? configurationRoot, string sectionName, ILogger? logger = null)
=> ResponseHeadersHelper.Parse(configurationRoot, sectionName, logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);

[Fact]
public void DisabledTest()
Expand Down Expand Up @@ -683,4 +685,49 @@ public void HeaderKeyValueWithEqualTest()
["x-header28"] = "value29=hex",
}, handler.Headers);
}

[Fact]
public void HeaderKeyInvalidCharsTest()
{
var configuration = new ConfigurationManager();
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["ResponseHeaders:Headers:x-header30"] = "value31",
["ResponseHeaders:Headers:x header31"] = "value32",
["ResponseHeaders:x@header33"] = "value34",
});

var loggerMock = new Mock<ILogger>();

var options = Parse(configuration, "ResponseHeaders", loggerMock.Object);

Assert.NotNull(options);
Assert.NotNull(options.DefaultHandler);
Assert.Empty(options.Handlers);

var handler = options.DefaultHandler;
Assert.NotNull(handler);
Assert.Empty(handler.ContentTypeMatchEq);
Assert.Empty(handler.ContentTypeMatchContain);
Assert.Empty(handler.ContentTypeMatchStartWith);
Assert.Empty(handler.StatusCode);

//loggerMock.Verify(x => x.LogInformation(It.IsAny<string>()), Times.Exactly(2));

loggerMock.Verify(
x => x.Log(
LogLevel.Warning,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((o, t) => o.ToString()!.StartsWith("Invalid HTTP header ") && o.ToString()!.Contains("Invalid non-ASCII or control character in header: ")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Exactly(2));

Assert.Equal(new Dictionary<string, StringValues>
{
["x-header30"] = "value31",
}, handler.Headers);

Assert.Equal<string[]>(["x-header30"], handler.HeaderNames);
}
}

0 comments on commit 1aa253b

Please sign in to comment.