diff --git a/src/Microsoft.Identity.Web.UI/Areas/MicrosoftIdentity/Controllers/AccountController.cs b/src/Microsoft.Identity.Web.UI/Areas/MicrosoftIdentity/Controllers/AccountController.cs
index a11694c3f..303365c4f 100644
--- a/src/Microsoft.Identity.Web.UI/Areas/MicrosoftIdentity/Controllers/AccountController.cs
+++ b/src/Microsoft.Identity.Web.UI/Areas/MicrosoftIdentity/Controllers/AccountController.cs
@@ -53,7 +53,7 @@ public IActionResult SignIn(
{
scheme ??= OpenIdConnectDefaults.AuthenticationScheme;
string redirect;
- if (!string.IsNullOrEmpty(redirectUri) && Url.IsLocalUrl(redirectUri))
+ if (!string.IsNullOrEmpty(redirectUri) && Url.IsLocalUrl(redirectUri) && !IsPercentEncodedSlashBypass(redirectUri))
{
redirect = redirectUri;
}
@@ -254,6 +254,12 @@ private static bool IsSameOrigin(Uri absolute, HttpRequest request)
/// closes. Comparison is case-insensitive because the RFC 3986 encoding is
/// hex-case-insensitive.
///
+ ///
+ /// Canonical implementation: RedirectUriHelper.HasPercentEncodedSlashPrefix in
+ /// Microsoft.Identity.Web. This private copy exists because RedirectUriHelper
+ /// is internal and Microsoft.Identity.Web.UI is a separate assembly. Keep both
+ /// in sync.
+ ///
private static bool IsPercentEncodedSlashBypass(string path) =>
path.StartsWith("/%2f", StringComparison.OrdinalIgnoreCase)
|| path.StartsWith("/%5c", StringComparison.OrdinalIgnoreCase);
diff --git a/tests/Microsoft.Identity.Web.UI.Test/Areas/MicrosoftIdentity/Controllers/AccountControllerTests.cs b/tests/Microsoft.Identity.Web.UI.Test/Areas/MicrosoftIdentity/Controllers/AccountControllerTests.cs
index 7f8f1cb68..a7c30d4e0 100644
--- a/tests/Microsoft.Identity.Web.UI.Test/Areas/MicrosoftIdentity/Controllers/AccountControllerTests.cs
+++ b/tests/Microsoft.Identity.Web.UI.Test/Areas/MicrosoftIdentity/Controllers/AccountControllerTests.cs
@@ -439,5 +439,51 @@ public void Challenge_WithSameOriginAbsoluteButSlashBackslashPathAndQuery_UsesDe
var challengeResult = Assert.IsType(result);
Assert.Equal("/", challengeResult.Properties!.RedirectUri);
}
+
+ // -----------------------------------------------------------------------------
+ // SignIn action — percent-encoded slash bypass.
+ //
+ // A redirectUri of "/%2fevil.example" (the value the controller sees after ASP.NET
+ // Core has query-decoded the request once — the raw query string would be "%252f")
+ // passes Url.IsLocalUrl because the framework checks for literal "//" and "/\" only.
+ // A normalising reverse proxy (NGINX, IIS ARR, F5) that decodes percent-encoded
+ // path segments on the way out turns that into "//evil.example", which browsers
+ // treat as a protocol-relative URL. The IsPercentEncodedSlashBypass guard must
+ // block this on the SignIn path, matching the guard already on the Challenge action.
+ // -----------------------------------------------------------------------------
+
+ [Fact]
+ public void SignIn_WithPercentEncodedSlashRedirectUri_UsesDefaultRedirectUri()
+ {
+ // Arrange: use a realistic IsLocalUrl mock so that removing either the
+ // IsLocalUrl check or the IsPercentEncodedSlashBypass check would cause this
+ // test to fail.
+ UseRealisticIsLocalUrl();
+ string scheme = OpenIdConnectDefaults.AuthenticationScheme;
+
+ // "/%2fevil.example" — passes IsLocalUrl (starts with "/" and second char is "%"),
+ // but must be rejected by IsPercentEncodedSlashBypass.
+ // Act
+ var result = _accountController.SignIn(scheme, "/%2fevil.example");
+
+ // Assert: redirect must fall back to default, not to the attacker-controlled URI.
+ var challengeResult = Assert.IsType(result);
+ Assert.Equal("/", challengeResult.Properties!.RedirectUri);
+ }
+
+ [Fact]
+ public void SignIn_WithPercentEncodedBackslashRedirectUri_UsesDefaultRedirectUri()
+ {
+ // Arrange: "/%5cevil.example" — backslash variant of the same bypass.
+ UseRealisticIsLocalUrl();
+ string scheme = OpenIdConnectDefaults.AuthenticationScheme;
+
+ // Act
+ var result = _accountController.SignIn(scheme, "/%5cevil.example");
+
+ // Assert
+ var challengeResult = Assert.IsType(result);
+ Assert.Equal("/", challengeResult.Properties!.RedirectUri);
+ }
}
}