diff --git a/exclusion.dic b/exclusion.dic index 13da0e7..771fc25 100644 --- a/exclusion.dic +++ b/exclusion.dic @@ -17,3 +17,4 @@ testhost urls yaml yyyy +Zabcdefghijklmnopqrstuvwxyz diff --git a/src/Hosting/HttpCharacters.cs b/src/Hosting/HttpCharacters.cs new file mode 100644 index 0000000..6ea3861 --- /dev/null +++ b/src/Hosting/HttpCharacters.cs @@ -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 _allowedTokenChars = SearchValues.Create("!#$%&'*+-.^_`|~" + AlphaNumeric); + + public static int IndexOfInvalidTokenChar(ReadOnlySpan 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 +} diff --git a/src/Hosting/ResponseHeadersHelper.cs b/src/Hosting/ResponseHeadersHelper.cs index c28f745..a3519fe 100644 --- a/src/Hosting/ResponseHeadersHelper.cs +++ b/src/Hosting/ResponseHeadersHelper.cs @@ -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; @@ -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; @@ -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()) @@ -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); @@ -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(); @@ -137,7 +152,18 @@ private static ResponseHandlerEntry ParseHandler(BaseResponseHeadersConfiguratio } } + bool FilterValidHeaderName(KeyValuePair 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(kv.Key, new StringValues(kv.Value))) .ToArray(); @@ -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; diff --git a/src/Hosting/WebApplicationExtensions.cs b/src/Hosting/WebApplicationExtensions.cs index 6ba2528..fed179a 100644 --- a/src/Hosting/WebApplicationExtensions.cs +++ b/src/Hosting/WebApplicationExtensions.cs @@ -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 @@ -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; diff --git a/test/Hosting.Test/ResponseHeadersHelperTest.cs b/test/Hosting.Test/ResponseHeadersHelperTest.cs index f865df9..de9117d 100644 --- a/test/Hosting.Test/ResponseHeadersHelperTest.cs +++ b/test/Hosting.Test/ResponseHeadersHelperTest.cs @@ -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() @@ -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 + { + ["ResponseHeaders:Headers:x-header30"] = "value31", + ["ResponseHeaders:Headers:x header31"] = "value32", + ["ResponseHeaders:x@header33"] = "value34", + }); + + var loggerMock = new Mock(); + + 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()), Times.Exactly(2)); + + loggerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((o, t) => o.ToString()!.StartsWith("Invalid HTTP header ") && o.ToString()!.Contains("Invalid non-ASCII or control character in header: ")), + It.IsAny(), + It.IsAny>()), + Times.Exactly(2)); + + Assert.Equal(new Dictionary + { + ["x-header30"] = "value31", + }, handler.Headers); + + Assert.Equal(["x-header30"], handler.HeaderNames); + } }