Check Url.IsLocalUrl(returnUrl) and redirect with LocalRedirect(returnUrl): IsLocalUrl accepts "/" or "~/" paths not followed by "/" or "\" and free of control characters. Follow an absolute URL only when Uri.TryCreate gives scheme https, an allowlisted host, the default port and no user info; otherwise LocalRedirect("~/account"). Never trust StartsWith('/').
- Find every redirect built from request data: Redirect(returnUrl), RedirectPermanent, Response.Redirect, Results.Redirect, Location header writes, and return URLs kept in cookies or hidden fields.
- In MVC controllers and Razor Pages, check Url.IsLocalUrl(returnUrl) and redirect with LocalRedirect(returnUrl). In minimal APIs on .NET 10, use RedirectHttpResult.IsLocalUrl and Results.LocalRedirect.
- Fall back to a fixed local page with LocalRedirect("~/account") rather than letting LocalRedirect throw InvalidOperationException, which turns a bad link into a 500.
- For the few external destinations you support, parse with Uri.TryCreate(returnUrl, UriKind.Absolute, out var url) and require Scheme == Uri.UriSchemeHttps, IdnHost in an exact allowlist, IsDefaultPort and an empty UserInfo; redirect to url.AbsoluteUri.
- Keep [ValidateAntiForgeryToken] (or the AutoValidateAntiforgeryTokenAttribute filter) on the sign-in POST; the form carries the token and returnUrl in hidden fields.
- Add regression tests that sign in through WebApplicationFactory with AllowAutoRedirect = false, read the raw Location header (Headers.NonValidated) and resolve it the way a browser does (the BrowserUrl helper in the tests).
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
protected override IActionResult RedirectAfterSignIn(string? returnUrl) =>
Redirect(string.IsNullOrEmpty(returnUrl) ? "~/account" : returnUrl);With IMPL=vulnerable, 9 of 16 tests fail (9 exploit tests).
The fix
private static readonly string[] AllowedHosts = ["app.example.test", "docs.example.test"];
protected override IActionResult RedirectAfterSignIn(string? returnUrl)
{
// A path on this site: "/" or "~/" not followed by "/" or "\", and no control characters.
if (Url.IsLocalUrl(returnUrl))
{
return LocalRedirect(returnUrl);
}
// An https URL on an allowlisted host, with the default port and no user name or password.
if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var url)
&& url.Scheme == Uri.UriSchemeHttps
&& AllowedHosts.Contains(url.IdnHost, StringComparer.OrdinalIgnoreCase)
&& url.IsDefaultPort
&& url.UserInfo.Length == 0)
{
return Redirect(url.AbsoluteUri); // the URL that was checked, not the raw input
}
return LocalRedirect("~/account");
}With IMPL=fixed, 16 of 16 tests pass.
A fix that looks safe but is not
protected override IActionResult RedirectAfterSignIn(string? returnUrl)
{
if (string.IsNullOrEmpty(returnUrl))
{
return LocalRedirect("~/account");
}
// A path on this site...
if (returnUrl.StartsWith('/'))
{
return Redirect(returnUrl);
}
// ...or a full link back to one of our sites, app.example.test or docs.example.test.
if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var url) && url.Host.Contains("example.test"))
{
return Redirect(returnUrl);
}
return LocalRedirect("~/account");
}With IMPL=wrong, 4 of 16 tests fail (4 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>
/// Each value arrives in a link to the real sign-in page, such as /login?returnUrl=%2F%2Fevil.example%2F.
/// After a genuine sign-in the browser must still be on this site or the allowlisted docs site.
/// </summary>
public sealed class ExploitTests(LoginAppFactory factory) : LoginFlow(factory)
{
[Theory]
[InlineData("https://evil.example/")] // an absolute URL to another site
[InlineData("//evil.example/")] // a scheme-relative URL
[InlineData("///evil.example/")] // three slashes
[InlineData(@"/\evil.example/")] // a backslash after the slash
[InlineData(@"\\evil.example/")] // two backslashes
[InlineData("https://app.example.test@evil.example/")] // our host name as a user name
[InlineData("https://docs.example.test.evil.example/")] // a look-alike host that contains an allowed name
[InlineData(@"https://evil.example\@docs.example.test/")] // a backslash before the @
[InlineData("/%2F%2Fevil.example/")] // encoded slashes
[InlineData("javascript:alert(document.domain)")] // a javascript: URL
public Task SignInLandsOnAnAllowedSite(string returnUrl) => AssertLandsOnAnAllowedSite(returnUrl);
}- returnUrl.StartsWith('/') accepts //evil.example/ and /\evil.example/; Url.IsLocalUrl exists to reject both, along with control characters (it checks the second character for / and \).
- Uri.TryCreate("//evil.example/", UriKind.Absolute, ...) succeeds as file://evil.example/, because .NET has read // as a UNC path on every platform since .NET 5. On macOS, ///evil.example/ and /\evil.example/ gave the same result and even /reports/q3 became a file: URI. 'Is it absolute?' and Host checks mislead unless Scheme is checked first.
- new Uri(baseUri, @"/\evil.example/") resolves to https://app.example.test//evil.example/ on .NET 10.0.12, a path on the site, while browsers go to evil.example; do not use it to work out where a redirect lands.
- A host test with Contains or EndsWith accepts docs.example.test.evil.example and look-alike registrations; compare the whole host against a list.
- Browsers refuse a Location header whose scheme is not http or https, but a javascript: returnUrl rendered into an href or a meta refresh runs; IsLocalUrl rejects it either way.
- Semgrep's C# rule misses string? returnUrl and protected helpers, CA3007 only knows System.Web, and CodeQL's query does not model minimal API Results.Redirect; keep the regression tests.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs open-redirect/csharp-aspnetcore does this for you).
- Against your own app, sign in through /login?returnUrl=%2F%5Cevil.example%2F and /login?returnUrl=%2F%2F%2Fevil.example%2F and check that the Location header is your fallback page.
- Grep for Redirect(, RedirectPermanent(, Response.Redirect and Headers.Location; no scanner checked here found the vulnerable code in this card.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | csharp.lang.security.open-redirect.open-redirect | No findings on this card, for any implementation: the rule follows a string parameter of a public method into a bare Redirect(...) call, and here the redirect sits in a protected override. On a probe it flagged public actions that pass a string returnUrl to Redirect, including after a StartsWith('/') check, and skipped Redirect inside if (Url.IsLocalUrl(...)). It missed string? returnUrl, the usual declaration when nullable reference types are on.Checked: Semgrep 1.180.0 (CE); semgrep scan --config r/<id> src |
| Semgrep | p/default, p/security-audit, p/owasp-top-ten | No redirect findings. The packs' one finding was csharp.dotnet.security.audit.missing-or-broken-authorization on AccountControllerBase, which has no class-level [Authorize] or [AllowAnonymous] because the sign-in actions must stay public; it does not concern the redirect.Checked: Semgrep 1.180.0 (CE); on src, excluding obj and bin |
| CodeQL | cs/web/unvalidated-url-redirection | From the query source: sinks are MVC controller Redirect* helpers, HttpResponse.Redirect and Location header writes; minimal API Results.Redirect is not modelled. Barriers include any call named IsLocalUrl that guards the value, Contains calls that take the URL as their first argument, and == or != comparisons of Uri.Host. It should reach VulnerableAccountController's Redirect(returnUrl) through the virtual call from SignIn; that was not tested.Checked: github/codeql main at b5b8165; not executed |
| .NET code analysis | CA3007Review code for open redirect vulnerabilities | No warnings on this card, and none on a probe with Redirect(returnUrl), Response.Redirect(Request.Query["next"]) and Results.Redirect(next). The rule is off by default, and its sinks in dotnet/sdk (RedirectSinks.cs, last changed in 748f0c9) are System.Web's HttpResponse and HttpResponseBase only, so it does not cover ASP.NET Core.Checked: .NET SDK 10.0.107; dotnet build with -p:AnalysisModeSecurity=All and dotnet_diagnostic.CA3007.severity = warning |
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12) |
| Framework | ASP.NET Core 10 MVC controllers with cookie authentication and antiforgery |
| Microsoft.AspNetCore.App (shared framework: MVC, cookie authentication, antiforgery) | 10.0.12 at run time |
| Microsoft.AspNetCore.Mvc.Testing (test only) | 10.0.12 |
| xunit.v3 (test only) | 4.0.1 (Microsoft Testing Platform 2.4.0) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the open-redirect/csharp-aspnetcorefolder.
dotnet restore tests/Login.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Login.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Login.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Login.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs open-redirect/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- dotnetSdk 10.0.112
- aspNetCoreRuntime 10.0.12
- mvcTesting 10.0.12
- xunitV3 4.0.1
- microsoftTestingPlatform 2.4.0
- semgrep 1.180.0
- Matrix
- fixed: 16 of 16 tests pass; vulnerable: 9 of 16 tests fail (9 exploit tests); wrong: 4 of 16 tests fail (4 exploit tests)
- Test results
- fixed 16/16 pass; vulnerable 9/16 fail (9 exploit tests: every payload except the encoded slashes); wrong 4/16 fail (4 exploit tests: //, ///, /\ and the look-alike host), offline after install
- Dependency freshness
- Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) is the current 10.0.x patch. xunit.v3 4.0.1 (2026-09-12) is used rather than 4.0.2 (2026-10-09), which is under 14 days old. dotnet list package --vulnerable found nothing. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- Prevent open redirect attacks in ASP.NET Core
- IUrlHelper.IsLocalUrl
- ControllerBase.LocalRedirect
- OWASP Unvalidated Redirects and Forwards Cheat Sheet
- Breaking change in .NET 5: Uri recognition of UNC paths on Unix (//host)
- CA3007: Review code for open redirect vulnerabilities
- CodeQL query help: cs/web/unvalidated-url-redirection
- CWE-601: URL Redirection to Untrusted Site ('Open Redirect')