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));
+ }
+ }
+}