Skip to content

Commit 52a97fe

Browse files
jmprieurCopilot
authored andcommitted
Fix MSRC c805e950: challenge open-redirect + logout CSRF validation
F1 (Microsoft.Identity.Web.UI / AccountController.Challenge): - Reject redirectUri values that pass Url.IsLocalUrl() but decode to a protocol-relative form (leading %2F%2F...), which Cookie auth would emit verbatim in Location and some clients decode to //host before following. New IsPercentEncodedSlashBypass helper runs after both raw input and Uri-coerced candidate. F3 (Microsoft.Identity.Web / MapLoginAndLogout): - Remove .DisableAntiforgery() from /logout. - Add .RequireAuthorization() as primary CSRF gate. - Validate IAntiforgery.IsRequestValidAsync(context) in the handler when IAntiforgery is registered, independent of whether UseAntiforgery() middleware is in the pipeline. Makes /logout pipeline-shape-agnostic (Blazor, MVC, minimal-API hosts all get equivalent protection). - When IAntiforgery is not registered, log a one-time warning at MapLoginAndLogout time (EventId=1, AntiforgeryNotRegistered) so operators can see the graceful-degradation state; RequireAuthorization + SameSite=Lax cookies remain as the gate. Tests: - F1: 44/44 on net8/net9 (16 unit + 28 integration). - F3: 22/22 on net8/net9/net10 (16 unit + 6 integration including MVC-shape graceful-degradation coverage). Reviewed independently by two models (both passes sign-off). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f09486c commit 52a97fe

12 files changed

Lines changed: 1191 additions & 22 deletions

File tree

