Add support for clients to configure a policy with custom logic about whether an origin should be allowed and provide a default implementation of the functionality to allow wildcard subdomains

This commit is contained in:
Choc 2016-11-01 15:31:05 -07:00 committed by Kiran Challa
parent 2d916a8b48
commit 8214954d5b
14 changed files with 339 additions and 33 deletions

1
.gitignore vendored
View File

@ -37,3 +37,4 @@ node_modules
*launchSettings.json
.build/
.testPublish/
.vscode

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Microsoft.AspNetCore.Cors.Infrastructure
@ -14,6 +15,14 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
{
private TimeSpan? _preflightMaxAge;
/// <summary>
/// Default constructor for a CorsPolicy.
/// </summary>
public CorsPolicy()
{
IsOriginAllowed = DefaultIsOriginAllowed;
}
/// <summary>
/// Gets a value indicating if all headers are allowed.
/// </summary>
@ -62,6 +71,11 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
}
}
/// <summary>
/// Gets or sets a function which evaluates whether an origin is allowed.
/// </summary>
public Func<string, bool> IsOriginAllowed { get; set; }
/// <summary>
/// Gets the headers that the resource might use and can be exposed.
/// </summary>
@ -141,5 +155,10 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
builder.Append("}");
return builder.ToString();
}
private bool DefaultIsOriginAllowed(string origin)
{
return Origins.Contains(origin, StringComparer.Ordinal);
}
}
}

View File

