Preventing Open Redirection Attacks in ASP.NET MVC

Summary

ASP.NET MVC 3 includes a new change in the the Account Controller to prevent open redirection attacks. After explaining how open redirection attacks work, I'll This tutorial explains how you can prevent open redirection attacks in your ASP.NET MVC applications. This tutorial discusses the changes that have been made in the AccountController in ASP.NET MVC 3 and demonstrates how you can apply these changes in your existing ASP.NET MVC 1.0 and 2 applications.

What is an Open Redirection Attack?

Any web application that redirects to a URL that is specified via the request such as the querystring or form data can potentially be tampered with to redirect users to an external, malicious URL. This tampering is called an open redirection attack.

Whenever your application logic redirects to a specified URL, you must verify that the redirection URL hasn’t been tampered with. The login used in the default AccountController for both ASP.NET MVC 1.0 and ASP.NET MVC 2 is vulnerable to open redirection attacks. Fortunately, it is easy to update your existing applications to use the corrections from the ASP.NET MVC 3 Preview.

To understand the vulnerability, let’s look at how the login redirection works in a default ASP.NET MVC 2 Web Application project. In this application, attempting to visit a controller action that has the [Authorize] attribute will redirect unauthorized users to the /Account/LogOn view. This redirect to /Account/LogOn will include a returnUrl querystring parameter so that the user can be returned to the originally requested URL after they have successfully logged in.

In the screenshot below, we can see that an attempt to access the /Account/ChangePassword view when not logged in results in a redirect to /Account/LogOn?ReturnUrl=%2fAccount%2fChangePassword%2f.

Since the ReturnUrl querystring parameter is not validated, an attacker can modify it to inject any URL address into the parameter to conduct an open redirection attack. To demonstrate this, we can modify the ReturnUrl parameter to http://bing.com, so the resulting login URL will be /Account/LogOn?ReturnUrl=http://www.bing.com/. Upon successfully logging in to the site, we are redirected to http://bing.com. Since this redirection is not validated, it could instead point to a malicious site that attempts to trick the user.

A more complex Open Redirection Attack

Open redirection attacks are especially dangerous because an attacker knows that we’re trying to log into a specific website, which makes us vulnerable to a phishing attack. For example, an attacker could send malicious e-mails to website users in an attempt to capture their passwords. Let’s look at how this would work on the NerdDinner site. (Note that the live NerdDinner site has been updated to protect against open redirection attacks.)

First, an attacker sends us a link to the login page on NerdDinner that includes a redirect to their forged page:

http://nerddinner.com/Account/LogOn?returnUrl=http://nerddiner.com/Account/LogOn

Note that the return URL points to nerddiner.com, which is missing an “n” from the word dinner. In this example, this is a domain that the attacker controls. When we access the above link, we’re taken to the legitimate NerdDinner.com login page.

Figure 02: NerdDinner login page with an open redirection

When we correctly log in, the ASP.NET MVC AccountController’s LogOn action redirects us to the URL specified in the returnUrl querystring parameter. In this case, it’s the URL that the attacker has entered, which is http://nerddiner.com/Account/LogOn. Unless we’re extremely watchful, it’s very likely we won’t notice this, especially because the attacker has been careful to make sure that their forged page looks exactly like the legitimate login page. This login page includes an error message requesting that we login again. Clumsy us, we must have mistyped our password.

When we retype our user name and password, the forged login page saves the information and sends us back to the legitimate NerdDinner.com site. At this point, the NerdDinner.com site has already authenticated us, so the forged login page can redirect directly to that page. The end result is that the attacker has our user name and password, and we are unaware that we’ve provided it to them.

Looking at the vulnerable code in the AccountController LogOn Action

The code for the LogOn action in an ASP.NET MVC 2 application is shown below. Note that upon a successful login, the controller returns a redirect to the returnUrl. You can see that no validation is being performed against the returnUrl parameter.

