From 6f76189846214f7a5e863e19c29c8acfb79bd5fa Mon Sep 17 00:00:00 2001 From: Pranav K Date: Tue, 2 Oct 2018 11:28:05 -0700 Subject: [PATCH] Normalize internationalized domain names when adding to CORS Fixes https://github.com/aspnet/Home/issues/3353 --- .../Infrastructure/CorsPolicyBuilder.cs | 44 +++++++++- .../CorsPolicyBuilderTests.cs | 84 ++++++++++++++++++- 2 files changed, 124 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.AspNetCore.Cors/Infrastructure/CorsPolicyBuilder.cs b/src/Microsoft.AspNetCore.Cors/Infrastructure/CorsPolicyBuilder.cs index 3953296168..145571ab60 100644 --- a/src/Microsoft.AspNetCore.Cors/Infrastructure/CorsPolicyBuilder.cs +++ b/src/Microsoft.AspNetCore.Cors/Infrastructure/CorsPolicyBuilder.cs @@ -17,6 +17,7 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure /// Creates a new instance of the . /// /// list of origins which can be added. + /// for details on normalizing the origin value. public CorsPolicyBuilder(params string[] origins) { WithOrigins(origins); @@ -36,16 +37,55 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure /// /// The origins that are allowed. /// The current policy builder. + /// + /// This method normalizes the origin value prior to adding it to to match + /// the normalization performed by the browser on the value sent in the ORIGIN header. + /// + /// + /// If the specified origin has an internationalized domain name (IDN), the punycoded value is used. If the origin + /// specifies a default port (e.g. 443 for HTTPS or 80 for HTTP), this will be dropped as part of normalization. + /// Finally, the scheme and punycoded host name are culture invariant lower cased before being added to the + /// collection. + /// + /// + /// For all other origins, normalization involves performing a culture invariant lower casing of the host name. + /// + /// + /// public CorsPolicyBuilder WithOrigins(params string[] origins) { foreach (var origin in origins) { - _policy.Origins.Add(origin.ToLowerInvariant()); + var normalizedOrigin = GetNormalizedOrigin(origin); + _policy.Origins.Add(normalizedOrigin); } return this; } + internal static string GetNormalizedOrigin(string origin) + { + if (Uri.TryCreate(origin, UriKind.Absolute, out var uri) && + (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) && + !string.Equals(uri.IdnHost, uri.Host, StringComparison.Ordinal)) + { + var builder = new UriBuilder(uri.Scheme.ToLowerInvariant(), uri.IdnHost.ToLowerInvariant()); + if (!uri.IsDefaultPort) + { + // Uri does not have a way to differentiate between a port value inferred by default (e.g. Port = 80 for http://www.example.com) and + // a default port value that is specified (e.g. Port = 80 for http://www.example.com:80). Although the HTTP or FETCH spec does not say + // anything about including the default port as part of the Origin header, at the time of writing, browsers drop "default" port when navigating + // and when sending the Origin header. All this goes to say, it appears OK to drop an explicitly specified port, + // if it is the default port when working with an IDN host. + builder.Port = uri.Port; + } + + return builder.Uri.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped); + } + + return origin.ToLowerInvariant(); + } + /// /// Adds the specified to the policy. /// @@ -222,4 +262,4 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure return this; } } -} \ No newline at end of file +} diff --git a/test/Microsoft.AspNetCore.Cors.Test/CorsPolicyBuilderTests.cs b/test/Microsoft.AspNetCore.Cors.Test/CorsPolicyBuilderTests.cs index c9b3afca84..6aa6cd0cd7 100644 --- a/test/Microsoft.AspNetCore.Cors.Test/CorsPolicyBuilderTests.cs +++ b/test/Microsoft.AspNetCore.Cors.Test/CorsPolicyBuilderTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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; @@ -299,5 +299,85 @@ namespace Microsoft.AspNetCore.Cors.Infrastructure var corsPolicy = builder.Build(); Assert.False(corsPolicy.SupportsCredentials); } + + [Theory] + [InlineData("Some-String", "some-string")] + [InlineData("x:\\Test", "x:\\test")] + [InlineData("FTP://Some-url", "ftp://some-url")] + public void GetNormalizedOrigin_ReturnsLowerCasedValue_IfStringIsNotHttpOrHttpsUrl(string origin, string expected) + { + // Act + var normalizedOrigin = CorsPolicyBuilder.GetNormalizedOrigin(origin); + + // Assert + Assert.Equal(expected, normalizedOrigin); + } + + [Fact] + public void GetNormalizedOrigin_DoesNotAddPort_IfUriDoesNotSpecifyOne() + { + // Arrange + var origin = "http://www.example.com"; + + // Act + var normalizedOrigin = CorsPolicyBuilder.GetNormalizedOrigin(origin); + + // Assert + Assert.Equal(origin, normalizedOrigin); + } + + [Fact] + public void GetNormalizedOrigin_LowerCasesScheme() + { + // Arrange + var origin = "HTTP://www.example.com"; + + // Act + var normalizedOrigin = CorsPolicyBuilder.GetNormalizedOrigin(origin); + + // Assert + Assert.Equal("http://www.example.com", normalizedOrigin); + } + + [Fact] + public void GetNormalizedOrigin_LowerCasesHost() + { + // Arrange + var origin = "http://www.Example.Com"; + + // Act + var normalizedOrigin = CorsPolicyBuilder.GetNormalizedOrigin(origin); + + // Assert + Assert.Equal("http://www.example.com", normalizedOrigin); + } + + [Theory] + [InlineData("http://www.Example.com:80", "http://www.example.com:80")] + [InlineData("https://www.Example.com:8080", "https://www.example.com:8080")] + public void GetNormalizedOrigin_PreservesPort_ForNonIdnHosts(string origin, string expected) + { + // Act + var normalizedOrigin = CorsPolicyBuilder.GetNormalizedOrigin(origin); + + // Assert + Assert.Equal(expected, normalizedOrigin); + } + + [Theory] + [InlineData("http://Bücher.example", "http://xn--bcher-kva.example")] + [InlineData("http://Bücher.example.com:83", "http://xn--bcher-kva.example.com:83")] + [InlineData("https://example.қаз", "https://example.xn--80ao21a")] + [InlineData("http://😉.fm", "http://xn--n28h.fm")] + // Note that in following case, the default port (443 for HTTPS) is not preserved. + [InlineData("https://www.example.இந்தியா:443", "https://www.example.xn--xkc2dl3a5ee0h")] + public void GetNormalizedOrigin_ReturnsPunyCodedOrigin(string origin, string expected) + { + // Act + var normalizedOrigin = CorsPolicyBuilder.GetNormalizedOrigin(origin); + + // Assert + Assert.Equal(expected, normalizedOrigin); + } } -} \ No newline at end of file +}