diff --git a/src/Microsoft.Identity.Web/AuthorizeForScopesAttribute.cs b/src/Microsoft.Identity.Web/AuthorizeForScopesAttribute.cs index e259536ee..e0eee331f 100644 --- a/src/Microsoft.Identity.Web/AuthorizeForScopesAttribute.cs +++ b/src/Microsoft.Identity.Web/AuthorizeForScopesAttribute.cs @@ -134,7 +134,8 @@ public override void OnException(ExceptionContext context) : httpRequest.Query[Constants.XReturnUrl]; UrlHelper urlHelper = new UrlHelper(context); - if (urlHelper.IsLocalUrl(redirectUri)) + if (urlHelper.IsLocalUrl(redirectUri) + && !RedirectUriHelper.HasPercentEncodedSlashPrefix(redirectUri!)) { properties.RedirectUri = redirectUri; } diff --git a/src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs b/src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs index 4dbb9cea8..5da71b6ec 100644 --- a/src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs +++ b/src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs @@ -154,22 +154,6 @@ private static void WarnIfAntiforgeryMissing(IServiceProvider? serviceProvider) internal static AuthenticationProperties GetAuthProperties(string? returnUrl) { const string pathBase = "/"; - return new AuthenticationProperties { RedirectUri = IsLocalUrl(returnUrl) ? returnUrl! : pathBase }; - } - - private static bool IsLocalUrl(string? url) - { - if (string.IsNullOrEmpty(url)) - { - return false; - } - - // "/foo" is local, but not "//foo" (protocol-relative) and not "/\foo" (slash-backslash). - if (url[0] == '/') - { - return url.Length == 1 || (url[1] != '/' && url[1] != '\\'); - } - - return false; + return new AuthenticationProperties { RedirectUri = RedirectUriHelper.IsLocalUrl(returnUrl) ? returnUrl! : pathBase }; } } diff --git a/src/Microsoft.Identity.Web/Internal/RedirectUriHelper.cs b/src/Microsoft.Identity.Web/Internal/RedirectUriHelper.cs new file mode 100644 index 000000000..5454e682c --- /dev/null +++ b/src/Microsoft.Identity.Web/Internal/RedirectUriHelper.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System; + +namespace Microsoft.Identity.Web; + +/// +/// Shared redirect-URI sanitization helpers for consistent local-URL validation +/// across login/logout endpoints and authorization attributes. +/// +internal static class RedirectUriHelper +{ + /// + /// Returns true when is a strictly local path + /// (starts with a single "/" that is not followed by another "/" or "\") + /// and does not begin with a percent-encoded slash or backslash sequence. + /// + internal static bool IsLocalUrl(string? url) + { + if (string.IsNullOrEmpty(url)) + { + return false; + } + + if (HasPercentEncodedSlashPrefix(url!)) + { + return false; + } + + // "/foo" is local, but not "//foo" (protocol-relative) and not "/\foo" (slash-backslash). + if (url![0] == '/') + { + return url.Length == 1 || (url[1] != '/' && url[1] != '\\'); + } + + return false; + } + + /// + /// Returns true when starts with a percent-encoded + /// forward slash (%2f) or backslash (%5c). + /// + internal static bool HasPercentEncodedSlashPrefix(string path) => + path.StartsWith("/%2f", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/%5c", StringComparison.OrdinalIgnoreCase); +} diff --git a/tests/Microsoft.Identity.Web.Test/Blazor/LoginLogoutEndpointRouteBuilderExtensionsTests.cs b/tests/Microsoft.Identity.Web.Test/Blazor/LoginLogoutEndpointRouteBuilderExtensionsTests.cs index 8fd88f47c..4ba514e1f 100644 --- a/tests/Microsoft.Identity.Web.Test/Blazor/LoginLogoutEndpointRouteBuilderExtensionsTests.cs +++ b/tests/Microsoft.Identity.Web.Test/Blazor/LoginLogoutEndpointRouteBuilderExtensionsTests.cs @@ -43,6 +43,13 @@ public class LoginLogoutEndpointRouteBuilderExtensionsTests // Bare hostnames / non-slash-prefixed — blocked. [InlineData("evil.example", "/")] [InlineData("home", "/")] + // Percent-encoded slash/backslash — blocked (reverse proxies may decode these). + [InlineData("/%2Fsome.example", "/")] + [InlineData("/%2fsome.example", "/")] + [InlineData("/%5Csome.example", "/")] + [InlineData("/%5csome.example", "/")] + [InlineData("/%2f%2fsome.example/x", "/")] + [InlineData("/%2F%5Csome.example", "/")] public void GetAuthProperties_CoercesNonLocalReturnUrls(string? input, string expected) { var props = LoginLogoutEndpointRouteBuilderExtensions.GetAuthProperties(input); diff --git a/tests/Microsoft.Identity.Web.Test/RedirectUriHelperTests.cs b/tests/Microsoft.Identity.Web.Test/RedirectUriHelperTests.cs new file mode 100644 index 000000000..4fbd276bd --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/RedirectUriHelperTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + public class RedirectUriHelperTests + { + [Theory] + // Local paths — accepted. + [InlineData("/", true)] + [InlineData("/home", true)] + [InlineData("/home?query=1", true)] + [InlineData("/a/b/c", true)] + // Null/empty — rejected. + [InlineData(null, false)] + [InlineData("", false)] + // Protocol-relative — rejected. + [InlineData("//some.example", false)] + [InlineData("//some.example/path", false)] + // Slash-backslash — rejected. + [InlineData("/\\some.example", false)] + [InlineData("/\\\\some.example", false)] + // Absolute URLs — rejected. + [InlineData("https://some.example/", false)] + [InlineData("http://some.example/", false)] + [InlineData("javascript:alert(1)", false)] + // Bare hostnames / non-slash-prefixed — rejected. + [InlineData("some.example", false)] + [InlineData("home", false)] + // Percent-encoded slash/backslash — rejected (reverse proxies may decode these). + [InlineData("/%2Fsome.example", false)] + [InlineData("/%2fsome.example", false)] + [InlineData("/%5Csome.example", false)] + [InlineData("/%5csome.example", false)] + [InlineData("/%2f%2fsome.example/x", false)] + [InlineData("/%2F%5Csome.example", false)] + public void IsLocalUrl_ValidatesCorrectly(string? input, bool expected) + { + Assert.Equal(expected, RedirectUriHelper.IsLocalUrl(input)); + } + + [Theory] + [InlineData("/%2Fsome.example", true)] + [InlineData("/%2fsome.example", true)] + [InlineData("/%5Csome.example", true)] + [InlineData("/%5csome.example", true)] + [InlineData("/%2f%2fsome.example/x", true)] + [InlineData("/%2F%5Csome.example", true)] + [InlineData("/home", false)] + [InlineData("/a/b/c", false)] + [InlineData("/", false)] + public void HasPercentEncodedSlashPrefix_DetectsEncodedSlashes(string input, bool expected) + { + Assert.Equal(expected, RedirectUriHelper.HasPercentEncodedSlashPrefix(input)); + } + } +}