[HttpPost]
public ActionResult LogOn(LogOnModel model, string returnUrl)
{
    if (ModelState.IsValid)
    {
        if (MembershipService.ValidateUser(model.UserName, model.Password))
        {
            FormsService.SignIn(model.UserName, model.RememberMe);
            if (!String.IsNullOrEmpty(returnUrl))
            {
                return Redirect(returnUrl);
            }
            else
            {
                return RedirectToAction("Index", "Home");
            }
        }
        else
        {
            ModelState.AddModelError("", "The user name or password provided is incorrect.");
        }
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

Now let’s look at the changes to the ASP.NET MVC 3 LogOn action. This code has been changed to validate the returnUrl parameter by calling a new method in the System.Web.Mvc.Url helper class named IsLocalUrl().

[HttpPost]
public ActionResult LogOn(LogOnModel model, string returnUrl)
{
    if (ModelState.IsValid)
    {
        if (MembershipService.ValidateUser(model.UserName, model.Password))
        {
            FormsService.SignIn(model.UserName, model.RememberMe);
            if (Url.IsLocalUrl(returnUrl))
            {
                return Redirect(returnUrl);
            }
            else
            {
                return RedirectToAction("Index", "Home");
            }
        }
        else
        {
            ModelState.AddModelError("", "The user name or password provided is incorrect.");
        }
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

This has been changed to validate the return URL parameter by calling a new method in the System.Web.Mvc.Url helper class, IsLocalUrl().

Protecting Your ASP.NET MVC 1.0 and MVC 2 Applications

We can take advantage of the ASP.NET MVC 3 changes in our existing ASP.NET MVC 1.0 and 2 applications by adding the IsLocalUrl() helper method and updating the LogOn action to validate the returnUrl parameter.

The UrlHelper IsLocalUrl() method actually just calling into a method in System.Web.WebPages, as this validation is also used by ASP.NET Web Pages applications. 

public bool IsLocalUrl(string url) {
    return System.Web.WebPages.RequestExtensions.IsUrlLocalToHost(
        RequestContext.HttpContext.Request, url);
}

The IsUrlLocalToHost method contains the actual validation logic, as shown below.

public static bool IsUrlLocalToHost(this HttpRequestBase request, string url) {
    if (url.IsEmpty()) {
        return false;
    }
 
    Uri absoluteUri;
    if (Uri.TryCreate(url, UriKind.Absolute, out absoluteUri)) {
        return String.Equals(request.Url.Host, absoluteUri.Host, StringComparison.OrdinalIgnoreCase);
    }
    else {
        bool isLocal = !url.StartsWith("http:", StringComparison.OrdinalIgnoreCase)
            && !url.StartsWith("https:", StringComparison.OrdinalIgnoreCase)
            && Uri.IsWellFormedUriString(url, UriKind.Relative);
        return isLocal;
    }
}

In our ASP.NET MVC 1.0 or 2 application, we’ll add a IsLocalUrl() method to the AccountController, but you’re encouraged to add it to a separate helper class if possible. We will make two small changes to the ASP.NET MVC 3 version of IsLocalUrl() so that it will work inside the AccountController. First, we’ll change it from a public method to a private method, since public methods in controllers can be accessed as controller actions. Second, we’ll modify the call that checks the URL host against the application host. That call makes use of a local RequestContext field in the UrlHelper class. Instead of using this.RequestContext.HttpContext.Request.Url.Host, we will use this.Request.Url.Host. The following code shows the modified IsLocalUrl() method for use with a controller class in ASP.NET MVC 1.0 and 2 applications.

//Note: This has been copied from the System.Web.WebPages RequestExtensions class
private bool IsLocalUrl(string url)
{
    if (string.IsNullOrEmpty(url))
    {
        return false;
    }

    Uri absoluteUri;
    if (Uri.TryCreate(url, UriKind.Absolute, out absoluteUri))
    {
        return String.Equals(this.Request.Url.Host, absoluteUri.Host, StringComparison.OrdinalIgnoreCase);
    }
    else
    {
        bool isLocal = !url.StartsWith("http:", StringComparison.OrdinalIgnoreCase)
            && !url.StartsWith("https:", StringComparison.OrdinalIgnoreCase)
            && Uri.IsWellFormedUriString(url, UriKind.Relative);
        return isLocal;
    }
}

Now that the IsLocalUrl() method is in place, we can call it from our LogOn action to validate the returnUrl parameter, as shown in the following code.

[HttpPost] 
public ActionResult LogOn(LogOnModel model, string returnUrl) 
{ 
    if (ModelState.IsValid) 
    { 
        if (MembershipService.ValidateUser(model.UserName, model.Password)) 
        { 
            FormsService.SignIn(model.UserName, model.RememberMe); 
            if (IsLocalUrl(returnUrl)) 
            { 
                return Redirect(returnUrl); 
            } 
            else 
            { 
                return RedirectToAction("Index", "Home"); 
            } 
        } 
        else 
        { 
            ModelState.AddModelError("", "The user name or password provided is incorrect."); 
        } 
    }
}

Now we can test an open redirection attack by attempting to log in using an external return URL. Let’s use /Account/LogOn?ReturnUrl=http://www.bing.com/ again.

After successfully logging in, we are redirected to the Home/Index Controller action rather than the external URL.

Taking Additional Actions when an open redirect attempt is detected

The LogOn action can take additional actions in the case an open redirect is detected. For instance, you may want to log this as a security exception using ELMAH and display a custom logon message that lets the user know that they've been logged in but that the link they clicked may have been malicious. That logic goes in the "else" block in the LogOn action.

[HttpPost]
public ActionResult LogOn(LogOnModel model, string returnUrl)
{
    if (ModelState.IsValid)
    {
        if (MembershipService.ValidateUser(model.UserName, model.Password))
        {
            FormsService.SignIn(model.UserName, model.RememberMe);
            if (IsLocalUrl(returnUrl))
            {
                return Redirect(returnUrl);
            }
            else
            {
                // Actions on for detected open redirect go here. 
                string message = string.Format("Open redirect to to {0} detected.", returnUrl);
                ErrorSignal.FromCurrentContext().Raise(new System.Security.SecurityException(message));
                return RedirectToAction("SecurityWarning", "Home");
            }
        }
        else
        {
            ModelState.AddModelError("", "The user name or password provided is incorrect.");
        }
    }

    // If we got this far, something failed, redisplay form
    return View(model);
}

Recap

Open redirection attacks can occur when redirection URLs are passed as parameters in the URL for an application. The ASP.NET MVC 3 template includes code to protect against open redirection attacks. You can add this code with some modification to ASP.NET MVC 1.0 and 2 applications. To protect against open redirection attacks when logging into ASP.NET 1.0 and 2 applications, add an IsLocalUrl() method and validate the returnUrl parameter in the LogOn action.

Note: This information (minus the "additional actions" bit) is available in the Security Tutorials section on the ASP.NET website.

14 Comments

  • I can't believe it's taken three versions of MVC to get this right - especially as the WebForms version (FormsAuthentication.GetReturnUrl) has been doing it right since at least v2!


    string result = current.Request.QueryString["ReturnUrl"];
    ...
    if (!string.IsNullOrEmpty(result) && !EnableCrossAppRedirects && !UrlPath.IsPathOnSameServer(result, current.Request.Url))
    {
    result = null;
    }

  • Nice article! I wasn't even aware of the existence of Open Redirection Attacks until I read this post.

    Nice!

  • Thanks. This is very useful.

  • I quickly wrote an actionfilter(could be global)

    public class ValidateReturnUrlAttribute : ActionFilterAttribute
    {
    public ValidateReturnUrlAttribute() : this(true)
    {

    }

    public ValidateReturnUrlAttribute(bool allowSubdomains)
    {
    AllowSubDomains = allowSubdomains;
    SubdomainDepth = 1;
    }

    ///
    /// Depth of subdomains to check.
    ///
    /// To check for subdomains of example.com, depth is 1 (default). To check for subdomains of example.co.uk, depth should be 2.
    public short SubdomainDepth { get; set; }

    public bool AllowSubDomains { get; set; }

    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
    var key = filterContext.ActionParameters.Keys.FirstOrDefault(k => k.ToUpperInvariant() == "RETURNURL");
    if (string.IsNullOrWhiteSpace(key))
    {
    string url = filterContext.ActionParameters[key] as string;
    bool isLocal = true;
    if (string.IsNullOrWhiteSpace(url))
    {
    Uri absoluteUri;
    if (Uri.TryCreate(url, UriKind.Absolute, out absoluteUri))
    {
    isLocal = String.Equals(filterContext.HttpContext.Request.Url.Host, absoluteUri.Host, StringComparison.OrdinalIgnoreCase);

    if (!isLocal && AllowSubDomains)
    {
    isLocal = true; // new check;

    string[] trgt = absoluteUri.Host.Split(new char[]{'.'}, StringSplitOptions.RemoveEmptyEntries );
    string[] me = filterContext.HttpContext.Request.Url.Host.Split(new char[] { '.' }, StringSplitOptions.RemoveEmptyEntries);

    for (short i = 0; i <= SubdomainDepth; i++)
    {
    if (!String.Equals(me[me.Length - 1 - i], trgt[trgt.Length -1- i], StringComparison.OrdinalIgnoreCase))
    {
    isLocal = false;
    break;
    }
    }
    }

    }
    else
    {
    isLocal = !url.StartsWith("http:", StringComparison.OrdinalIgnoreCase)
    && !url.StartsWith("https:", StringComparison.OrdinalIgnoreCase)
    && Uri.IsWellFormedUriString(url, UriKind.Relative);
    }
    }

    if (!isLocal)
    filterContext.ActionParameters[key] = null;
    }
    base.OnActionExecuting(filterContext);
    }
    }

  • I'm confused. By what mechanism can an attacker change the "returnUrl" in the querystring (e.g. interception and modification of the request), without also having the ability to change the form's postback target, which would bypass all server-side security? Unless we're assuming that the user is blindly following a custom URL crafted by a malicious 3rd-party, in which case they could just send them to their phishing site from the start.

  • @Tom: The user will often check the link to see if it's valid and then proceeed to enter their login details. If it fails they'll simply re enter them without even thinking they may have been redirected.

  • The attribute filter has two minor issues, the two string.IsNullOrWhiteSpace checks should be negative, ie !string.IsNullOrWhiteSpace

  • @Gidon - I'd recommend not writing your own logic to check for Local URL's. Use the UrlHelper extension method - Url.IsLocalUrl(returnUrl)) if possible. The custom code is shown shown for cases where you're working with and MVC 1 or 2 site.

  • Very nice article... Good eye-opener.

  • Shouldn't this code, and the MVC3 IsLocalUrl method, check more than just the host? I'm thinking at least the scheme and port too.

  • @Jon, it was just a quick POC actionfilter. The actionfilter optionally allows redirecting within the same domain, but to a different subdomain.

    I think having the returnUrl checked before it comes to the controller (and having it checked globally) adds another layer of protection.

    My site, for example, has a lot of places where I use a returnUrl querystring param.

  • Why is the return URL in the QueryString to begin with?!

    but anyhow, this addition is better than nothing at all.

  • This chair has puff without having to forfeiture their second to the aches and
    strain of the office chairs antecedently used. But they Revaluation,
    so you may demand to venture further into the land site by clicking on a category varlet that allows you
    to see a lean of chairs. Currently, the cushions are existing office chairs, condition should be given to the type of floor surface the chair
    rolls on. You wont be a shopworn octane Orange I
    was afterward.

  • Howdy just wanted to give you a brief heads up and let you know a few of the pictures aren't loading properly. I'm not sure why but I think its a linking issue.

    I've tried it in two different web browsers and both show the same results.

Comments have been disabled for this content.