changelog.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,25 @@
88
- Fix race condition in `MergedOptions` causing sporadic "No ClientId was specified" errors under concurrent `GraphServiceClient` usage. See [#3760](https://github.com/AzureAD/microsoft-identity-web/pull/3760).
99
- Fix `CredentialsProvider` DI lifetime mismatch causing startup crash in Development mode when using `AddMicrosoftIdentityWebApi()`. See [#3783](https://github.com/AzureAD/microsoft-identity-web/pull/3783).
1010

11+
### Behavior changes
12+
- **`/MicrosoftIdentity/Account/Challenge` — redirect URI validation.** The `redirectUri` query-string parameter is now validated. Accepted values:
13+
- Local paths (e.g. `/home`, `/counter?tab=1`) — unchanged behavior.
14+
- Same-origin absolute URLs (matching scheme, host, and effective port of the current request). These are coerced to their path-and-query before being stored in `AuthenticationProperties.RedirectUri`. This preserves the canonical `[AuthorizeForScopes]` / `MsalUiRequiredException` step-up consent flow, which goes through `MicrosoftIdentityConsentAndConditionalAccessHandler.ChallengeUser()` and passes `NavigationManager.Uri` (always absolute) for Blazor Server, or an absolute request URL for Razor Pages / MVC.
15+
- Any other value (external host, different scheme, different port, protocol-relative `//host`, empty, or `null`) falls back to `~/`.
16+
- **UX note:** URL fragments (`#section`) are dropped when a same-origin absolute URL is coerced. If a Blazor Server page depends on a fragment being preserved across step-up consent, pass a relative path explicitly rather than relying on `NavigationManager.Uri`.
17+
- **Reverse-proxy deployments:** apps behind a reverse proxy (Azure App Service, Container Apps, AKS ingress, nginx, etc.) should configure `app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost })` before `UseAuthentication()`. Without it, `Request.Scheme` / `Request.Host` reflect the internal container/pod hostname, the same-origin check fails for the external `NavigationManager.Uri`, and step-up lands the user on `/` rather than the original page.
18+
19+
- **Blazor `MapGroup(...).MapLoginAndLogout()``/logout` endpoint.** The generated `POST /logout` endpoint now (a) requires authentication (`RequireAuthorization()`) and (b) requires an antiforgery token (the previous `DisableAntiforgery()` opt-out has been removed). UX and integration implications:
20+
- Forms rendered by `SignOutLink` / `LogInOrOut` already include the antiforgery token and continue to work without code changes.
21+
- Custom clients that `fetch`/`XMLHttpRequest` to `/logout` must now include the antiforgery token. Obtain it via `IAntiforgery.GetAndStoreTokens(context).RequestToken` and send it in the request header configured by `AddAntiforgery(options => options.HeaderName = "...")` (default `RequestVerificationToken`) or as the configured form field.
22+
- The `ReturnUrl` form value is now treated as strictly local: any non-local value (absolute URL, protocol-relative, `/\host`, etc.) is coerced to `/`. Apps that previously passed an absolute URL should switch to a relative path.
23+
- **Edge case:** a user whose authentication cookie has already expired and who then clicks the logout button will be redirected to the login page (because of `RequireAuthorization()`) rather than seeing a silent no-op. This is a minor change from previous behavior; the happy path (authenticated user clicking logout) is unchanged.
24+
- **Blazor WebAssembly clients are unaffected.** WASM apps sign out through `Microsoft.Authentication.WebAssembly.Msal` / the `/authentication/logout` JS interop path, not by POSTing to the server-side `/logout` endpoint, so no client code changes are needed.
25+
- **Server-side Blazor using `AuthenticationStateProvider` / `SignOutAsync` is unaffected.** The new gate only applies to direct HTTP POSTs to `/logout`. Components that call `AuthenticationStateProvider.GetAuthenticationStateAsync()` or sign out through the scheme handler continue to work unchanged.
26+
- **Graceful degradation when antiforgery is not configured.** The check is performed inside the endpoint handler by explicitly resolving `IAntiforgery` from request services (not via endpoint metadata / middleware coupling). This means the `/logout` endpoint works correctly on every pipeline shape: (a) minimal API with both `AddAntiforgery()` and `UseAntiforgery()` wired — token is validated by middleware and re-checked by the handler (idempotent); (b) MVC / Razor Pages hosts that call `AddControllersWithViews()` or `AddRazorPages()` (which transitively register `IAntiforgery`) but do not call `UseAntiforgery()` — the handler validates the token directly; (c) hosts that reuse `MapLoginAndLogout` without any antiforgery configuration — the handler skips validation and `RequireAuthorization()` + cookie `SameSite=Lax` remain the CSRF gate, matching pre-4.8.0 behavior. For scenario (c), a single warning is logged at endpoint map time recommending that `AddAntiforgery()` be configured.
27+
28+
- **`/MicrosoftIdentity/Account/Challenge``%2f` / `%5c` defense-in-depth.** In addition to the path-and-query re-check for protocol-relative shapes (`//host`, `/\host`), `redirectUri` values whose path begins with `/%2f`, `/%5c`, `/%2F`, or `/%5C` are now rejected and coerced to `~/`. Browsers per RFC 3986 treat these as literal path characters (a direct hit yields a 404), so this change does not affect legitimate deep-links. It guards against misconfigured reverse proxies (NGINX, IIS ARR, F5) that can decode `%2f``/` while rewriting `Location` headers, which would otherwise reopen the protocol-relative bypass after the proxy pass.
29+
1130
### Dependencies updates
1231
- Upgrade Microsoft Application Insights packages. See [#3763](https://github.com/AzureAD/microsoft-identity-web/pull/3763).
1332
- Bump net8/net9/net10 runtime package baselines to patched crypto servicing versions. See [#3779](https://github.com/AzureAD/microsoft-identity-web/pull/3779).

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

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
using System;
45
using System.Collections.Generic;
56
using System.Threading.Tasks;
67
using Microsoft.AspNetCore.Authentication;
78
using Microsoft.AspNetCore.Authentication.Cookies;
89
using Microsoft.AspNetCore.Authentication.OAuth;
910
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
1011
using Microsoft.AspNetCore.Authorization;
12+
using Microsoft.AspNetCore.Http;
1113
using Microsoft.AspNetCore.Mvc;
1214
using Microsoft.Extensions.Options;
1315

@@ -112,7 +114,42 @@ public IActionResult Challenge(
112114
{
113115
oAuthChallengeProperties.Scope = scope.Split(" ");
114116
}
115-
oAuthChallengeProperties.RedirectUri = redirectUri;
117+
118+
// Validate the redirect URI. Accept:
119+
// * Local URLs (e.g. "/path") — the common MVC pattern.
120+
// * Same-origin absolute URLs (coerced to PathAndQuery) — required because
121+
// Microsoft.Identity.Web's own MicrosoftIdentityConsentAndConditionalAccessHandler
122+
// passes NavigationManager.Uri (always absolute) for Blazor Server step-up
123+
// consent and for Razor Pages / MVC step-up. Rejecting those would break the
124+
// canonical [AuthorizeForScopes] / MsalUiRequiredException flow.
125+
// Reject everything else. The post-sign-in 302 honors AuthenticationProperties.RedirectUri
126+
// as-is (CookieAuthenticationHandler does not enforce IsLocalUrl), so the check must
127+
// happen here. This closes the open-redirect class of bug matching the SignIn action's
128+
// own IsLocalUrl gate added in PR #1219.
129+
string? safeRedirect = null;
130+
if (!string.IsNullOrEmpty(redirectUri))
131+
{
132+
if (Url.IsLocalUrl(redirectUri) && !IsPercentEncodedSlashBypass(redirectUri))
133+
{
134+
safeRedirect = redirectUri;
135+
}
136+
else if (Uri.TryCreate(redirectUri, UriKind.Absolute, out var absolute)
137+
&& IsSameOrigin(absolute, HttpContext.Request))
138+
{
139+
// PathAndQuery of a same-origin absolute URL can still begin with "//" or "/\"
140+
// for inputs like "http://victim.app//evil.com/x" (Uri.Host="victim.app",
141+
// PathAndQuery="//evil.com/x") — a protocol-relative URL that CookieAuthenticationHandler
142+
// would emit verbatim in its Location header. Re-run IsLocalUrl on the coerced value
143+
// to reject those shapes.
144+
var candidate = absolute.PathAndQuery;
145+
if (Url.IsLocalUrl(candidate) && !IsPercentEncodedSlashBypass(candidate))
146+
{
147+
safeRedirect = candidate;
148+
}
149+
}
150+
}
151+
152+
oAuthChallengeProperties.RedirectUri = safeRedirect ?? Url.Content("~/")!;
116153

117154
return Challenge(
118155
oAuthChallengeProperties,
@@ -186,5 +223,39 @@ public async Task<IActionResult> EditProfile([FromRoute] string scheme)
186223
properties.Items[Constants.Policy] = _optionsMonitor.Get(scheme).EditProfilePolicyId;
187224
return Challenge(properties, scheme);
188225
}
226+
227+
/// <summary>
228+
/// Returns <c>true</c> when <paramref name="absolute"/> has the same origin (scheme + host + port)
229+
/// as <paramref name="request"/>. Used by <c>Challenge</c> to accept same-origin absolute redirect URIs
230+
/// without opening an open-redirect sink.
231+
/// </summary>
232+
private static bool IsSameOrigin(Uri absolute, HttpRequest request)
233+
{
234+
if (!string.Equals(absolute.Scheme, request.Scheme, StringComparison.OrdinalIgnoreCase))
235+
{
236+
return false;
237+
}
238+
239+
if (!string.Equals(absolute.Host, request.Host.Host, StringComparison.OrdinalIgnoreCase))
240+
{
241+
return false;
242+
}
243+
244+
int requestPort = request.Host.Port ?? (request.IsHttps ? 443 : 80);
245+
return absolute.Port == requestPort;
246+
}
247+
248+
/// <summary>
249+
/// Defense-in-depth: reject paths whose first segment starts with a percent-encoded
250+
/// forward or backward slash (<c>%2f</c>/<c>%5c</c>). Browsers per RFC 3986 treat these
251+
/// as literal path characters, but misconfigured reverse proxies (NGINX, IIS ARR, F5)
252+
/// can decode them into <c>//</c> or <c>/\</c> when rewriting the <c>Location</c>
253+
/// header, reopening the protocol-relative bypass that this controller otherwise
254+
/// closes. Comparison is case-insensitive because the RFC 3986 encoding is
255+
/// hex-case-insensitive.
256+
/// </summary>
257+
private static bool IsPercentEncodedSlashBypass(string path) =>
258+
path.StartsWith("/%2f", StringComparison.OrdinalIgnoreCase)
259+
|| path.StartsWith("/%5c", StringComparison.OrdinalIgnoreCase);
189260
}
190261
}

src/Microsoft.Identity.Web/Blazor/LoginLogoutEndpointRouteBuilderExtensions.cs

Lines changed: 75 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
using System;
55
using System.Diagnostics.CodeAnalysis;
66
using System.Threading.Tasks;
7+
using Microsoft.AspNetCore.Antiforgery;
78
using Microsoft.AspNetCore.Authentication;
89
using Microsoft.AspNetCore.Authentication.Cookies;
910
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
1011
using Microsoft.AspNetCore.Builder;
1112
using Microsoft.AspNetCore.Http;
1213
using Microsoft.AspNetCore.Routing;
14+
using Microsoft.Extensions.DependencyInjection;
15+
using Microsoft.Extensions.Logging;
1316
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
1417

1518
namespace Microsoft.Identity.Web;
@@ -40,6 +43,8 @@ public static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBu
4043
{
4144
var group = endpoints.MapGroup("");
4245

46+
WarnIfAntiforgeryMissing(endpoints.ServiceProvider);
47+
4348
// Enhanced login endpoint that supports incremental consent and Conditional Access
4449
group.MapGet("/login", (
4550
string? returnUrl,
@@ -81,28 +86,90 @@ public static IEndpointConventionBuilder MapLoginAndLogout(this IEndpointRouteBu
8186

8287
group.MapPost("/logout", async (HttpContext context) =>
8388
{
89+
// Defense-in-depth CSRF validation (MSRC hardening). When the host has registered
90+
// IAntiforgery via AddAntiforgery() (or indirectly via AddControllersWithViews,
91+
// AddRazorPages, AddMvc, etc.), we explicitly validate the request token here —
92+
// independently of whether UseAntiforgery() middleware is in the pipeline. This
93+
// avoids coupling our endpoint to pipeline shape: MVC hosts that rely on filter-
94+
// time validation, minimal-API hosts that wire UseAntiforgery(), and Blazor hosts
95+
// that wire both all receive equivalent protection at this endpoint. Hosts that
96+
// do not register IAntiforgery at all fall back to RequireAuthorization() +
97+
// SameSite=Lax cookie semantics as the primary CSRF gate — matching pre-MSRC
98+
// behavior and logged once at map time (see WarnIfAntiforgeryMissing).
99+
// IsRequestValidAsync is safe to call after UseAntiforgery() middleware has already
100+
// validated: the form is buffered (ReadFormAsync is cached on HttpRequest), tokens
101+
// are not single-use in the default flow, and re-validation is a cheap hash verify —
102+
// not a no-op, but inexpensive.
103+
var antiforgery = context.RequestServices.GetService<IAntiforgery>();
104+
if (antiforgery is not null && !await antiforgery.IsRequestValidAsync(context))
105+
{
106+
return Results.BadRequest();
107+
}
108+
84109
string? returnUrl = null;
85110
if (context.Request.HasFormContentType)
86111
{
87112
var form = await context.Request.ReadFormAsync();
88113
returnUrl = form["ReturnUrl"];
89114
}
90115

91-
return TypedResults.SignOut(GetAuthProperties(returnUrl),
116+
return (IResult)TypedResults.SignOut(GetAuthProperties(returnUrl),
92117
[CookieAuthenticationDefaults.AuthenticationScheme, OpenIdConnectDefaults.AuthenticationScheme]);
93118
})
94-
.DisableAntiforgery();
119+
.RequireAuthorization();
95120

96121
return group;
97122
}
98123

99-
private static AuthenticationProperties GetAuthProperties(string? returnUrl)
124+
// Emits a single warning at endpoint-build time when IAntiforgery isn't registered in DI.
125+
// This surfaces the graceful-degradation state to operators so it's not silently invisible
126+
// that CSRF protection at /logout relies solely on RequireAuthorization + SameSite=Lax
127+
// (rather than token validation). Called once per MapLoginAndLogout invocation.
128+
private static void WarnIfAntiforgeryMissing(IServiceProvider? serviceProvider)
129+
{
130+
var isService = serviceProvider?.GetService<IServiceProviderIsService>();
131+
var antiforgeryRegistered = isService?.IsService(typeof(IAntiforgery)) ?? false;
132+
if (antiforgeryRegistered)
133+
{
134+
return;
135+
}
136+
137+
var loggerFactory = serviceProvider?.GetService<ILoggerFactory>();
138+
var logger = loggerFactory?.CreateLogger(typeof(LoginLogoutEndpointRouteBuilderExtensions).FullName!);
139+
logger?.LogWarning(
140+
new EventId(1, "AntiforgeryNotRegistered"),
141+
"MapLoginAndLogout was called but IAntiforgery is not registered in DI. The /logout " +
142+
"endpoint will rely on RequireAuthorization and SameSite=Lax cookies as its CSRF gate. " +
143+
"To enable antiforgery token validation, call services.AddAntiforgery() (and, for " +
144+
"minimal APIs, app.UseAntiforgery()).");
145+
}
146+
147+
/// <summary>
148+
/// Builds <see cref="AuthenticationProperties"/> with a strictly-local <c>RedirectUri</c>.
149+
/// Any non-local input (absolute URL, protocol-relative "//host", slash-backslash "/\host",
150+
/// or anything not starting with a single '/') is coerced to "/". This matches the
151+
/// semantics of <see cref="Microsoft.AspNetCore.Mvc.IUrlHelper.IsLocalUrl"/> and prevents
152+
/// open-redirect attacks via the ReturnUrl query/form parameter.
153+
/// </summary>
154+
internal static AuthenticationProperties GetAuthProperties(string? returnUrl)
100155
{
101156
const string pathBase = "/";
102-
if (string.IsNullOrEmpty(returnUrl)) returnUrl = pathBase;
103-
else if (returnUrl.StartsWith("//", StringComparison.Ordinal)) returnUrl = pathBase; // Prevent protocol-relative redirects
104-
else if (!Uri.IsWellFormedUriString(returnUrl, UriKind.Relative)) returnUrl = new Uri(returnUrl, UriKind.Absolute).PathAndQuery;
105-
else if (returnUrl[0] != '/') returnUrl = $"{pathBase}{returnUrl}";
106-
return new AuthenticationProperties { RedirectUri = returnUrl };
157+
return new AuthenticationProperties { RedirectUri = IsLocalUrl(returnUrl) ? returnUrl! : pathBase };
158+
}
159+
160+
private static bool IsLocalUrl(string? url)
161+
{
162+
if (string.IsNullOrEmpty(url))
163+
{
164+
return false;
165+
}
166+
167+
// "/foo" is local, but not "//foo" (protocol-relative) and not "/\foo" (slash-backslash).
168+
if (url[0] == '/')
169+
{
170+
return url.Length == 1 || (url[1] != '/' && url[1] != '\\');
171+
}
172+
173+
return false;
107174
}
108175
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.GetAuthProperties(string? returnUrl) -> Microsoft.AspNetCore.Authentication.AuthenticationProperties!
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.GetAuthProperties(string? returnUrl) -> Microsoft.AspNetCore.Authentication.AuthenticationProperties!
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.LoginLogoutEndpointRouteBuilderExtensions.GetAuthProperties(string? returnUrl) -> Microsoft.AspNetCore.Authentication.AuthenticationProperties!

0 commit comments

Comments
 (0)