Skip to content

CWE-601: Open Redirect - C# / ASP.NET

Overview

Open Redirect vulnerabilities occur when an application redirects users to URLs controlled by attackers, enabling phishing attacks and credential theft.

Primary Defence: Where the destination does not have to come from the request at all, use the indirect pattern below - a server-side map from an opaque key to a URL removes the weakness rather than constraining it, and no parser disagreement can apply to a value that is never parsed.

Where it does, use Url.IsLocalUrl() (ASP.NET Core) or Request.IsUrlLocalToHost() (ASP.NET MVC 5) to validate that redirect URLs are local before redirecting. These framework methods reject common external URL forms such as absolute URLs, protocol-relative URLs, and JavaScript URLs when applied to the value that will be passed to the redirect API.

Common Vulnerable Patterns

Unvalidated Redirect After Login

// VULNERABLE - Unvalidated redirect
public IActionResult Login(string returnUrl)
{
    // Authenticate user...
    return Redirect(returnUrl);  // Dangerous!
}

// Attack: returnUrl = "https://evil.com/phishing"

Why this is vulnerable: The redirect happens after authentication succeeds, which is what makes this worth more to an attacker than an ordinary open redirect: the victim has just proved they trust the site, and the destination inherits that trust for a credential prompt or an OAuth consent screen. The origin in the address bar changes at exactly the moment a user has stopped reading it.

ASP.NET Core has the check built in. LocalRedirect() throws InvalidOperationException for anything that is not a local URL, and Url.IsLocalUrl() returns the same decision as a boolean - both reject the protocol-relative //evil.com and the backslash form /\evil.com that a hand-written StartsWith("/") test lets through.

External URL Redirect Without Validation

// VULNERABLE - External URL redirect
public IActionResult ExternalLink(string url)
{
    return Redirect(url);  // No validation
}

Why this is vulnerable: An endpoint on your origin that forwards to any URL is a laundering service for links: the address the victim inspects, and the one a mail filter or link scanner evaluates, is yours. That is the value of the bug even though the application discloses nothing itself.

Where redirecting off-site is a real requirement - an outbound link tracker, a payment return - the destination has to come from an allowlist of hosts rather than from the request, or be signed when it is issued and verified here. LocalRedirect() is not applicable to that case, which is why it needs the separate answer rather than the same one.

Secure Patterns

Validate Local URLs Only

// SECURE - ASP.NET Core
public IActionResult Login(string returnUrl)
{
    // Authenticate user...

    if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
    {
        return Redirect(returnUrl);
    }

    return RedirectToAction("Index", "Home");
}

// SECURE - ASP.NET MVC 5
public ActionResult Login(string returnUrl)
{
    // Authenticate...

    if (!string.IsNullOrEmpty(returnUrl) && Request.IsUrlLocalToHost(returnUrl))
    {
        return Redirect(returnUrl);
    }

    return RedirectToAction("Index", "Home");
}

