diff --git a/BasicMiddleware.sln b/BasicMiddleware.sln index a4452cd103..8810201f5a 100644 --- a/BasicMiddleware.sln +++ b/BasicMiddleware.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26817.0 +VisualStudioVersion = 15.0.27130.2027 MinimumVisualStudioVersion = 15.0.26730.03 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HttpOverrides", "src\Microsoft.AspNetCore.HttpOverrides\Microsoft.AspNetCore.HttpOverrides.csproj", "{517308C3-B477-4B01-B461-CAB9C10B6928}" EndProject @@ -63,6 +63,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpsPolicySample", "sample EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HttpsPolicy.Tests", "test\Microsoft.AspNetCore.HttpsPolicy.Tests\Microsoft.AspNetCore.HttpsPolicy.Tests.csproj", "{1C67B0F1-6E70-449E-A2F1-98B9D5C576CE}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostFilteringSample", "samples\HostFilteringSample\HostFilteringSample.csproj", "{368B00A2-992A-4B0E-9085-A8136A22922D}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{5CEA6F31-A829-4A02-8CD5-EC3DDD4CC1EA}" + ProjectSection(SolutionItems) = preProject + build\dependencies.props = build\dependencies.props + build\repo.props = build\repo.props + build\sources.props = build\sources.props + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HostFiltering.Tests", "test\Microsoft.AspNetCore.HostFiltering.Tests\Microsoft.AspNetCore.HostFiltering.Tests.csproj", "{4BC947ED-13B8-4BE6-82A4-96A48D86980B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HostFiltering", "src\Microsoft.AspNetCore.HostFiltering\Microsoft.AspNetCore.HostFiltering.csproj", "{762F7276-C916-4111-A6C0-41668ABB3823}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +142,18 @@ Global {1C67B0F1-6E70-449E-A2F1-98B9D5C576CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C67B0F1-6E70-449E-A2F1-98B9D5C576CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C67B0F1-6E70-449E-A2F1-98B9D5C576CE}.Release|Any CPU.Build.0 = Release|Any CPU + {368B00A2-992A-4B0E-9085-A8136A22922D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {368B00A2-992A-4B0E-9085-A8136A22922D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {368B00A2-992A-4B0E-9085-A8136A22922D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {368B00A2-992A-4B0E-9085-A8136A22922D}.Release|Any CPU.Build.0 = Release|Any CPU + {4BC947ED-13B8-4BE6-82A4-96A48D86980B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BC947ED-13B8-4BE6-82A4-96A48D86980B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BC947ED-13B8-4BE6-82A4-96A48D86980B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BC947ED-13B8-4BE6-82A4-96A48D86980B}.Release|Any CPU.Build.0 = Release|Any CPU + {762F7276-C916-4111-A6C0-41668ABB3823}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {762F7276-C916-4111-A6C0-41668ABB3823}.Debug|Any CPU.Build.0 = Debug|Any CPU + {762F7276-C916-4111-A6C0-41668ABB3823}.Release|Any CPU.ActiveCfg = Release|Any CPU + {762F7276-C916-4111-A6C0-41668ABB3823}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -149,6 +174,10 @@ Global {4D39C29B-4EC8-497C-B411-922DA494D71B} = {A5076D28-FA7E-4606-9410-FEDD0D603527} {AC424AEE-4883-49C6-945F-2FC916B8CA1C} = {9587FE9F-5A17-42C4-8021-E87F59CECB98} {1C67B0F1-6E70-449E-A2F1-98B9D5C576CE} = {8437B0F3-3894-4828-A945-A9187F37631D} + {368B00A2-992A-4B0E-9085-A8136A22922D} = {9587FE9F-5A17-42C4-8021-E87F59CECB98} + {5CEA6F31-A829-4A02-8CD5-EC3DDD4CC1EA} = {59A9B64C-E9BE-409E-89A2-58D72E2918F5} + {4BC947ED-13B8-4BE6-82A4-96A48D86980B} = {8437B0F3-3894-4828-A945-A9187F37631D} + {762F7276-C916-4111-A6C0-41668ABB3823} = {A5076D28-FA7E-4606-9410-FEDD0D603527} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4518E9CE-3680-4E05-9259-B64EA7807158} diff --git a/build/dependencies.props b/build/dependencies.props index 97d1862813..4dadaa5ffa 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -14,6 +14,7 @@ 2.1.0-preview2-30281 2.1.0-preview2-30281 2.1.0-preview2-30281 + 2.1.0-preview2-30281 2.1.0-preview2-30281 2.1.0-preview2-30281 2.1.0-preview2-30281 diff --git a/samples/HostFilteringSample/HostFilteringSample.csproj b/samples/HostFilteringSample/HostFilteringSample.csproj new file mode 100644 index 0000000000..7818be1fb0 --- /dev/null +++ b/samples/HostFilteringSample/HostFilteringSample.csproj @@ -0,0 +1,29 @@ + + + + netcoreapp2.1;net461 + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/samples/HostFilteringSample/Program.cs b/samples/HostFilteringSample/Program.cs new file mode 100644 index 0000000000..0d4ffa9324 --- /dev/null +++ b/samples/HostFilteringSample/Program.cs @@ -0,0 +1,37 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace HostFilteringSample +{ + public class Program + { + public static void Main(string[] args) + { + BuildWebHost(args).Run(); + } + + public static IWebHost BuildWebHost(string[] args) + { + var hostBuilder = new WebHostBuilder() + .ConfigureLogging((_, factory) => + { + factory.SetMinimumLevel(LogLevel.Debug); + factory.AddConsole(); + }) + .ConfigureAppConfiguration((hostingContext, config) => + { + var env = hostingContext.HostingEnvironment; + config.AddJsonFile("appsettings.json", optional: true) + .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true); + }) + .UseKestrel() + .UseStartup(); + + return hostBuilder.Build(); + } + } +} diff --git a/samples/HostFilteringSample/Properties/launchSettings.json b/samples/HostFilteringSample/Properties/launchSettings.json new file mode 100644 index 0000000000..a0ee1d227c --- /dev/null +++ b/samples/HostFilteringSample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:14124/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "HostFilteringSample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:14125/" + } + } +} \ No newline at end of file diff --git a/samples/HostFilteringSample/Startup.cs b/samples/HostFilteringSample/Startup.cs new file mode 100644 index 0000000000..93c217b71c --- /dev/null +++ b/samples/HostFilteringSample/Startup.cs @@ -0,0 +1,41 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace HostFilteringSample +{ + public class Startup + { + public IConfiguration Config { get; } + + public Startup(IConfiguration config) + { + Config = config; + } + + public void ConfigureServices(IServiceCollection services) + { + services.AddHostFiltering(options => + { + // If this is excluded then it will fall back to the server's addresses + options.AllowedHosts = Config.GetSection("AllowedHosts").Get>(); + }); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment env) + { + app.UseHostFiltering(); + + app.Run(context => + { + return context.Response.WriteAsync("Hello World! " + context.Request.Host); + }); + } + } +} diff --git a/samples/HostFilteringSample/appsettings.Development.json b/samples/HostFilteringSample/appsettings.Development.json new file mode 100644 index 0000000000..7c2d8e26dc --- /dev/null +++ b/samples/HostFilteringSample/appsettings.Development.json @@ -0,0 +1,3 @@ +{ + "AllowedHosts": [ "localhost", "127.0.0.1", "[::1]" ] +} diff --git a/samples/HostFilteringSample/appsettings.Production.json b/samples/HostFilteringSample/appsettings.Production.json new file mode 100644 index 0000000000..f2fc90c390 --- /dev/null +++ b/samples/HostFilteringSample/appsettings.Production.json @@ -0,0 +1,3 @@ +{ + "AllowedHosts": [ "example.com", "localhost" ] +} diff --git a/samples/HostFilteringSample/appsettings.json b/samples/HostFilteringSample/appsettings.json new file mode 100644 index 0000000000..29091fa582 --- /dev/null +++ b/samples/HostFilteringSample/appsettings.json @@ -0,0 +1,3 @@ +{ + +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.HostFiltering/HostFilteringBuilderExtensions.cs b/src/Microsoft.AspNetCore.HostFiltering/HostFilteringBuilderExtensions.cs new file mode 100644 index 0000000000..b8722de1c7 --- /dev/null +++ b/src/Microsoft.AspNetCore.HostFiltering/HostFilteringBuilderExtensions.cs @@ -0,0 +1,32 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.HostFiltering; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for the HostFiltering middleware. + /// + public static class HostFilteringBuilderExtensions + { + /// + /// Adds middleware for filtering requests by allowed host headers. Invalid requests will be rejected with a + /// 400 status code. + /// + /// The instance this method extends. + /// The original . + public static IApplicationBuilder UseHostFiltering(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + app.UseMiddleware(); + + return app; + } + } +} diff --git a/src/Microsoft.AspNetCore.HostFiltering/HostFilteringMiddleware.cs b/src/Microsoft.AspNetCore.HostFiltering/HostFilteringMiddleware.cs new file mode 100644 index 0000000000..e1e38eca37 --- /dev/null +++ b/src/Microsoft.AspNetCore.HostFiltering/HostFilteringMiddleware.cs @@ -0,0 +1,166 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// 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.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.HostFiltering +{ + /// + /// A middleware used to filter requests by their Host header. + /// + public class HostFilteringMiddleware + { + // Matches Http.Sys. + private static readonly byte[] DefaultResponse = Encoding.ASCII.GetBytes( + "\r\n" + + "Bad Request\r\n" + + "\r\n" + + "

Bad Request - Invalid Hostname

\r\n" + + "

HTTP Error 400. The request hostname is invalid.

\r\n" + + ""); + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly HostFilteringOptions _options; + private IList _allowedHosts; + private bool? _allowAnyNonEmptyHost; + + /// + /// A middleware used to filter requests by their Host header. + /// + /// + /// + /// + public HostFilteringMiddleware(RequestDelegate next, ILogger logger, + IOptions options) + { + _next = next ?? throw new ArgumentNullException(nameof(next)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Processes requests + /// + /// + /// + public Task Invoke(HttpContext context) + { + EnsureConfigured(); + + if (!CheckHost(context)) + { + context.Response.StatusCode = 400; + if (_options.IncludeFailureMessage) + { + context.Response.ContentLength = DefaultResponse.Length; + context.Response.ContentType = "text/html"; + return context.Response.Body.WriteAsync(DefaultResponse, 0, DefaultResponse.Length); + } + return Task.CompletedTask; + } + + return _next(context); + } + + private void EnsureConfigured() + { + if (_allowAnyNonEmptyHost == true || _allowedHosts?.Count > 0) + { + return; + } + + var allowedHosts = new List(); + if (_options.AllowedHosts?.Count > 0 && !TryProcessHosts(_options.AllowedHosts, allowedHosts)) + { + _logger.LogDebug("Wildcard detected, all requests with hosts will be allowed."); + _allowAnyNonEmptyHost = true; + return; + } + + if (allowedHosts.Count == 0) + { + throw new InvalidOperationException("No allowed hosts were configured."); + } + + _logger.LogDebug("Allowed hosts: " + string.Join("; ", allowedHosts)); + _allowedHosts = allowedHosts; + } + + // returns false if any wildcards were found + private bool TryProcessHosts(IEnumerable incoming, IList results) + { + foreach (var entry in incoming) + { + // Punycode. Http.Sys requires you to register Unicode hosts, but the headers contain punycode. + var host = new HostString(entry).ToUriComponent(); + + if (IsTopLevelWildcard(host)) + { + // Disable filtering + return false; + } + + if (!results.Contains(host, StringSegmentComparer.OrdinalIgnoreCase)) + { + results.Add(host); + } + } + + return true; + } + + private bool IsTopLevelWildcard(string host) + { + return (string.Equals("*", host, StringComparison.Ordinal) // HttpSys wildcard + || string.Equals("[::]", host, StringComparison.Ordinal) // Kestrel wildcard, IPv6 Any + || string.Equals("0.0.0.0", host, StringComparison.Ordinal)); // IPv4 Any + } + + // This does not duplicate format validations that are expected to be performed by the host. + private bool CheckHost(HttpContext context) + { + var host = new StringSegment(context.Request.Headers[HeaderNames.Host].ToString()).Trim(); + + if (StringSegment.IsNullOrEmpty(host)) + { + // Http/1.0 does not require the host header. + // Http/1.1 requires the header but the value may be empty. + if (!_options.AllowEmptyHosts) + { + _logger.LogInformation($"{context.Request.Protocol} request rejected due to missing or empty host header."); + return false; + } + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug($"{context.Request.Protocol} request allowed with missing or empty host header."); + } + return true; + } + + if (_allowAnyNonEmptyHost == true) + { + _logger.LogTrace($"All hosts are allowed."); + return true; + } + + if (HostString.MatchesAny(host, _allowedHosts)) + { + _logger.LogTrace($"The host '{host}' matches an allowed host."); + return true; + } + + _logger.LogInformation($"The host '{host}' does not match an allowed host."); + return false; + } + } +} diff --git a/src/Microsoft.AspNetCore.HostFiltering/HostFilteringOptions.cs b/src/Microsoft.AspNetCore.HostFiltering/HostFilteringOptions.cs new file mode 100644 index 0000000000..1645591579 --- /dev/null +++ b/src/Microsoft.AspNetCore.HostFiltering/HostFilteringOptions.cs @@ -0,0 +1,44 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.HostFiltering +{ + /// + /// Options for the HostFiltering middleware + /// + public class HostFilteringOptions + { + /// + /// The hosts headers that are allowed to access this site. At least one value is required. + /// + /// + /// + /// Port numbers must be excluded. + /// A top level wildcard "*" allows all non-empty hosts. + /// Subdomain wildcards are permitted. E.g. "*.example.com" matches subdomains like foo.example.com, + /// but not the parent domain example.com. + /// Unicode host names are allowed but will be converted to punycode for matching. + /// IPv6 addresses must include their bounding brackets and be in their normalized form. + /// + /// + public IList AllowedHosts { get; set; } = new List(); + + /// + /// Indicates if requests without hosts are allowed. The default is true. + /// + /// + /// HTTP/1.0 does not require a host header. + /// Http/1.1 requires a host header, but says the value may be empty. + /// + public bool AllowEmptyHosts { get; set; } = true; + + // Note if this were disabled then things like the status code middleware may try to re-execute + // the request. This is a low level protocol violation, pretty error pages should not be required. + /// + /// Indicates if the 400 response should include a default message or be empty. This is enabled by default. + /// + public bool IncludeFailureMessage { get; set; } = true; + } +} diff --git a/src/Microsoft.AspNetCore.HostFiltering/HostFilteringServicesExtensions.cs b/src/Microsoft.AspNetCore.HostFiltering/HostFilteringServicesExtensions.cs new file mode 100644 index 0000000000..81b48444a8 --- /dev/null +++ b/src/Microsoft.AspNetCore.HostFiltering/HostFilteringServicesExtensions.cs @@ -0,0 +1,36 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.HostFiltering; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for the host filtering middleware. + /// + public static class HostFilteringServicesExtensions + { + /// + /// Adds services and options for the host filtering middleware. + /// + /// The for adding services. + /// A delegate to configure the . + /// + public static IServiceCollection AddHostFiltering(this IServiceCollection services, Action configureOptions) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + if (configureOptions == null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + + services.Configure(configureOptions); + return services; + } + } +} diff --git a/src/Microsoft.AspNetCore.HostFiltering/Microsoft.AspNetCore.HostFiltering.csproj b/src/Microsoft.AspNetCore.HostFiltering/Microsoft.AspNetCore.HostFiltering.csproj new file mode 100644 index 0000000000..ec6d642340 --- /dev/null +++ b/src/Microsoft.AspNetCore.HostFiltering/Microsoft.AspNetCore.HostFiltering.csproj @@ -0,0 +1,18 @@ + + + + + ASP.NET Core middleware for filtering out requests with unknown HTTP host headers. + + netstandard2.0 + true + aspnetcore + + + + + + + + + diff --git a/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersMiddleware.cs b/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersMiddleware.cs index d5e074de57..decda81d58 100644 --- a/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersMiddleware.cs +++ b/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersMiddleware.cs @@ -2,6 +2,7 @@ // 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; @@ -11,6 +12,7 @@ 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 { @@ -22,6 +24,8 @@ namespace Microsoft.AspNetCore.HttpOverrides private readonly ForwardedHeadersOptions _options; private readonly RequestDelegate _next; private readonly ILogger _logger; + private bool _allowAllHosts; + private IList _allowedHosts; static ForwardedHeadersMiddleware() { @@ -85,6 +89,8 @@ namespace Microsoft.AspNetCore.HttpOverrides _options = options.Value; _logger = loggerFactory.CreateLogger(); _next = next; + + PreProcessHosts(); } private static void EnsureOptionNotNullorWhitespace(string value, string propertyName) @@ -95,6 +101,43 @@ namespace Microsoft.AspNetCore.HttpOverrides } } + private void PreProcessHosts() + { + if (_options.AllowedHosts == null || _options.AllowedHosts.Count == 0) + { + _allowAllHosts = true; + return; + } + + var allowedHosts = new List(); + foreach (var entry in _options.AllowedHosts) + { + // Punycode. Http.Sys requires you to register Unicode hosts, but the headers contain punycode. + var host = new HostString(entry).ToUriComponent(); + + if (IsTopLevelWildcard(host)) + { + // Disable filtering + _allowAllHosts = true; + return; + } + + if (!allowedHosts.Contains(host, StringSegmentComparer.OrdinalIgnoreCase)) + { + allowedHosts.Add(host); + } + } + + _allowedHosts = allowedHosts; + } + + private bool IsTopLevelWildcard(string host) + { + return (string.Equals("*", host, StringComparison.Ordinal) // HttpSys wildcard + || string.Equals("[::]", host, StringComparison.Ordinal) // Kestrel wildcard, IPv6 Any + || string.Equals("0.0.0.0", host, StringComparison.Ordinal)); // IPv4 Any + } + public Task Invoke(HttpContext context) { ApplyForwarders(context); @@ -231,7 +274,8 @@ namespace Microsoft.AspNetCore.HttpOverrides if (checkHost) { - if (!string.IsNullOrEmpty(set.Host) && TryValidateHost(set.Host)) + if (!string.IsNullOrEmpty(set.Host) && TryValidateHost(set.Host) + && (_allowAllHosts || HostString.MatchesAny(set.Host, _allowedHosts))) { applyChanges = true; currentValues.Host = set.Host; diff --git a/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersOptions.cs b/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersOptions.cs index cbe00baaa1..3507d9bea5 100644 --- a/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersOptions.cs +++ b/src/Microsoft.AspNetCore.HttpOverrides/ForwardedHeadersOptions.cs @@ -67,6 +67,22 @@ namespace Microsoft.AspNetCore.Builder /// public IList KnownNetworks { get; } = new List() { new IPNetwork(IPAddress.Loopback, 8) }; + /// + /// The allowed values from x-forwarded-host. If the list is empty then all hosts are allowed. + /// Failing to restrict this these values may allow an attacker to spoof links generated by your service. + /// + /// + /// + /// Port numbers must be excluded. + /// A top level wildcard "*" allows all non-empty hosts. + /// Subdomain wildcards are permitted. E.g. "*.example.com" matches subdomains like foo.example.com, + /// but not the parent domain example.com. + /// Unicode host names are allowed but will be converted to punycode for matching. + /// IPv6 addresses must include their bounding brackets and be in their normalized form. + /// + /// + public IList AllowedHosts { get; set; } = new List(); + /// /// Require the number of header values to be in sync between the different headers being processed. /// The default is 'false'. diff --git a/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionBuilderExtensions.cs b/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionBuilderExtensions.cs index 429f0f0ee2..c6f804763f 100644 --- a/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionBuilderExtensions.cs @@ -2,11 +2,9 @@ // 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 Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.HttpsPolicy; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Builder { @@ -20,15 +18,13 @@ namespace Microsoft.AspNetCore.Builder /// /// The instance this method extends. /// The for HttpsRedirection. - /// - /// HTTPS Enforcement interanlly uses the UrlRewrite middleware to redirect HTTP requests to HTTPS. - /// public static IApplicationBuilder UseHttpsRedirection(this IApplicationBuilder app) { if (app == null) { throw new ArgumentNullException(nameof(app)); } + var serverAddressFeature = app.ServerFeatures.Get(); if (serverAddressFeature != null) { diff --git a/test/Microsoft.AspNetCore.HostFiltering.Tests/HostFilteringMiddlewareTests.cs b/test/Microsoft.AspNetCore.HostFiltering.Tests/HostFilteringMiddlewareTests.cs new file mode 100644 index 0000000000..2fc7b8c713 --- /dev/null +++ b/test/Microsoft.AspNetCore.HostFiltering.Tests/HostFilteringMiddlewareTests.cs @@ -0,0 +1,183 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.HostFiltering +{ + public class HostFilteringMiddlewareTests + { + [Fact] + public async Task MissingConfigThrows() + { + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseHostFiltering(); + }); + await Assert.ThrowsAsync(() => new TestServer(builder).SendAsync(_ => { })); + } + + [Theory] + [InlineData(true, 200)] + [InlineData(false, 400)] + public async Task AllowsMissingHost(bool allowed, int status) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddHostFiltering(options => + { + options.AllowEmptyHosts = allowed; + options.AllowedHosts.Add("Localhost"); + }); + }) + .Configure(app => + { + app.Use((ctx, next) => + { + ctx.Request.Headers.Remove(HeaderNames.Host); + return next(); + }); + app.UseHostFiltering(); + app.Run(c => + { + Assert.False(c.Request.Headers.TryGetValue(HeaderNames.Host, out var host)); + return Task.CompletedTask; + }); + }); + var server = new TestServer(builder); + var response = await server.CreateClient().GetAsync("/"); + Assert.Equal(status, (int)response.StatusCode); + } + + [Theory] + [InlineData(true, 200)] + [InlineData(false, 400)] + public async Task AllowsEmptyHost(bool allowed, int status) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddHostFiltering(options => + { + options.AllowEmptyHosts = allowed; + options.AllowedHosts.Add("Localhost"); + }); + }) + .Configure(app => + { + app.Use((ctx, next) => + { + ctx.Request.Headers[HeaderNames.Host] = " "; + return next(); + }); + app.UseHostFiltering(); + app.Run(c => + { + Assert.True(c.Request.Headers.TryGetValue(HeaderNames.Host, out var host)); + Assert.True(StringValues.Equals(" ", host)); + return Task.CompletedTask; + }); + app.Run(c => Task.CompletedTask); + }); + var server = new TestServer(builder); + var response = await server.CreateClient().GetAsync("/"); + Assert.Equal(status, (int)response.StatusCode); + } + + [Theory] + [InlineData("localHost", "localhost")] + [InlineData("localHost", "*")] // Any - Used by HttpSys + [InlineData("localHost", "[::]")] // IPv6 Any - This is what Kestrel reports when binding to * + [InlineData("localHost", "0.0.0.0")] // IPv4 Any + [InlineData("localhost:9090", "example.com;localHost")] + [InlineData("example.com:443", "example.com;localhost")] + [InlineData("localHost:80", "localhost;")] + [InlineData("foo.eXample.com:443", "*.exampLe.com")] + [InlineData("f.eXample.com:443", "*.exampLe.com")] + [InlineData("127.0.0.1", "127.0.0.1")] + [InlineData("127.0.0.1:443", "127.0.0.1")] + [InlineData("xn--c1yn36f:443", "xn--c1yn36f")] + [InlineData("xn--c1yn36f:443", "點看")] + [InlineData("[::ABC]", "[::aBc]")] + [InlineData("[::1]:80", "[::1]")] + public async Task AllowsSpecifiedHost(string host, string allowedHost) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddHostFiltering(options => + { + options.AllowedHosts = allowedHost.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + }); + }) + .Configure(app => + { + app.Use((ctx, next) => + { + // TestHost's ClientHandler doesn't let you set the host header, only the host in the URI + // and that would over-normalize some of our test conditions like casing. + ctx.Request.Headers[HeaderNames.Host] = host; + return next(); + }); + app.UseHostFiltering(); + app.Run(c => Task.CompletedTask); + }); + var server = new TestServer(builder); + var response = await server.CreateRequest("/").GetAsync(); + Assert.Equal(200, (int)response.StatusCode); + } + + [Theory] + [InlineData("example.com", "localhost")] + [InlineData("localhost:9090", "example.com;")] + [InlineData(";", "example.com;localhost")] + [InlineData(";:80", "example.com;localhost")] + [InlineData(":80", "localhost")] + [InlineData(":", "localhost")] + [InlineData("example.com:443", "*.example.com")] + [InlineData(".example.com:443", "*.example.com")] + [InlineData("foo.com:443", "*.example.com")] + [InlineData("foo.example.com.bar:443", "*.example.com")] + [InlineData(".com:443", "*.com")] + // Unicode in the host shouldn't be allowed without punycode anyways. This match fails because the middleware converts + // its input to punycode. + [InlineData("點看", "點看")] + [InlineData("[::1", "[::1]")] + [InlineData("[::1:80", "[::1]")] + public async Task RejectsMismatchedHosts(string host, string allowedHost) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddHostFiltering(options => + { + options.AllowedHosts = allowedHost.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries); + }); + }) + .Configure(app => + { + app.Use((ctx, next) => + { + // TestHost's ClientHandler doesn't let you set the host header, only the host in the URI + // and that would reject some of our test conditions. + ctx.Request.Headers[HeaderNames.Host] = host; + return next(); + }); + app.UseHostFiltering(); + app.Run(c => throw new NotImplementedException("App")); + }); + var server = new TestServer(builder); + var response = await server.CreateRequest("/").GetAsync(); + Assert.Equal(400, (int)response.StatusCode); + } + } +} diff --git a/test/Microsoft.AspNetCore.HostFiltering.Tests/Microsoft.AspNetCore.HostFiltering.Tests.csproj b/test/Microsoft.AspNetCore.HostFiltering.Tests/Microsoft.AspNetCore.HostFiltering.Tests.csproj new file mode 100644 index 0000000000..d71805c949 --- /dev/null +++ b/test/Microsoft.AspNetCore.HostFiltering.Tests/Microsoft.AspNetCore.HostFiltering.Tests.csproj @@ -0,0 +1,11 @@ + + + + $(StandardTestTfms) + + + + + + + diff --git a/test/Microsoft.AspNetCore.HttpOverrides.Tests/ForwardedHeadersMiddlewareTest.cs b/test/Microsoft.AspNetCore.HttpOverrides.Tests/ForwardedHeadersMiddlewareTest.cs index 81d6ccf15a..f88243c9e7 100644 --- a/test/Microsoft.AspNetCore.HttpOverrides.Tests/ForwardedHeadersMiddlewareTest.cs +++ b/test/Microsoft.AspNetCore.HttpOverrides.Tests/ForwardedHeadersMiddlewareTest.cs @@ -1,12 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Linq; using System.Net; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; +using Microsoft.Net.Http.Headers; using Xunit; namespace Microsoft.AspNetCore.HttpOverrides @@ -392,6 +394,119 @@ namespace Microsoft.AspNetCore.HttpOverrides Assert.True(assertsExecuted); } + [Theory] + [InlineData("localHost", "localhost")] + [InlineData("localHost", "*")] // Any - Used by HttpSys + [InlineData("localHost", "[::]")] // IPv6 Any - This is what Kestrel reports when binding to * + [InlineData("localHost", "0.0.0.0")] // IPv4 Any + [InlineData("localhost:9090", "example.com;localHost")] + [InlineData("example.com:443", "example.com;localhost")] + [InlineData("localHost:80", "localhost;")] + [InlineData("foo.eXample.com:443", "*.exampLe.com")] + [InlineData("f.eXample.com:443", "*.exampLe.com")] + [InlineData("127.0.0.1", "127.0.0.1")] + [InlineData("127.0.0.1:443", "127.0.0.1")] + [InlineData("xn--c1yn36f:443", "xn--c1yn36f")] + [InlineData("xn--c1yn36f:443", "點看")] + [InlineData("[::ABC]", "[::aBc]")] + [InlineData("[::1]:80", "[::1]")] + public async Task XForwardedHostAllowsSpecifiedHost(string host, string allowedHost) + { + bool assertsExecuted = false; + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedHost, + AllowedHosts = allowedHost.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries) + }); + app.Run(context => + { + Assert.Equal(host, context.Request.Headers[HeaderNames.Host]); + assertsExecuted = true; + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + var response = await server.SendAsync(ctx => + { + ctx.Request.Headers["X-forwarded-Host"] = host; + }); + Assert.True(assertsExecuted); + } + + [Theory] + [InlineData("example.com", "localhost")] + [InlineData("localhost:9090", "example.com;")] + [InlineData(";", "example.com;localhost")] + [InlineData(";:80", "example.com;localhost")] + [InlineData(":80", "localhost")] + [InlineData(":", "localhost")] + [InlineData("example.com:443", "*.example.com")] + [InlineData(".example.com:443", "*.example.com")] + [InlineData("foo.com:443", "*.example.com")] + [InlineData("foo.example.com.bar:443", "*.example.com")] + [InlineData(".com:443", "*.com")] + // Unicode in the host shouldn't be allowed without punycode anyways. This match fails because the middleware converts + // its input to punycode. + [InlineData("點看", "點看")] + [InlineData("[::1", "[::1]")] + [InlineData("[::1:80", "[::1]")] + public async Task XForwardedHostFailsMismatchedHosts(string host, string allowedHost) + { + bool assertsExecuted = false; + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedHost, + AllowedHosts = new[] { allowedHost } + }); + app.Run(context => + { + Assert.NotEqual(host, context.Request.Headers[HeaderNames.Host]); + assertsExecuted = true; + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + var response = await server.SendAsync(ctx => + { + ctx.Request.Headers["X-forwarded-Host"] = host; + }); + Assert.True(assertsExecuted); + } + + [Fact] + public async Task XForwardedHostStopsAtFirstUnspecifiedHost() + { + bool assertsExecuted = false; + var builder = new WebHostBuilder() + .Configure(app => + { + app.UseForwardedHeaders(new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedHost, + ForwardLimit = 10, + AllowedHosts = new[] { "bar.com", "*.foo.com" } + }); + app.Run(context => + { + Assert.Equal("bar.foo.com:432", context.Request.Headers[HeaderNames.Host]); + assertsExecuted = true; + return Task.FromResult(0); + }); + }); + var server = new TestServer(builder); + var response = await server.SendAsync(ctx => + { + ctx.Request.Headers["X-forwarded-Host"] = "stuff:523, bar.foo.com:432, bar.com:80"; + }); + Assert.True(assertsExecuted); + } + [Theory] [InlineData(0, "h1", "http")] [InlineData(1, "", "http")] diff --git a/test/Microsoft.AspNetCore.HttpsPolicy.Tests/HttpsRedirectionMiddlewareTests.cs b/test/Microsoft.AspNetCore.HttpsPolicy.Tests/HttpsRedirectionMiddlewareTests.cs index 9d482151ca..b55c195272 100644 --- a/test/Microsoft.AspNetCore.HttpsPolicy.Tests/HttpsRedirectionMiddlewareTests.cs +++ b/test/Microsoft.AspNetCore.HttpsPolicy.Tests/HttpsRedirectionMiddlewareTests.cs @@ -22,9 +22,6 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests public async Task SetOptions_DefaultsSetCorrectly() { var builder = new WebHostBuilder() - .ConfigureServices(services => - { - }) .Configure(app => { app.UseHttpsRedirection(); @@ -34,9 +31,7 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests }); }); - var featureCollection = new FeatureCollection(); - featureCollection.Set(new ServerAddressesFeature()); - var server = new TestServer(builder, featureCollection); + var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); @@ -75,9 +70,7 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests }); }); - var featureCollection = new FeatureCollection(); - featureCollection.Set(new ServerAddressesFeature()); - var server = new TestServer(builder, featureCollection); + var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); @@ -115,9 +108,7 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests }); }); - var featureCollection = new FeatureCollection(); - featureCollection.Set(new ServerAddressesFeature()); - var server = new TestServer(builder, featureCollection); + var server = new TestServer(builder); var client = server.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, ""); @@ -182,12 +173,6 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests public async Task SetServerAddressesFeature_SingleHttpsAddress_Success() { var builder = new WebHostBuilder() - .ConfigureServices(services => - { - services.AddHttpsRedirection(options => - { - }); - }) .Configure(app => { app.UseHttpsRedirection(); @@ -215,12 +200,6 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests public async Task SetServerAddressesFeature_MultipleHttpsAddresses_ThrowInMiddleware() { var builder = new WebHostBuilder() - .ConfigureServices(services => - { - services.AddHttpsRedirection(options => - { - }); - }) .Configure(app => { app.UseHttpsRedirection(); @@ -248,12 +227,6 @@ namespace Microsoft.AspNetCore.HttpsPolicy.Tests public async Task SetServerAddressesFeature_MultipleHttpsAddressesWithSamePort_Success() { var builder = new WebHostBuilder() - .ConfigureServices(services => - { - services.AddHttpsRedirection(options => - { - }); - }) .Configure(app => { app.UseHttpsRedirection();