From 758f9fcea590dbaf7294c940b2f7f32f01927379 Mon Sep 17 00:00:00 2001 From: Justin Kotalik Date: Fri, 13 Oct 2017 12:15:35 -0700 Subject: [PATCH] Adds Https Redirection and Hsts Middlewares (#264) --- BasicMiddleware.sln | 21 +++ .../HttpsPolicySample.csproj | 17 +++ .../Properties/launchSettings.json | 27 ++++ samples/HttpsPolicySample/Startup.cs | 71 ++++++++++ samples/HttpsPolicySample/testCert.pfx | Bin 0 -> 2483 bytes .../HstsBuilderExtensions.cs | 30 ++++ .../HstsMiddleware.cs | 68 ++++++++++ .../HstsOptions.cs | 39 ++++++ .../HstsServicesExtensions.cs | 36 +++++ .../HttpsRedirectionBuilderExtensions.cs | 44 ++++++ .../HttpsRedirectionOptions.cs | 26 ++++ .../HttpsRedirectionServicesExtensions.cs | 36 +++++ .../Microsoft.AspNetCore.HttpsPolicy.csproj | 16 +++ .../HstsMiddlewareTests.cs | 128 ++++++++++++++++++ .../HttpsPolicyTests.cs | 76 +++++++++++ .../HttpsRedirectionMiddlewareTests.cs | 125 +++++++++++++++++ ...rosoft.AspNetCore.HttpsPolicy.Tests.csproj | 10 ++ 17 files changed, 770 insertions(+) create mode 100644 samples/HttpsPolicySample/HttpsPolicySample.csproj create mode 100644 samples/HttpsPolicySample/Properties/launchSettings.json create mode 100644 samples/HttpsPolicySample/Startup.cs create mode 100644 samples/HttpsPolicySample/testCert.pfx create mode 100644 src/Microsoft.AspNetCore.HttpsPolicy/HstsBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.HttpsPolicy/HstsMiddleware.cs create mode 100644 src/Microsoft.AspNetCore.HttpsPolicy/HstsOptions.cs create mode 100644 src/Microsoft.AspNetCore.HttpsPolicy/HstsServicesExtensions.cs create mode 100644 src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionOptions.cs create mode 100644 src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionServicesExtensions.cs create mode 100644 src/Microsoft.AspNetCore.HttpsPolicy/Microsoft.AspNetCore.HttpsPolicy.csproj create mode 100644 test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HstsMiddlewareTests.cs create mode 100644 test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HttpsPolicyTests.cs create mode 100644 test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HttpsRedirectionMiddlewareTests.cs create mode 100644 test/Microsoft.AspNetCore.HttpsEnforcement.Tests/Microsoft.AspNetCore.HttpsPolicy.Tests.csproj diff --git a/BasicMiddleware.sln b/BasicMiddleware.sln index d915ed2016..c123191126 100644 --- a/BasicMiddleware.sln +++ b/BasicMiddleware.sln @@ -57,6 +57,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution version.xml = version.xml EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HttpsPolicy", "src\Microsoft.AspNetCore.HttpsPolicy\Microsoft.AspNetCore.HttpsPolicy.csproj", "{4D39C29B-4EC8-497C-B411-922DA494D71B}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HttpsPolicySample", "samples\HttpsPolicySample\HttpsPolicySample.csproj", "{AC424AEE-4883-49C6-945F-2FC916B8CA1C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.HttpsPolicy.Tests", "test\Microsoft.AspNetCore.HttpsEnforcement.Tests\Microsoft.AspNetCore.HttpsPolicy.Tests.csproj", "{1C67B0F1-6E70-449E-A2F1-98B9D5C576CE}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -111,6 +117,18 @@ Global {B2A3CE38-51B2-4486-982C-98C380AF140E}.Debug|Any CPU.Build.0 = Debug|Any CPU {B2A3CE38-51B2-4486-982C-98C380AF140E}.Release|Any CPU.ActiveCfg = Release|Any CPU {B2A3CE38-51B2-4486-982C-98C380AF140E}.Release|Any CPU.Build.0 = Release|Any CPU + {4D39C29B-4EC8-497C-B411-922DA494D71B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D39C29B-4EC8-497C-B411-922DA494D71B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D39C29B-4EC8-497C-B411-922DA494D71B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D39C29B-4EC8-497C-B411-922DA494D71B}.Release|Any CPU.Build.0 = Release|Any CPU + {AC424AEE-4883-49C6-945F-2FC916B8CA1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC424AEE-4883-49C6-945F-2FC916B8CA1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC424AEE-4883-49C6-945F-2FC916B8CA1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC424AEE-4883-49C6-945F-2FC916B8CA1C}.Release|Any CPU.Build.0 = Release|Any CPU + {1C67B0F1-6E70-449E-A2F1-98B9D5C576CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -128,6 +146,9 @@ Global {45308A9D-F4C6-46A8-A24F-E73D995CC223} = {A5076D28-FA7E-4606-9410-FEDD0D603527} {3360A5D1-70C0-49EE-9051-04A6A6B836DC} = {8437B0F3-3894-4828-A945-A9187F37631D} {B2A3CE38-51B2-4486-982C-98C380AF140E} = {9587FE9F-5A17-42C4-8021-E87F59CECB98} + {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} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4518E9CE-3680-4E05-9259-B64EA7807158} diff --git a/samples/HttpsPolicySample/HttpsPolicySample.csproj b/samples/HttpsPolicySample/HttpsPolicySample.csproj new file mode 100644 index 0000000000..a9d5f11b71 --- /dev/null +++ b/samples/HttpsPolicySample/HttpsPolicySample.csproj @@ -0,0 +1,17 @@ + + + + net461;netcoreapp2.0 + netcoreapp2.0 + + + + + + + + + + + + diff --git a/samples/HttpsPolicySample/Properties/launchSettings.json b/samples/HttpsPolicySample/Properties/launchSettings.json new file mode 100644 index 0000000000..fbffc1f457 --- /dev/null +++ b/samples/HttpsPolicySample/Properties/launchSettings.json @@ -0,0 +1,27 @@ +{ + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:31894/", + "sslPort": 0 + } + }, + "profiles": { + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "HttpsSample": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:31895/" + } + } +} diff --git a/samples/HttpsPolicySample/Startup.cs b/samples/HttpsPolicySample/Startup.cs new file mode 100644 index 0000000000..4fb7e62871 --- /dev/null +++ b/samples/HttpsPolicySample/Startup.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpsPolicy; +using Microsoft.Extensions.DependencyInjection; + +namespace HttpsSample +{ + public class Startup + { + // This method gets called by the runtime. Use this method to add services to the container. + // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 + public void ConfigureServices(IServiceCollection services) + { + services.AddHttpsRedirection(options => + { + options.RedirectStatusCode = StatusCodes.Status301MovedPermanently; + options.TlsPort = 5001; + }); + + services.AddHsts(options => + { + options.MaxAge = TimeSpan.FromDays(30); + options.Preload = true; + options.IncludeSubDomains = true; + }); + } + + public void Configure(IApplicationBuilder app, IHostingEnvironment environment) + { + if (!environment.IsDevelopment()) + { + app.UseHsts(); + } + app.UseHttpsRedirection(); + + app.Run(async context => + { + await context.Response.WriteAsync("Hello world!"); + }); + } + + // Entry point for the application. + public static void Main(string[] args) + { + var host = new WebHostBuilder() + .UseKestrel( + options => + { + options.Listen(new IPEndPoint(IPAddress.Loopback, 5001), listenOptions => + { + listenOptions.UseHttps("testCert.pfx", "testPassword"); + }); + options.Listen(new IPEndPoint(IPAddress.Loopback, 5000), listenOptions => + { + }); + }) + .UseContentRoot(Directory.GetCurrentDirectory()) // for the cert file + .UseStartup() + .Build(); + + host.Run(); + } + } +} diff --git a/samples/HttpsPolicySample/testCert.pfx b/samples/HttpsPolicySample/testCert.pfx new file mode 100644 index 0000000000000000000000000000000000000000..7118908c2d730670c16e9f8b2c532a262c951989 GIT binary patch literal 2483 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 literal 0 HcmV?d00001 diff --git a/src/Microsoft.AspNetCore.HttpsPolicy/HstsBuilderExtensions.cs b/src/Microsoft.AspNetCore.HttpsPolicy/HstsBuilderExtensions.cs new file mode 100644 index 0000000000..840593c5fb --- /dev/null +++ b/src/Microsoft.AspNetCore.HttpsPolicy/HstsBuilderExtensions.cs @@ -0,0 +1,30 @@ +// 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.Builder; +using Microsoft.AspNetCore.HttpsPolicy; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for the HSTS middleware. + /// + public static class HstsBuilderExtensions + { + /// + /// Adds middleware for using HSTS, which adds the Strict-Transport-Security header. + /// + /// The instance this method extends. + public static IApplicationBuilder UseHsts(this IApplicationBuilder app) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + return app.UseMiddleware(); + } + } +} diff --git a/src/Microsoft.AspNetCore.HttpsPolicy/HstsMiddleware.cs b/src/Microsoft.AspNetCore.HttpsPolicy/HstsMiddleware.cs new file mode 100644 index 0000000000..f67543c880 --- /dev/null +++ b/src/Microsoft.AspNetCore.HttpsPolicy/HstsMiddleware.cs @@ -0,0 +1,68 @@ +// 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.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; + +namespace Microsoft.AspNetCore.HttpsPolicy +{ + /// + /// Enables HTTP Strict Transport Security (HSTS) + /// See https://tools.ietf.org/html/rfc6797. + /// + public class HstsMiddleware + { + private const string IncludeSubDomains = "; includeSubDomains"; + private const string Preload = "; preload"; + + private readonly RequestDelegate _next; + private readonly StringValues _strictTransportSecurityValue; + + /// + /// Initialize the HSTS middleware. + /// + /// + /// + public HstsMiddleware(RequestDelegate next, IOptions options) + { + if (next == null) + { + throw new ArgumentNullException(nameof(next)); + } + + if (options == null) + { + throw new ArgumentNullException(nameof(options)); + } + + _next = next; + + var hstsOptions = options.Value; + var maxAge = Convert.ToInt64(Math.Floor(hstsOptions.MaxAge.TotalSeconds)) + .ToString(CultureInfo.InvariantCulture); + var includeSubdomains = hstsOptions.IncludeSubDomains ? IncludeSubDomains : StringSegment.Empty; + var preload = hstsOptions.Preload ? Preload : StringSegment.Empty; + _strictTransportSecurityValue = new StringValues($"max-age={maxAge}{includeSubdomains}{preload}"); + } + + /// + /// Invoke the middleware. + /// + /// The . + /// + public Task Invoke(HttpContext context) + { + if (context.Request.IsHttps) + { + context.Response.Headers[HeaderNames.StrictTransportSecurity] = _strictTransportSecurityValue; + } + + return _next(context); + } + } +} diff --git a/src/Microsoft.AspNetCore.HttpsPolicy/HstsOptions.cs b/src/Microsoft.AspNetCore.HttpsPolicy/HstsOptions.cs new file mode 100644 index 0000000000..77d1d76254 --- /dev/null +++ b/src/Microsoft.AspNetCore.HttpsPolicy/HstsOptions.cs @@ -0,0 +1,39 @@ +// 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; + +namespace Microsoft.AspNetCore.HttpsPolicy +{ + /// + /// Options for the Hsts Middleware + /// + public class HstsOptions + { + /// + /// Sets the max-age parameter of the Strict-Transport-Security header. + /// + /// + /// Max-age is required; defaults to 30 days. + /// See: https://tools.ietf.org/html/rfc6797#section-6.1.1 + /// + public TimeSpan MaxAge { get; set; } = TimeSpan.FromDays(30); + + /// + /// Enables includeSubDomain parameter of the Strict-Transport-Security header. + /// + /// + /// See: https://tools.ietf.org/html/rfc6797#section-6.1.2 + /// + public bool IncludeSubDomains { get; set; } + + /// + /// Sets the preload parameter of the Strict-Transport-Security header. + /// + /// + /// Preload is not part of the RFC specification, but is supported by web browsers + /// to preload HSTS sites on fresh install. See https://hstspreload.org/. + /// + public bool Preload { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.HttpsPolicy/HstsServicesExtensions.cs b/src/Microsoft.AspNetCore.HttpsPolicy/HstsServicesExtensions.cs new file mode 100644 index 0000000000..425ec9040c --- /dev/null +++ b/src/Microsoft.AspNetCore.HttpsPolicy/HstsServicesExtensions.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.HttpsPolicy; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for the HSTS middleware. + /// + public static class HstsServicesExtensions + { + /// + /// Adds HSTS services. + /// + /// The for adding services. + /// A delegate to configure the . + /// + public static IServiceCollection AddHsts(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.HttpsPolicy/HttpsRedirectionBuilderExtensions.cs b/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionBuilderExtensions.cs new file mode 100644 index 0000000000..89823a0e7c --- /dev/null +++ b/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionBuilderExtensions.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; +using Microsoft.AspNetCore.HttpsPolicy; +using Microsoft.AspNetCore.Rewrite; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for the HttpsRedirection middleware. + /// + public static class HttpsPolicyBuilderExtensions + { + /// + /// Adds middleware for redirecting HTTP Requests to HTTPS. + /// + /// 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 options = app.ApplicationServices.GetRequiredService>().Value; + + var rewriteOptions = new RewriteOptions(); + rewriteOptions.AddRedirectToHttps( + options.RedirectStatusCode, + options.TlsPort); + + app.UseRewriter(rewriteOptions); + + return app; + } + } +} diff --git a/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionOptions.cs b/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionOptions.cs new file mode 100644 index 0000000000..d73df4d59b --- /dev/null +++ b/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionOptions.cs @@ -0,0 +1,26 @@ +// 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.Http; + +namespace Microsoft.AspNetCore.HttpsPolicy +{ + /// + /// Options for the HttpsRedirection middleware + /// + public class HttpsRedirectionOptions + { + /// + /// The status code to redirect the response to. + /// + public int RedirectStatusCode { get; set; } = StatusCodes.Status301MovedPermanently; + + /// + /// The TLS port to be added to the redirected URL. + /// + /// + /// Defaults to 443 if not provided. + /// + public int? TlsPort { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionServicesExtensions.cs b/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionServicesExtensions.cs new file mode 100644 index 0000000000..cdc6f005bc --- /dev/null +++ b/src/Microsoft.AspNetCore.HttpsPolicy/HttpsRedirectionServicesExtensions.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.HttpsPolicy; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Builder +{ + /// + /// Extension methods for the HttpsRedirection middleware. + /// + public static class HttpsRedirectionServicesExtensions + { + /// + /// Adds HTTPS redirection services. + /// + /// The for adding services. + /// A delegate to configure the . + /// + public static IServiceCollection AddHttpsRedirection(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.HttpsPolicy/Microsoft.AspNetCore.HttpsPolicy.csproj b/src/Microsoft.AspNetCore.HttpsPolicy/Microsoft.AspNetCore.HttpsPolicy.csproj new file mode 100644 index 0000000000..b68d33b7dc --- /dev/null +++ b/src/Microsoft.AspNetCore.HttpsPolicy/Microsoft.AspNetCore.HttpsPolicy.csproj @@ -0,0 +1,16 @@ + + + + + ASP.NET Core basic middleware for supporting HTTPS Redirection and HTTP Strict-Transport-Security. + + netstandard2.0 + $(NoWarn);CS1591 + true + aspnetcore;https;hsts + + + + + + diff --git a/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HstsMiddlewareTests.cs b/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HstsMiddlewareTests.cs new file mode 100644 index 0000000000..e6d325769f --- /dev/null +++ b/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HstsMiddlewareTests.cs @@ -0,0 +1,128 @@ +// 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.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.HttpsPolicy.Tests +{ + public class HstsMiddlewareTests + { + [Fact] + public async Task SetOptionsWithDefault_SetsMaxAgeToCorrectValue() + { + var builder = new WebHostBuilder() + .UseUrls("https://*:5050") + .ConfigureServices(services => + { + }) + .Configure(app => + { + app.UseHsts(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + client.BaseAddress = new Uri("https://localhost:5050"); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("max-age=2592000", response.Headers.GetValues(HeaderNames.StrictTransportSecurity).FirstOrDefault()); + } + + [Theory] + [InlineData(0, false, false, "max-age=0")] + [InlineData(-1, false, false, "max-age=-1")] + [InlineData(0, true, false, "max-age=0; includeSubDomains")] + [InlineData(50000, false, true, "max-age=50000; preload")] + [InlineData(0, true, true, "max-age=0; includeSubDomains; preload")] + [InlineData(50000, true, true, "max-age=50000; includeSubDomains; preload")] + public async Task SetOptionsThroughConfigure_SetsHeaderCorrectly(int maxAge, bool includeSubDomains, bool preload, string expected) + { + var builder = new WebHostBuilder() + .UseUrls("https://*:5050") + .ConfigureServices(services => + { + services.Configure(options => { + options.Preload = preload; + options.IncludeSubDomains = includeSubDomains; + options.MaxAge = TimeSpan.FromSeconds(maxAge); + }); + }) + .Configure(app => + { + app.UseHsts(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + client.BaseAddress = new Uri("https://localhost:5050"); + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expected, response.Headers.GetValues(HeaderNames.StrictTransportSecurity).FirstOrDefault()); + } + + [Theory] + [InlineData(0, false, false, "max-age=0")] + [InlineData(-1, false, false, "max-age=-1")] + [InlineData(0, true, false, "max-age=0; includeSubDomains")] + [InlineData(50000, false, true, "max-age=50000; preload")] + [InlineData(0, true, true, "max-age=0; includeSubDomains; preload")] + [InlineData(50000, true, true, "max-age=50000; includeSubDomains; preload")] + public async Task SetOptionsThroughHelper_SetsHeaderCorrectly(int maxAge, bool includeSubDomains, bool preload, string expected) + { + var builder = new WebHostBuilder() + .UseUrls("https://*:5050") + .ConfigureServices(services => + { + services.AddHsts(options => { + options.Preload = preload; + options.IncludeSubDomains = includeSubDomains; + options.MaxAge = TimeSpan.FromSeconds(maxAge); + }); + }) + .Configure(app => + { + app.UseHsts(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + client.BaseAddress = new Uri("https://localhost:5050"); + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expected, response.Headers.GetValues(HeaderNames.StrictTransportSecurity).FirstOrDefault()); + } + } +} diff --git a/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HttpsPolicyTests.cs b/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HttpsPolicyTests.cs new file mode 100644 index 0000000000..58a0be138c --- /dev/null +++ b/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HttpsPolicyTests.cs @@ -0,0 +1,76 @@ +// 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.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.HttpsPolicy.Tests +{ + public class HttpsPolicyTests + { + [Theory] + [InlineData(302, null, 2592000, false, false, "max-age=2592000", "https://localhost/")] + [InlineData(301, 5050, 2592000, false, false, "max-age=2592000", "https://localhost:5050/")] + [InlineData(301, 443, 2592000, false, false, "max-age=2592000", "https://localhost/")] + [InlineData(301, 443, 2592000, true, false, "max-age=2592000; includeSubDomains", "https://localhost/")] + [InlineData(301, 443, 2592000, false, true, "max-age=2592000; preload", "https://localhost/")] + [InlineData(301, null, 2592000, true, true, "max-age=2592000; includeSubDomains; preload", "https://localhost/")] + [InlineData(302, 5050, 2592000, true, true, "max-age=2592000; includeSubDomains; preload", "https://localhost:5050/")] + public async Task SetsBothHstsAndHttpsRedirection_RedirectOnFirstRequest_HstsOnSecondRequest(int statusCode, int? tlsPort, int maxAge, bool includeSubDomains, bool preload, string expectedHstsHeader, string expectedUrl) + { + + var builder = new WebHostBuilder() + .UseUrls("https://*:5050", "http://*:5050") + .ConfigureServices(services => + { + services.Configure(options => + { + options.RedirectStatusCode = statusCode; + options.TlsPort = tlsPort; + }); + services.Configure(options => + { + options.IncludeSubDomains = includeSubDomains; + options.MaxAge = TimeSpan.FromSeconds(maxAge); + options.Preload = preload; + }); + }) + .Configure(app => + { + app.UseHttpsRedirection(); + app.UseHsts(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(statusCode, (int)response.StatusCode); + Assert.Equal(expectedUrl, response.Headers.Location.ToString()); + + client = server.CreateClient(); + client.BaseAddress = new Uri(response.Headers.Location.ToString()); + request = new HttpRequestMessage(HttpMethod.Get, ""); + response = await client.SendAsync(request); + + Assert.Equal(expectedHstsHeader, response.Headers.GetValues(HeaderNames.StrictTransportSecurity).FirstOrDefault()); + } + } +} diff --git a/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HttpsRedirectionMiddlewareTests.cs b/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HttpsRedirectionMiddlewareTests.cs new file mode 100644 index 0000000000..1e77683bf7 --- /dev/null +++ b/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/HttpsRedirectionMiddlewareTests.cs @@ -0,0 +1,125 @@ +// 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.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Xunit; + +namespace Microsoft.AspNetCore.HttpsPolicy.Tests +{ + public class HttpsRedirectionMiddlewareTests + { + [Fact] + public async Task SetOptions_DefaultsSetCorrectly() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + }) + .Configure(app => + { + app.UseHttpsRedirection(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); + Assert.Equal("https://localhost/", response.Headers.Location.ToString()); + } + + [Theory] + [InlineData(301, null, "https://localhost/")] + [InlineData(302, null, "https://localhost/")] + [InlineData(307, null, "https://localhost/")] + [InlineData(308, null, "https://localhost/")] + [InlineData(301, 5050, "https://localhost:5050/")] + [InlineData(301, 443, "https://localhost/")] + public async Task SetOptions_SetStatusCodeTlsPort(int statusCode, int? tlsPort, string expected) + { + + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.Configure(options => + { + options.RedirectStatusCode = statusCode; + options.TlsPort = tlsPort; + }); + }) + .Configure(app => + { + app.UseHttpsRedirection(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(statusCode, (int)response.StatusCode); + Assert.Equal(expected, response.Headers.Location.ToString()); + } + + [Theory] + [InlineData(301, null, "https://localhost/")] + [InlineData(302, null, "https://localhost/")] + [InlineData(307, null, "https://localhost/")] + [InlineData(308, null, "https://localhost/")] + [InlineData(301, 5050, "https://localhost:5050/")] + [InlineData(301, 443, "https://localhost/")] + public async Task SetOptionsThroughHelperMethod_SetStatusCodeTlsPort(int statusCode, int? tlsPort, string expectedUrl) + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddHttpsRedirection(options => + { + options.RedirectStatusCode = statusCode; + options.TlsPort = tlsPort; + }); + }) + .Configure(app => + { + app.UseHttpsRedirection(); + app.Run(context => + { + return context.Response.WriteAsync("Hello world"); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + + var response = await client.SendAsync(request); + + Assert.Equal(statusCode, (int)response.StatusCode); + Assert.Equal(expectedUrl, response.Headers.Location.ToString()); + } + } +} diff --git a/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/Microsoft.AspNetCore.HttpsPolicy.Tests.csproj b/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/Microsoft.AspNetCore.HttpsPolicy.Tests.csproj new file mode 100644 index 0000000000..acdd245d85 --- /dev/null +++ b/test/Microsoft.AspNetCore.HttpsEnforcement.Tests/Microsoft.AspNetCore.HttpsPolicy.Tests.csproj @@ -0,0 +1,10 @@ + + + + netcoreapp2.0 + + + + + +