Why this works:

  • Framework validation: Url.IsLocalUrl() returns true only for a path starting with / (like /dashboard) or the application-relative ~/, rejecting absolute URLs (https://evil.com), protocol-relative (//evil.com), JavaScript URLs
  • Edge case handling: Microsoft's implementation also rejects the backslash-prefixed /\evil.com and - measured on .NET 10 - any value containing a control character, which is what stops /<tab>/evil.com from being resolved as //evil.com by the browser. If upstream middleware decodes or rewrites redirect parameters, validate the final value that will be passed to Redirect.
  • Fail-closed default: return RedirectToAction("Index", "Home") sends invalid URLs to a safe destination instead of failing open or surfacing an error
  • Deployment-agnostic: Works across IIS, Kestrel, reverse proxies - correctly identifies local vs external URLs regardless of hosting
  • Phishing prevention: Stops attacks like bank.com/login?returnUrl=https://evil.com/phishing, where the user lands on the attacker's credential-stealing page immediately after logging in

Allowlist Approach

// SECURE - Allowlist of permitted domains
public class RedirectValidator
{
    private static readonly HashSet<string> AllowedDomains = new(StringComparer.OrdinalIgnoreCase)
    {
        "example.com",
        "www.example.com",
        "subdomain.example.com"
    };

    public static bool IsAllowedUrl(string url)
    {
        if (string.IsNullOrEmpty(url))
            return false;

        if (!Uri.TryCreate(url, UriKind.Absolute, out Uri uri))
            return false;

        return uri.Scheme == Uri.UriSchemeHttps &&
               uri.UserInfo.Length == 0 &&
               uri.IsDefaultPort &&
               AllowedDomains.Contains(uri.IdnHost);
    }
}

public IActionResult SafeRedirect(string url)
{
    if (Url.IsLocalUrl(url) || RedirectValidator.IsAllowedUrl(url))
    {
        return Redirect(url);
    }

    return RedirectToAction("Index", "Home");
}

Why this works:

  • Explicit domain validation: Uses Uri.TryCreate() to parse the URL into components, rejecting malformed URLs that fail parsing
  • Case-insensitive host comparison: HashSet<string>(StringComparer.OrdinalIgnoreCase) prevents bypass attempts using mixed-case domains like EvIl.CoM
  • Exact host matching: AllowedDomains.Contains(uri.IdnHost) compares the parsed host alone, so it rejects subdomains of attacker sites (evil.com.attacker.net), path-based bypasses (evil.com/example.com), and query parameter tricks (evil.com?domain=example.com)
  • Defense-in-depth: Combining Url.IsLocalUrl() with allowlist validation lets local URLs (like /dashboard) pass through without external validation, while absolute URLs must match the allowlist exactly
  • Explicit external policy: Null/empty rejection prevents missing URLs from falling through; external redirects require HTTPS, no userinfo, the default port, and an allowlisted host. Relative URLs are handled separately by Url.IsLocalUrl().
  • Ideal for trusted partners: Named external destinations such as payment processors and OAuth providers stay reachable while every other external redirect is blocked

Indirect Redirects (Best Practice)

// SECURE - Use identifiers instead of URLs
public IActionResult Redirect(int destinationId)
{
    var allowedDestinations = new Dictionary<int, string>
    {
        { 1, "/dashboard" },
        { 2, "/profile" },
        { 3, "/settings" }
    };

    if (allowedDestinations.TryGetValue(destinationId, out string url))
    {
        return Redirect(url);
    }

    return RedirectToAction("Index", "Home");
}

Why this works:

  • Eliminates URL injection entirely: Indirect redirects never accept user-controlled URL strings - users provide integer IDs instead, which cannot contain malicious URLs
  • Safe dictionary lookup: TryGetValue() maps safe integer keys (1, 2, 3) to pre-defined destination URLs controlled by the application, not the user
  • Fail-closed default: Returns users to the homepage when an invalid ID is provided, preventing information disclosure about valid redirect targets
  • Simplified security: Decoupling user input from the redirect destination means a review checks the hardcoded URL list rather than URL parsing logic
  • Where it fits: Post-login redirects, workflow navigation, and any scenario where redirect targets are known at development time. Keep the mapped destinations reviewed and local or explicitly trusted.

Warning Page for External URLs

// SECURE - Show warning before external redirect
public IActionResult ExternalRedirect(string url)
{
    if (Url.IsLocalUrl(url))
    {
        return Redirect(url);
    }

    if (!RedirectValidator.IsAllowedUrl(url))
    {
        return RedirectToAction("Index", "Home");
    }

    // Allowed external URL - show warning page
    return View("ExternalRedirectWarning", new { DestinationUrl = url });
}

// ExternalRedirectWarning.cshtml
@model dynamic
<h2>You are leaving our site</h2>
<p>You are about to visit: @Model.DestinationUrl</p>
<a href="@Model.DestinationUrl" rel="noopener noreferrer">Continue to external site</a>
<a href="/">Stay here</a>

Why this works:

  • Breaks phishing flow: Interstitial warning interrupts the silent auto-redirect to https://evil.com/fake-login
  • URL inspection: Displays the full @Model.DestinationUrl, so users can recognize suspicious domains (e.g., paypa1.com with the number 1 vs paypal.com)
  • Explicit user action: Users must click a "Continue" link rather than being carried across, giving them time to evaluate the destination
  • Safe escape: "Stay here" option provides clear alternative if redirect seems suspicious
  • Combined with local check: Url.IsLocalUrl() skips warning for local URLs (/dashboard), avoiding friction for legitimate intra-site navigation

Input Validation

Use Url.IsLocalUrl() wherever an IUrlHelper is in scope. The helper below is for code that has none - middleware, minimal API endpoints, a background job resolving a stored return URL - and it makes the same decision for paths:

// SECURE - path-only check for code with no IUrlHelper in scope
public static class UrlValidator
{
    public static bool IsLocalUrl(string? url)
    {
        if (string.IsNullOrEmpty(url) || url[0] != '/')
            return false;

        if (url.Length == 1)
            return true;

        // "/" alone is local. Whatever follows must not begin a new authority:
        // "//evil.com" is protocol-relative, and a browser reads the backslash
        // in "/\evil.com" as the same thing, so both leave the site.
        if (url[1] == '/' || url[1] == '\\')
            return false;

        // A browser deletes tab, CR and LF from a URL before resolving it, so
        // "/<tab>/evil.com" is read as "//evil.com". Kestrel writes a tab into
        // the Location header unchanged, and Url.IsLocalUrl() rejects every
        // control character for exactly this reason.
        foreach (char c in url.AsSpan(1))
        {
            if (char.IsControl(c))
                return false;
        }

        return true;
    }
}

A prefix-matching version - reject http://, https:// and //, then require a leading / - looks equivalent and is not. It accepts /\evil.com, which Url.IsLocalUrl() rejects, and which the browser sends to evil.com. Enumerating the external forms means listing every spelling of the authority; testing the first two characters does not.

The control-character loop is the part that is easy to leave out, and leaving it out reopens the bug the rest of the method closes. Measured on .NET 10 with Microsoft.AspNetCore.Mvc.Core 10.0.0: a version testing only the first two characters returns true for /%09/evil.com arriving as a query parameter - which model binding decodes to a real tab before the check sees it - Kestrel emits Location: /<tab>/evil.com verbatim, and the browser's URL parser strips the tab and navigates to evil.com. Url.IsLocalUrl() returns false for the same value.

The one case this helper treats differently from the framework is the application-relative ~/dashboard, which Url.IsLocalUrl() accepts and this rejects. Outside MVC nothing resolves ~/, so rejecting it is the correct answer there.

Verification

To verify redirect protection is working:

  • Test local URLs: Verify relative URLs (e.g., /dashboard, /profile/edit) are allowed
  • Test external URLs: Confirm absolute URLs (e.g., https://evil.com) are rejected or require allowlist approval
  • Test protocol-relative URLs: Ensure //evil.com is blocked, and /\evil.com with it - the backslash form resolves to the same destination and is the one hand-written checks miss
  • Test JavaScript URLs: Verify javascript:alert('xss') is rejected
  • Test control characters: /%09/evil.com, /%0a/evil.com and /%00/evil.com, once for each transport the endpoint accepts - they do not all arrive the same way. Measured on .NET 10 (SDK 10.0.401): from the query string all three arrive decoded, NUL included; from an application/x-www-form-urlencoded body the first two arrive decoded and %00 never reaches the action at all, because the form reader throws InvalidDataException; from a multipart/form-data field or a JSON body all three arrive as the literal text. Where the value arrives decoded, Url.IsLocalUrl() rejects it while a hand-written check that only inspects the first two characters accepts it, and the browser resolves the tab and LF forms to evil.com. Where it arrives literal, Url.IsLocalUrl() accepts it - correctly, since the browser keeps a percent-encoded control character in the path
  • Test edge cases: Try encoded URLs, mixed case, and Unicode variations
  • Review code: Search for all uses of Redirect(), RedirectToAction(), and URL parameters
  • Check allowlists: If external redirects are permitted, verify only trusted domains are in the allowlist
  • Test after authentication: Attempt open redirect attacks in login flows and other authentication scenarios

Common Pitfalls

  • Validating with Url.IsLocalUrl() and then concatenating extra query string or fragment data onto the already-validated returnUrl before calling Redirect() - the value that gets checked and the value that ends up in the response aren't the same string, so the concatenation step can reintroduce a destination the check never saw.
  • Using Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out uri) as an "is this local" check. RelativeOrAbsolute parses successfully for absolute external URLs too, not just relative paths - treating a successful TryCreate as proof the URL is safe skips the separate uri.IsAbsoluteUri check that's actually doing the restricting.
  • Wiring Url.IsLocalUrl() into the main login controller's returnUrl handling but not into a separate OAuth/OIDC external-login callback action - that callback typically has its own redirect_uri handling (or none), so fixing the primary login flow doesn't fix the second redirect sink using the same "trusted because it's local" assumption.

Additional Resources