diff --git a/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersMiddleware.cs b/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersMiddleware.cs index 1863087c0e..d5e074de57 100644 --- a/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersMiddleware.cs +++ b/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersMiddleware.cs @@ -2,25 +2,63 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using System.Linq; using System.Net; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Microsoft.Extensions.Primitives; namespace Microsoft.AspNetCore.HttpOverrides { public class ForwardedHeadersMiddleware { + private static readonly bool[] HostCharValidity = new bool[127]; + private static readonly bool[] SchemeCharValidity = new bool[123]; + private readonly ForwardedHeadersOptions _options; private readonly RequestDelegate _next; private readonly ILogger _logger; + static ForwardedHeadersMiddleware() + { + // RFC 3986 scheme = ALPHA * (ALPHA / DIGIT / "+" / "-" / ".") + SchemeCharValidity['+'] = true; + SchemeCharValidity['-'] = true; + SchemeCharValidity['.'] = true; + + // Host Matches Http.Sys and Kestrel + // Host Matches RFC 3986 except "*" / "+" / "," / ";" / "=" and "%" HEXDIG HEXDIG which are not allowed by Http.Sys + HostCharValidity['!'] = true; + HostCharValidity['$'] = true; + HostCharValidity['&'] = true; + HostCharValidity['\''] = true; + HostCharValidity['('] = true; + HostCharValidity[')'] = true; + HostCharValidity['-'] = true; + HostCharValidity['.'] = true; + HostCharValidity['_'] = true; + HostCharValidity['~'] = true; + for (var ch = '0'; ch <= '9'; ch++) + { + SchemeCharValidity[ch] = true; + HostCharValidity[ch] = true; + } + for (var ch = 'A'; ch <= 'Z'; ch++) + { + SchemeCharValidity[ch] = true; + HostCharValidity[ch] = true; + } + for (var ch = 'a'; ch <= 'z'; ch++) + { + SchemeCharValidity[ch] = true; + HostCharValidity[ch] = true; + } + } + public ForwardedHeadersMiddleware(RequestDelegate next, ILoggerFactory loggerFactory, IOptions options) { if (next == null) @@ -179,7 +217,7 @@ namespace Microsoft.AspNetCore.HttpOverrides if (checkProto) { - if (!string.IsNullOrEmpty(set.Scheme)) + if (!string.IsNullOrEmpty(set.Scheme) && TryValidateScheme(set.Scheme)) { applyChanges = true; currentValues.Scheme = set.Scheme; @@ -193,7 +231,7 @@ namespace Microsoft.AspNetCore.HttpOverrides if (checkHost) { - if (!string.IsNullOrEmpty(set.Host)) + if (!string.IsNullOrEmpty(set.Host) && TryValidateHost(set.Host)) { applyChanges = true; currentValues.Host = set.Host; @@ -288,5 +326,124 @@ namespace Microsoft.AspNetCore.HttpOverrides public string Host; public string Scheme; } + + // Empty was checked for by the caller + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryValidateScheme(string scheme) + { + for (var i = 0; i < scheme.Length; i++) + { + if (!IsValidSchemeChar(scheme[i])) + { + return false; + } + } + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidSchemeChar(char ch) + { + return ch < SchemeCharValidity.Length && SchemeCharValidity[ch]; + } + + // Empty was checked for by the caller + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryValidateHost(string host) + { + if (host[0] == '[') + { + return TryValidateIPv6Host(host); + } + + if (host[0] == ':') + { + // Only a port + return false; + } + + var i = 0; + for (; i < host.Length; i++) + { + if (!IsValidHostChar(host[i])) + { + break; + } + } + return TryValidateHostPort(host, i); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsValidHostChar(char ch) + { + return ch < HostCharValidity.Length && HostCharValidity[ch]; + } + + // The lead '[' was already checked + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryValidateIPv6Host(string hostText) + { + for (var i = 1; i < hostText.Length; i++) + { + var ch = hostText[i]; + if (ch == ']') + { + // [::1] is the shortest valid IPv6 host + if (i < 4) + { + return false; + } + return TryValidateHostPort(hostText, i + 1); + } + + if (!IsHex(ch) && ch != ':' && ch != '.') + { + return false; + } + } + + // Must contain a ']' + return false; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool TryValidateHostPort(string hostText, int offset) + { + if (offset == hostText.Length) + { + // No port + return true; + } + + if (hostText[offset] != ':' || hostText.Length == offset + 1) + { + // Must have at least one number after the colon if present. + return false; + } + + for (var i = offset + 1; i < hostText.Length; i++) + { + if (!IsNumeric(hostText[i])) + { + return false; + } + } + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsNumeric(char ch) + { + return '0' <= ch && ch <= '9'; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private bool IsHex(char ch) + { + return IsNumeric(ch) + || ('a' <= ch && ch <= 'f') + || ('A' <= ch && ch <= 'F'); + } } } diff --git a/test/Microsoft.AspNetCore.HttpOverrides.Tests/ForwardedHeadersMiddlewareTest.cs b/test/Microsoft.AspNetCore.HttpOverrides.Tests/ForwardedHeadersMiddlewareTest.cs index ea905dace0..81d6ccf15a 100644 --- a/test/Microsoft.AspNetCore.HttpOverrides.Tests/ForwardedHeadersMiddlewareTest.cs +++ b/test/Microsoft.AspNetCore.HttpOverrides.Tests/ForwardedHeadersMiddlewareTest.cs @@ -252,6 +252,146 @@ namespace Microsoft.AspNetCore.HttpOverrides Assert.Equal("testhost", context.Request.Host.ToString()); } + public static TheoryData HostHeaderData + { + get + { + return new TheoryData() { + "z", + "1", + "y:1", + "1:1", + "[ABCdef]", + "[abcDEF]:0", + "[abcdef:127.2355.1246.114]:0", + "[::1]:80", + "127.0.0.1:80", + "900.900.900.900:9523547852", + "foo", + "foo:234", + "foo.bar.baz", + "foo.BAR.baz:46245", + "foo.ba-ar.baz:46245", + "-foo:1234", + "xn--c1yn36f:134", + "-", + "_", + "~", + "!", + "$", + "'", + "(", + ")", + }; + } + } + + [Theory] + [MemberData(nameof(HostHeaderData))] + public async Task XForwardedHostAllowsValidCharacters(string host) + { + var assertsExecuted = false; + + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedHost + }); + app.Run(context => + { + Assert.Equal(host, context.Request.Host.ToString()); + assertsExecuted = true; + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + await server.SendAsync(c => + { + c.Request.Headers["X-Forwarded-Host"] = host; + }); + Assert.True(assertsExecuted); + } + + public static TheoryData HostHeaderInvalidData + { + get + { + // see https://tools.ietf.org/html/rfc7230#section-5.4 + var data = new TheoryData() { + "", // Empty + "[]", // Too short + "[::]", // Too short + "[ghijkl]", // Non-hex + "[afd:adf:123", // Incomplete + "[afd:adf]123", // Missing : + "[afd:adf]:", // Missing port digits + "[afd adf]", // Space + "[ad-314]", // dash + ":1234", // Missing host + "a:b:c", // Missing [] + "::1", // Missing [] + "::", // Missing everything + "abcd:1abcd", // Letters in port + "abcd:1.2", // Dot in port + "1.2.3.4:", // Missing port digits + "1.2 .4", // Space + }; + + // These aren't allowed anywhere in the host header + var invalid = "\"#%*+/;<=>?@[]\\^`{}|"; + foreach (var ch in invalid) + { + data.Add(ch.ToString()); + } + + invalid = "!\"#$%&'()*+,/;<=>?@[]\\^_`{}|~-"; + foreach (var ch in invalid) + { + data.Add("[abd" + ch + "]:1234"); + } + + invalid = "!\"#$%&'()*+/;<=>?@[]\\^_`{}|~:abcABC-."; + foreach (var ch in invalid) + { + data.Add("a.b.c:" + ch); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(HostHeaderInvalidData))] + public async Task XForwardedHostFailsForInvalidCharacters(string host) + { + var assertsExecuted = false; + + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedHost + }); + app.Run(context => + { + Assert.NotEqual(host, context.Request.Host.Value); + assertsExecuted = true; + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + await server.SendAsync(c => + { + c.Request.Headers["X-Forwarded-Host"] = host; + }); + Assert.True(assertsExecuted); + } + [Theory] [InlineData(0, "h1", "http")] [InlineData(1, "", "http")] @@ -281,6 +421,100 @@ namespace Microsoft.AspNetCore.HttpOverrides Assert.Equal(expected, context.Request.Scheme); } + public static TheoryData ProtoHeaderData + { + get + { + // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + return new TheoryData() { + "z", + "Z", + "1", + "y+", + "1-", + "a.", + }; + } + } + + [Theory] + [MemberData(nameof(ProtoHeaderData))] + public async Task XForwardedProtoAcceptsValidProtocols(string scheme) + { + var assertsExecuted = false; + + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedProto + }); + app.Run(context => + { + Assert.Equal(scheme, context.Request.Scheme); + assertsExecuted = true; + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + await server.SendAsync(c => + { + c.Request.Headers["X-Forwarded-Proto"] = scheme; + }); + Assert.True(assertsExecuted); + } + + public static TheoryData ProtoHeaderInvalidData + { + get + { + // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) + var data = new TheoryData() { + "a b", // Space + }; + + // These aren't allowed anywhere in the scheme header + var invalid = "!\"#$%&'()*/:;<=>?@[]\\^_`{}|~"; + foreach (var ch in invalid) + { + data.Add(ch.ToString()); + } + + return data; + } + } + + [Theory] + [MemberData(nameof(ProtoHeaderInvalidData))] + public async Task XForwardedProtoRejectsInvalidProtocols(string scheme) + { + var assertsExecuted = false; + + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedProto, + }); + app.Run(context => + { + Assert.Equal("http", context.Request.Scheme); + assertsExecuted = true; + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + + await server.SendAsync(c => + { + c.Request.Headers["X-Forwarded-Proto"] = scheme; + }); + Assert.True(assertsExecuted); + } + [Theory] [InlineData(0, "h1", "::1", "http")] [InlineData(1, "", "::1", "http")]