@ -35,7 +35,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// Adds the specified <paramref name="origins"/> to the policy.
/// </summary>
/// <param name="origins">The origins that are allowed.</param>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder WithOrigins(params string[] origins)
{
foreach (var req in origins)
@ -50,7 +50,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// Adds the specified <paramref name="headers"/> to the policy.
/// </summary>
/// <param name="headers">The headers which need to be allowed in the request.</param>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder WithHeaders(params string[] headers)
{
foreach (var req in headers)
@ -64,7 +64,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// Adds the specified <paramref name="exposedHeaders"/> to the policy.
/// </summary>
/// <param name="exposedHeaders">The headers which need to be exposed to the client.</param>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder WithExposedHeaders(params string[] exposedHeaders)
{
foreach (var req in exposedHeaders)
@ -79,7 +79,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// Adds the specified <paramref name="methods"/> to the policy.
/// </summary>
/// <param name="methods">The methods which need to be added to the policy.</param>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder WithMethods(params string[] methods)
{
foreach (var req in methods)
@ -93,7 +93,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// <summary>
/// Sets the policy to allow credentials.
/// </summary>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder AllowCredentials()
{
_policy.SupportsCredentials = true;
@ -103,7 +103,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// <summary>
/// Sets the policy to not allow credentials.
/// </summary>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder DisallowCredentials()
{
_policy.SupportsCredentials = false;
@ -113,7 +113,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// <summary>
/// Ensures that the policy allows any origin.
/// </summary>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder AllowAnyOrigin()
{
_policy.Origins.Clear();
@ -124,7 +124,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// <summary>
/// Ensures that the policy allows any method.
/// </summary>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder AllowAnyMethod()
{
_policy.Methods.Clear();
@ -135,7 +135,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// <summary>
/// Ensures that the policy allows any header.
/// </summary>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder AllowAnyHeader()
{
_policy.Headers.Clear();
@ -148,13 +148,36 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// </summary>
/// <param name="preflightMaxAge">A positive <see cref="TimeSpan"/> indicating the time a preflight
/// request can be cached.</param>
/// <returns></returns>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder SetPreflightMaxAge(TimeSpan preflightMaxAge)
{
_policy.PreflightMaxAge = preflightMaxAge;
return this;
}
/// <summary>
/// Sets the specified <paramref name="isOriginAllowed"/> for the underlying policy.
/// </summary>
/// <param name="isOriginAllowed">The function used by the policy to evaluate if an origin is allowed.</param>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder SetIsOriginAllowed(Func<string, bool> isOriginAllowed)
{
_policy.IsOriginAllowed = isOriginAllowed;
return this;
}
/// <summary>
/// Sets the <see cref="CorsPolicy.IsOriginAllowed"/> property of the policy to be a function
/// that allows origins to match a configured wildcarded domain when evaluating if the
/// origin is allowed.
/// </summary>
/// <returns>The current policy builder.</returns>
public CorsPolicyBuilder SetIsOriginAllowedToAllowWildcardSubdomains()
{
_policy.IsOriginAllowed = _policy.IsOriginAnAllowedSubdomain;
return this;
}
/// <summary>
/// Builds a new <see cref="CorsPolicy"/> using the entries added.
/// </summary>
@ -168,7 +191,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
/// Combines the given <paramref name="policy"/> to the existing properties in the builder.
/// </summary>
/// <param name="policy">The policy which needs to be combined.</param>
/// <returns>The current policy builder</returns>
/// <returns>The current policy builder.</returns>
private CorsPolicyBuilder Combine(CorsPolicy policy)
{
if (policy == null)
@ -180,6 +203,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
WithHeaders(policy.Headers.ToArray());
WithExposedHeaders(policy.ExposedHeaders.ToArray());
WithMethods(policy.Methods.ToArray());
SetIsOriginAllowed(policy.IsOriginAllowed);
if (policy.PreflightMaxAge.HasValue)
{

View File

@ -0,0 +1,31 @@
// 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;
namespace Microsoft.AspNetCore.Cors.Infrastructure
{
internal static class CorsPolicyExtensions
{
private const string _WildcardSubdomain = "*.";
public static bool IsOriginAnAllowedSubdomain(this CorsPolicy policy, string origin)
{
if (policy.Origins.Contains(origin))
{
return true;
}
var originUri = new Uri(origin, UriKind.Absolute);
return policy.Origins
.Where(o => o.Contains($"://{_WildcardSubdomain}"))
.Select(CreateDomainUri)
.Any(domain => UriHelpers.IsSubdomainOf(originUri, domain));
}
private static Uri CreateDomainUri(string origin)
{
return new Uri(origin.Replace(_WildcardSubdomain, string.Empty), UriKind.Absolute);
}
}
}

View File

@ -97,17 +97,8 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
public virtual void EvaluateRequest(HttpContext context, CorsPolicy policy, CorsResult result)
{
var origin = context.Request.Headers[CorsConstants.Origin];
if (StringValues.IsNullOrEmpty(origin))
if (!IsOriginAllowed(policy, origin))
{
_logger?.RequestDoesNotHaveOriginHeader();
return;
}
_logger?.RequestHasOriginHeader(origin);
if (!policy.AllowAnyOrigin && !policy.Origins.Contains(origin))
{
_logger?.PolicyFailure();
_logger?.OriginNotAllowed(origin);
return;
}
@ -120,17 +111,8 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
public virtual void EvaluatePreflightRequest(HttpContext context, CorsPolicy policy, CorsResult result)
{
var origin = context.Request.Headers[CorsConstants.Origin];
if (StringValues.IsNullOrEmpty(origin))
if (!IsOriginAllowed(policy, origin))
{
_logger?.RequestDoesNotHaveOriginHeader();
return;
}
_logger?.RequestHasOriginHeader(origin);
if (!policy.AllowAnyOrigin && !policy.Origins.Contains(origin))
{
_logger?.PolicyFailure();
_logger?.OriginNotAllowed(origin);
return;
}
@ -286,7 +268,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
result.AllowedOrigin = CorsConstants.AnyOrigin;
}
}
else if (policy.Origins.Contains(origin))
else if (policy.IsOriginAllowed(origin))
{
result.AllowedOrigin = origin;
}
@ -304,5 +286,23 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
target.Add(current);
}
}
private bool IsOriginAllowed(CorsPolicy policy, StringValues origin)
{
if (StringValues.IsNullOrEmpty(origin))
{
_logger?.RequestDoesNotHaveOriginHeader();
return false;
}
_logger?.RequestHasOriginHeader(origin);
if (policy.AllowAnyOrigin || policy.IsOriginAllowed(origin))
{
return true;
}
_logger?.PolicyFailure();
_logger?.OriginNotAllowed(origin);
return false;
}
}
}

View File

@ -0,0 +1,19 @@
// 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.Cors.Infrastructure
{
internal static class UriHelpers
{
public static bool IsSubdomainOf(Uri subdomain, Uri domain)
{
return subdomain.IsAbsoluteUri
&& domain.IsAbsoluteUri
&& subdomain.Scheme == domain.Scheme
&& subdomain.Port == domain.Port
&& subdomain.Host.EndsWith($".{domain.Host}", StringComparison.Ordinal);
}
}
}

View File

@ -3,9 +3,11 @@
using System.Reflection;
using System.Resources;
using System.Runtime.CompilerServices;
[assembly: AssemblyMetadata("Serviceable", "True")]
[assembly: NeutralResourcesLanguage("en-US")]
[assembly: AssemblyCompany("Microsoft Corporation.")]
[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")]
[assembly: AssemblyProduct("Microsoft ASP.NET Core")]
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Cors.Test,PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

View File

@ -14,6 +14,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
public void Constructor_WithPolicy_AddsTheGivenPolicy()
{
// Arrange
Func<string, bool> isOriginAllowed = origin => true;
var originalPolicy = new CorsPolicy();
originalPolicy.Origins.Add("http://existing.com");
originalPolicy.Headers.Add("Existing");
@ -21,6 +22,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
originalPolicy.ExposedHeaders.Add("ExistingExposed");
originalPolicy.SupportsCredentials = true;
originalPolicy.PreflightMaxAge = TimeSpan.FromSeconds(12);
originalPolicy.IsOriginAllowed = isOriginAllowed;
// Act
var builder = new CorsPolicyBuilder(originalPolicy);
@ -41,6 +43,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
Assert.NotSame(originalPolicy.ExposedHeaders, corsPolicy.ExposedHeaders);
Assert.Equal(originalPolicy.ExposedHeaders, corsPolicy.ExposedHeaders);
Assert.Equal(TimeSpan.FromSeconds(12), corsPolicy.PreflightMaxAge);
Assert.Same(originalPolicy.IsOriginAllowed, corsPolicy.IsOriginAllowed);
}
[Fact]
@ -140,6 +143,35 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
Assert.Equal(new List<string>() { "*" }, corsPolicy.Origins);
}
[Fact]
public void SetIsOriginAllowed_AddsIsOriginAllowed()
{
// Arrange
var builder = new CorsPolicyBuilder();
Func<string, bool> isOriginAllowed = origin => true;
// Act
builder.SetIsOriginAllowed(isOriginAllowed);
// Assert
var corsPolicy = builder.Build();
Assert.Same(corsPolicy.IsOriginAllowed, isOriginAllowed);
}
[Fact]
public void SetIsOriginAllowedToAllowWildcardSubdomains_AllowsWildcardSubdomains()
{
// Arrange
var builder = new CorsPolicyBuilder("http://*.example.com");
// Act
builder.SetIsOriginAllowedToAllowWildcardSubdomains();
// Assert
var corsPolicy = builder.Build();
Assert.True(corsPolicy.IsOriginAllowed("http://test.example.com"));
}
[Fact]
public void WithMethods_AddsMethods()
{

View File

@ -0,0 +1,65 @@
// 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 Xunit;
namespace Microsoft.AspNetCore.Cors.Infrastructure
{
public sealed class CorsPolicyExtensionsTest
{
[Fact]
public void IsOriginAnAllowedSubdomain_ReturnsTrueIfPolicyContainsOrigin()
{
// Arrange
const string origin = "http://sub.domain";
var policy = new CorsPolicy();
policy.Origins.Add(origin);
// Act
var actual = policy.IsOriginAnAllowedSubdomain(origin);
// Assert
Assert.True(actual);
}
[Theory]
[InlineData("http://sub.domain", "http://*.domain")]
[InlineData("http://sub.sub.domain", "http://*.domain")]
[InlineData("http://sub.sub.domain", "http://*.sub.domain")]
[InlineData("http://sub.domain:4567", "http://*.domain:4567")]
public void IsOriginAnAllowedSubdomain_ReturnsTrue_WhenASubdomain(string origin, string allowedOrigin)
{
// Arrange
var policy = new CorsPolicy();
policy.Origins.Add(allowedOrigin);
// Act
var isAllowed = policy.IsOriginAnAllowedSubdomain(origin);
// Assert
Assert.True(isAllowed);
}
[Theory]
[InlineData("http://domain", "http://*.domain")]
[InlineData("http://sub.domain", "http://domain")]
[InlineData("http://sub.domain:1234", "http://*.domain:5678")]
[InlineData("http://sub.domain", "http://domain.*")]
[InlineData("http://sub.sub.domain", "http://sub.*.domain")]
[InlineData("http://sub.domain.hacker", "http://*.domain")]
[InlineData("https://sub.domain", "http://*.domain")]
public void IsOriginAnAllowedSubdomain_ReturnsFalse_WhenNotASubdomain(string origin, string allowedOrigin)
{
// Arrange
var policy = new CorsPolicy();
policy.Origins.Add(allowedOrigin);
// Act
var isAllowed = policy.IsOriginAnAllowedSubdomain(origin);
// Assert
Assert.False(isAllowed);
}
}
}

View File

@ -24,6 +24,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
Assert.Empty(corsPolicy.Methods);
Assert.Empty(corsPolicy.Origins);
Assert.Null(corsPolicy.PreflightMaxAge);
Assert.NotNull(corsPolicy.IsOriginAllowed);
}
[Fact]

View File

@ -58,6 +58,26 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
Assert.False(result.VaryByOrigin);
}
[Fact]
public void EvaluatePolicy_IsOriginAllowedReturnsFalse_ReturnsInvalidResult()
{
// Arrange
var corsService = new CorsService(new TestCorsOptions());
var requestContext = GetHttpContext(origin: "http://example.com");
var policy = new CorsPolicy()
{
IsOriginAllowed = origin => false
};
policy.Origins.Add("example.com");
// Act
var result = corsService.EvaluatePolicy(requestContext, policy);
// Assert
Assert.Null(result.AllowedOrigin);
Assert.False(result.VaryByOrigin);
}
[Fact]
public void EvaluatePolicy_AllowAnyOrigin_DoesNotSupportCredentials_EmitsWildcardForOrigin()
{
@ -407,6 +427,28 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure
Assert.Equal("http://example.com", result.AllowedOrigin);
}
[Fact]
public void EvaluatePolicy_PreflightRequest_IsOriginAllowedReturnsTrue_ReturnsOrigin()
{
// Arrange
var corsService = new CorsService(new TestCorsOptions());
var requestContext = GetHttpContext(
method: "OPTIONS",
origin: "http://example.com",
accessControlRequestMethod: "PUT");
var policy = new CorsPolicy
{
IsOriginAllowed = origin => true
};
policy.Methods.Add("*");
// Act
var result = corsService.EvaluatePolicy(requestContext, policy);
// Assert
Assert.Equal("http://example.com", result.AllowedOrigin);
}
[Fact]
public void EvaluatePolicy_PreflightRequest_SupportsCredentials_AllowCredentialsReturnsTrue()
{

View File

@ -0,0 +1,66 @@
// 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 Xunit;
namespace Microsoft.AspNetCore.Cors.Infrastructure
{
public sealed class UriHelpersTests
{
[Theory]
[MemberData(nameof(IsSubdomainOfTestData))]
public void TestIsSubdomainOf(Uri subdomain, Uri domain)
{
// Act
bool isSubdomain = UriHelpers.IsSubdomainOf(subdomain, domain);
// Assert
Assert.True(isSubdomain);
}
[Theory]
[MemberData(nameof(IsNotSubdomainOfTestData))]
public void TestIsSubdomainOf_ReturnsFalse_WhenNotSubdomain(Uri subdomain, Uri domain)
{
// Act
bool isSubdomain = UriHelpers.IsSubdomainOf(subdomain, domain);
// Assert
Assert.False(isSubdomain);
}
public static IEnumerable<object[]> IsSubdomainOfTestData
{
get
{
return new[]
{
new object[] {new Uri("http://sub.domain"), new Uri("http://domain")},
new object[] {new Uri("https://sub.domain"), new Uri("https://domain")},
new object[] {new Uri("https://sub.domain:5678"), new Uri("https://domain:5678")},
new object[] {new Uri("http://sub.sub.domain"), new Uri("http://domain")},
new object[] {new Uri("http://sub.sub.domain"), new Uri("http://sub.domain")}
};
}
}
public static IEnumerable<object[]> IsNotSubdomainOfTestData
{
get
{
return new[]
{
new object[] {new Uri("http://subdomain"), new Uri("http://domain")},
new object[] {new Uri("https://sub.domain"), new Uri("http://domain")},
new object[] {new Uri("https://sub.domain:1234"), new Uri("https://domain:5678")},
new object[] {new Uri("http://domain.tld"), new Uri("http://domain")},
new object[] {new Uri("http://sub.domain.tld"), new Uri("http://domain")},
new object[] {new Uri("/relativeUri", UriKind.Relative), new Uri("http://domain")},
new object[] {new Uri("http://sub.domain"), new Uri("/relative", UriKind.Relative)}
};
}
}
}
}

View File

@ -1,5 +1,8 @@
{
"version": "1.1.0-*",
"buildOptions": {
"keyFile": "../../tools/Key.snk"
},
"dependencies": {
"CorsMiddlewareWebSite": "1.0.0-*",
"dotnet-test-xunit": "2.2.0-*",

View File

@ -1,6 +1,7 @@
{
"buildOptions": {
"emitEntryPoint": true
"emitEntryPoint": true,
"keyFile": "../../../tools/Key.snk"
},
"dependencies": {
"Microsoft.AspNetCore.Cors": "1.2.0-*",