diff --git a/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs new file mode 100644 index 000000000..d6fa16f1b --- /dev/null +++ b/src/Microsoft.AspNet.Mvc.Core/RequireHttpsAttribute.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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; + +namespace Microsoft.AspNet.Mvc +{ + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = false)] + public class RequireHttpsAttribute : + Attribute, IAuthorizationFilter, IOrderedFilter + { + public int Order { get; set; } + + public virtual void OnAuthorization([NotNull]AuthorizationContext filterContext) + { + if (!filterContext.HttpContext.Request.IsSecure) + { + HandleNonHttpsRequest(filterContext); + } + } + + protected virtual void HandleNonHttpsRequest(AuthorizationContext filterContext) + { + // only redirect for GET requests, otherwise the browser might not propagate the verb and request + // body correctly. + if (!string.Equals(filterContext.HttpContext.Request.Method, "GET", StringComparison.OrdinalIgnoreCase)) + { + filterContext.Result = new HttpStatusCodeResult(403); + } + else + { + var request = filterContext.HttpContext.Request; + var newUrl = string.Concat( + "https://", + request.Host.ToUriComponent(), + request.PathBase.ToUriComponent(), + request.Path.ToUriComponent(), + request.QueryString.ToUriComponent()); + + // redirect to HTTPS version of page + filterContext.Result = new RedirectResult(newUrl, permanent: true); + } + } + } +} diff --git a/test/Microsoft.AspNet.Mvc.Core.Test/RequireHttpsAttributeTests.cs b/test/Microsoft.AspNet.Mvc.Core.Test/RequireHttpsAttributeTests.cs new file mode 100644 index 000000000..246126988 --- /dev/null +++ b/test/Microsoft.AspNet.Mvc.Core.Test/RequireHttpsAttributeTests.cs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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 System.Linq; +using Moq; +using Xunit; +using Microsoft.AspNet.Http; +using Microsoft.AspNet.PipelineCore; +using Microsoft.AspNet.Routing; + +namespace Microsoft.AspNet.Mvc +{ + + public class RequireHttpsAttributeTests + { + [Fact] + public void OnAuthorization_AllowsTheRequestIfItIsSecure() + { + // Arrange + var requestContext = new DefaultHttpContext(); + requestContext.Request.Scheme = "https"; + + var authContext = CreateAuthorizationContext(requestContext); + var attr = new RequireHttpsAttribute(); + + // Act + attr.OnAuthorization(authContext); + + // Assert + Assert.Null(authContext.Result); + } + + public static IEnumerable RedirectToHttpEndpointTestData + { + get + { + // host, pathbase, path, query, expectedRedirectUrl + var data = new TheoryData(); + + data.Add("localhost", null, null, null, "https://localhost"); + data.Add("localhost:5000", null, null, null, "https://localhost:5000"); + data.Add("localhost", "/pathbase", null, null, "https://localhost/pathbase"); + data.Add("localhost", "/pathbase", "/path", null, "https://localhost/pathbase/path"); + data.Add("localhost", "/pathbase", "/path", "?foo=bar", "https://localhost/pathbase/path?foo=bar"); + + // Encode some special characters on the url. + data.Add("localhost", "/path?base", null, null, "https://localhost/path%3Fbase"); + data.Add("localhost", null, "/pa?th", null, "https://localhost/pa%3Fth"); + data.Add("localhost", "/", null, "?foo=bar%2Fbaz", "https://localhost/?foo=bar%2Fbaz"); + + // Urls with punycode + // 本地主機 is "localhost" in chinese traditional, "xn--tiq21tzznx7c" is the + // punycode representation. + data.Add("本地主機", "/", null, null, "https://xn--tiq21tzznx7c/"); + return data; + } + } + + [Theory] + [MemberData(nameof(RedirectToHttpEndpointTestData))] + public void OnAuthorization_RedirectsToHttpsEndpoint_ForNonHttpsGetRequests( + string host, + string pathBase, + string path, + string queryString, + string expectedUrl) + { + // Arrange + var requestContext = new DefaultHttpContext(); + requestContext.Request.Scheme = "http"; + requestContext.Request.Method = "GET"; + requestContext.Request.Host = HostString.FromUriComponent(host); + + if (pathBase != null) + { + requestContext.Request.PathBase = new PathString(pathBase); + } + + if (path != null) + { + requestContext.Request.Path = new PathString(path); + } + + if (queryString != null) + { + requestContext.Request.QueryString = new QueryString(queryString); + } + + var authContext = CreateAuthorizationContext(requestContext); + var attr = new RequireHttpsAttribute(); + + // Act + attr.OnAuthorization(authContext); + + // Assert + Assert.NotNull(authContext.Result); + var result = Assert.IsType(authContext.Result); + + Assert.True(result.Permanent); + Assert.Equal(expectedUrl, result.Url); + } + + [Theory] + [InlineData("POST")] + [InlineData("PUT")] + [InlineData("PATCH")] + [InlineData("DELETE")] + public void OnAuthorization_SignalsBadRequestStatusCode_ForNonHttpsAndNonGetRequests(string method) + { + // Arrange + var requestContext = new DefaultHttpContext(); + requestContext.Request.Scheme = "http"; + requestContext.Request.Method = method; + var authContext = CreateAuthorizationContext(requestContext); + var attr = new RequireHttpsAttribute(); + + // Act + attr.OnAuthorization(authContext); + + // Assert + Assert.NotNull(authContext.Result); + var result = Assert.IsType(authContext.Result); + Assert.Equal(403, result.StatusCode); + } + + [Fact] + public void HandleNonHttpsRequestExtensibility() + { + // Arrange + var requestContext = new DefaultHttpContext(); + requestContext.Request.Scheme = "http"; + + var authContext = CreateAuthorizationContext(requestContext); + var attr = new CustomRequireHttpsAttribute(); + + // Act + attr.OnAuthorization(authContext); + + // Assert + var result = Assert.IsType(authContext.Result); + Assert.Equal(404, result.StatusCode); + } + + private class CustomRequireHttpsAttribute : RequireHttpsAttribute + { + protected override void HandleNonHttpsRequest(AuthorizationContext filterContext) + { + filterContext.Result = new HttpStatusCodeResult(404); + } + } + + private static AuthorizationContext CreateAuthorizationContext(HttpContext ctx) + { + var actionContext = new ActionContext(ctx, new RouteData(), actionDescriptor: null); + + return new AuthorizationContext(actionContext, Enumerable.Empty().ToList()); + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNet.Mvc.FunctionalTests/BasicTests.cs b/test/Microsoft.AspNet.Mvc.FunctionalTests/BasicTests.cs index ed9bda8ac..83aa20f97 100644 --- a/test/Microsoft.AspNet.Mvc.FunctionalTests/BasicTests.cs +++ b/test/Microsoft.AspNet.Mvc.FunctionalTests/BasicTests.cs @@ -3,6 +3,7 @@ using System; using System.Net; +using System.Net.Http; using System.Net.Http.Headers; using System.Reflection; using System.Threading.Tasks; @@ -125,5 +126,63 @@ namespace Microsoft.AspNet.Mvc.FunctionalTests Assert.Equal(expectedContent, responseContent); } } + + [Fact] + public async Task ActionWithRequireHttps_RedirectsToSecureUrl_ForNonHttpsGetRequests() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.GetAsync("http://localhost/Home/HttpsOnlyAction"); + + // Assert + Assert.Equal(HttpStatusCode.MovedPermanently, response.StatusCode); + Assert.NotNull(response.Headers.Location); + Assert.Equal("https://localhost/Home/HttpsOnlyAction", response.Headers.Location.ToString()); + Assert.Equal(0, response.Content.Headers.ContentLength); + + var responseBytes = await response.Content.ReadAsByteArrayAsync(); + Assert.Equal(0, responseBytes.Length); + } + + [Fact] + public async Task ActionWithRequireHttps_ReturnsBadRequestResponse_ForNonHttpsNonGetRequests() + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = server.CreateClient(); + + // Act + var response = await client.SendAsync(new HttpRequestMessage( + HttpMethod.Post, + "http://localhost/Home/HttpsOnlyAction")); + + // Assert + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Equal(0, response.Content.Headers.ContentLength); + + var responseBytes = await response.Content.ReadAsByteArrayAsync(); + Assert.Equal(0, responseBytes.Length); + } + + [Theory] + [InlineData("GET")] + [InlineData("POST")] + public async Task ActionWithRequireHttps_AllowsHttpsRequests(string method) + { + // Arrange + var server = TestServer.Create(_provider, _app); + var client = new HttpClient(server.CreateHandler(), false); + + // Act + var response = await client.SendAsync(new HttpRequestMessage( + new HttpMethod(method), + "https://localhost/Home/HttpsOnlyAction")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } } } \ No newline at end of file diff --git a/test/WebSites/BasicWebSite/Controllers/HomeController.cs b/test/WebSites/BasicWebSite/Controllers/HomeController.cs index 339159d3e..4d2a414cf 100644 --- a/test/WebSites/BasicWebSite/Controllers/HomeController.cs +++ b/test/WebSites/BasicWebSite/Controllers/HomeController.cs @@ -24,6 +24,13 @@ namespace BasicWebSite.Controllers return new HttpStatusCodeResult(204); } + [AcceptVerbs("GET", "POST")] + [RequireHttps] + public IActionResult HttpsOnlyAction() + { + return new HttpStatusCodeResult(200); + } + public async Task ActionReturningTask() { // TODO: #1077. With HttpResponseMessage, there seems to be a race between the write operation setting the