Skip to content

Commit 45efdba

Browse files
iNinjaCopilot
andauthored
Apply consistent redirect-URI validation on AccountController.SignIn (#3940)
SignIn was missing the IsPercentEncodedSlashBypass guard that Challenge already had. A redirectUri like /%2fevil.example passes Url.IsLocalUrl (the framework only checks for literal // and /\), but a normalising reverse proxy decodes it to //evil.example; browsers then follow it as a protocol-relative URL after sign-in. Add !IsPercentEncodedSlashBypass(redirectUri) to the SignIn guard, matching the guard on Challenge. Also update the IsPercentEncodedSlashBypass XML doc to note it is a local copy of RedirectUriHelper.HasPercentEncodedSlashPrefix (internal in a separate assembly) so future changes keep both in sync. Tests: two new facts in AccountControllerTests covering the %2f and %5c variants on the SignIn path, using the realistic IsLocalUrl mock so removing either guard breaks the tests. Copilot-Session: 00c4f24d-188f-4bd9-926c-a6d0620eb053 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f5e30e9 commit 45efdba

2 files changed

Lines changed: 53 additions & 1 deletion

File tree

src/Microsoft.Identity.Web.UI/Areas/MicrosoftIdentity/Controllers/AccountController.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ public IActionResult SignIn(
5353
{
5454
scheme ??= OpenIdConnectDefaults.AuthenticationScheme;
5555
string redirect;
56-
if (!string.IsNullOrEmpty(redirectUri) && Url.IsLocalUrl(redirectUri))
56+
if (!string.IsNullOrEmpty(redirectUri) && Url.IsLocalUrl(redirectUri) && !IsPercentEncodedSlashBypass(redirectUri))
5757
{
5858
redirect = redirectUri;
5959
}
@@ -254,6 +254,12 @@ private static bool IsSameOrigin(Uri absolute, HttpRequest request)
254254
/// closes. Comparison is case-insensitive because the RFC 3986 encoding is
255255
/// hex-case-insensitive.
256256
/// </summary>
257+
/// <remarks>
258+
/// Canonical implementation: <c>RedirectUriHelper.HasPercentEncodedSlashPrefix</c> in
259+
/// <c>Microsoft.Identity.Web</c>. This private copy exists because <c>RedirectUriHelper</c>
260+
/// is internal and <c>Microsoft.Identity.Web.UI</c> is a separate assembly. Keep both
261+
/// in sync.
262+
/// </remarks>
257263
private static bool IsPercentEncodedSlashBypass(string path) =>
258264
path.StartsWith("/%2f", StringComparison.OrdinalIgnoreCase)
259265
|| path.StartsWith("/%5c", StringComparison.OrdinalIgnoreCase);

tests/Microsoft.Identity.Web.UI.Test/Areas/MicrosoftIdentity/Controllers/AccountControllerTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,5 +439,51 @@ public void Challenge_WithSameOriginAbsoluteButSlashBackslashPathAndQuery_UsesDe
439439
var challengeResult = Assert.IsType<ChallengeResult>(result);
440440
Assert.Equal("/", challengeResult.Properties!.RedirectUri);
441441
}
442+
443+
// -----------------------------------------------------------------------------
444+
// SignIn action — percent-encoded slash bypass.
445+
//
446+
// A redirectUri of "/%2fevil.example" (the value the controller sees after ASP.NET
447+
// Core has query-decoded the request once — the raw query string would be "%252f")
448+
// passes Url.IsLocalUrl because the framework checks for literal "//" and "/\" only.
449+
// A normalising reverse proxy (NGINX, IIS ARR, F5) that decodes percent-encoded
450+
// path segments on the way out turns that into "//evil.example", which browsers
451+
// treat as a protocol-relative URL. The IsPercentEncodedSlashBypass guard must
452+
// block this on the SignIn path, matching the guard already on the Challenge action.
453+
// -----------------------------------------------------------------------------
454+
455+
[Fact]
456+
public void SignIn_WithPercentEncodedSlashRedirectUri_UsesDefaultRedirectUri()
457+
{
458+
// Arrange: use a realistic IsLocalUrl mock so that removing either the
459+
// IsLocalUrl check or the IsPercentEncodedSlashBypass check would cause this
460+
// test to fail.
461+
UseRealisticIsLocalUrl();
462+
string scheme = OpenIdConnectDefaults.AuthenticationScheme;
463+
464+
// "/%2fevil.example" — passes IsLocalUrl (starts with "/" and second char is "%"),
465+
// but must be rejected by IsPercentEncodedSlashBypass.
466+
// Act
467+
var result = _accountController.SignIn(scheme, "/%2fevil.example");
468+
469+
// Assert: redirect must fall back to default, not to the attacker-controlled URI.
470+
var challengeResult = Assert.IsType<ChallengeResult>(result);
471+
Assert.Equal("/", challengeResult.Properties!.RedirectUri);
472+
}
473+
474+
[Fact]
475+
public void SignIn_WithPercentEncodedBackslashRedirectUri_UsesDefaultRedirectUri()
476+
{
477+
// Arrange: "/%5cevil.example" — backslash variant of the same bypass.
478+
UseRealisticIsLocalUrl();
479+
string scheme = OpenIdConnectDefaults.AuthenticationScheme;
480+
481+
// Act
482+
var result = _accountController.SignIn(scheme, "/%5cevil.example");
483+
484+
// Assert
485+
var challengeResult = Assert.IsType<ChallengeResult>(result);
486+
Assert.Equal("/", challengeResult.Properties!.RedirectUri);
487+
}
442488
}
443489
}

0 commit comments

Comments
 (0)