Laravel Open Redirect
Security Guide

stackhawk

StackHawk|July 17, 2021

In this blog post, you'll learn about a security vulnerability called "the open redirect vulnerability" and about laravel external redirect.

The internet is, by its nature, a connected place. It's filled with resources linking to other resources in a glorious spiderweb of information, both delightful and terrifying. Enabling this functionality is the humble URL, which can point you in the right direction or just as easily lead you astray.

In this blog post, you'll learn about a security vulnerability called the open redirect vulnerability and how to protect yourself against it. The open redirect vulnerability allows a malicious actor to craft a special URL that will trick a user into trusting an untrustworthy website.

Let's start by finding out more about this vulnerability.

What's an Open Redirect Vulnerability?

Open redirect vulnerabilities happen when a website allows user-generated content to be used as a parameter during a URL redirection. If the user-generated content isn't validated, an attacker can craft a URL that looks trustworthy but isn't. This URL will look like it's on the current domain, but it will in reality point to an external domain under the attacker's control.

Here's an example. Let's assume that the MyBank website has a vulnerable redirector that an attacker knows about. MyBank uses the redirect_url parameter to send the user to the page they were trying to access before they were asked to log in.

Laravel Open Redirect Security Guide image

An example of a redirect URL that could be used as a phishing attack.

The MyBank website assumes that the redirect_url parameter always points within the site. But an attacker can change this URL so that it redirects to a completely different website named NyBank that's under the attacker's control.

This means that once the user logs in, MyBank will redirect the user to NyBank on nybank.com. This website could look exactly like MyBank, and the user wouldn't be any the wiser because they came in through a trusted source.

At this point, the attacker can have the user fill in a fake login form that steals the user's credentials for the MyBank website.

I learn things faster when I see examples. So we're going to create a website for MyBank and put in this functionality. First, we'll put it in the wrong way and see how the vulnerability works. After that, we'll explore a couple of approaches we can use to fix this vulnerability.

Let's get started!

Recreating the Vulnerability

In this section, we'll set up Laravel to show how this vulnerability works. The code in this blog assumes that you have Docker running. You'll also need to be running either a Mac/Linux environment or Windows with WSL 2.0 installed. If you're in a different environment or looking for alternate installation options, have a look at the Laravel docs to set things up.

Setting Up

Let's start by setting up an application that'll simulate the MyBank website. Run the following commands to get going:

# Setup the directories we need
cd /path/to/your/projects/
mkdir open-vulnerability-test
cd open-vulnerability-test

# Create the safe application 
curl -s https://laravel.build/mybank| bash

# Bring up the application
cd mybank
./vendor/bin/sail up

Once the commands have finished running, try to load your website on http://localhost.

Laravel Open Redirect Security Guide image

Default landing page.

If everything worked out well, you should see something like the above. If you do, then you're ready to move to the next step and set up the redirect.

Creating Our Fake Bank

To make this a good example, we'll need the following functionality:

  1. Public landing page with some links

  2. A mixed bag of public and private pages

  3. Fake login page that serves as a "gate" to access private pages

Let's start by creating the landing page.

Creating the Landing Page

To change the content on the landing page, open the /resources/views/welcome.blade.php. Then put in the following content:

Laravel Open Redirect Security Guide
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>My Bank</title>
       <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
    </head>
    <body>
        <div class="container ">
            <h2 class="pb-2 border-bottom">
            My Bank
            @if(Session::has('is-logged-in'))
              <small><a href="/logout">Logout</a></small>
            @endif
            </h2>
            
            <div class="row">
                <div class="col">
                    <a href="/balance">View Account Balance</a>
                </div>

                <div class="col">
                    <a href="/account">View Account Number</a>
                </div>
                
                <div class="col">
                    <a href="/contact">Contact Us</a>
                </div>
            </div>
        </div>
    </body>
</html>

Now, let's create the page handler.

Creating the Page Handler

Create a new file, app/Http/PageController.php. Put the following code inside it. This will handle the different page view functions.

Laravel Open Redirect Security Guide
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class PageController extends Controller {

  public function showBalance(Request $request) {
    // If you're not logged in, then you get redirected
    if (!$request->session()->get('is-logged-in')) {
      return redirect('/login?redirect_url=/balance');
    }
    return "Your balance : $234,451!";
  }

  public function showAccount(Request $request) {
    // If you're not logged in, then you get redirected
    if (!$request->session()->get('is-logged-in')) {
      return redirect('/login?redirect_url=/account');
    }
    return "Your account number : 345222234";
  }

  public function showAddress() {
    return "Our address : 123 Bank Avenue, Melrose Place.";
  }
}

This controller has three functions. The showAddress function is publicly accessible and will show you the bank's address. The showBalance and showAccount functions are accessible only if you've logged in. If you haven't logged in, you get redirected to the login page.

Now, let's create the login page so it can handle the redirection.

Creating the Login Page

We'll create a simple controller to simulate the login functionality. This is only for demonstration purposes, and you should use an existing auth library if you want a real authentication system.

Create a new file named app/Http/Controllers/LoginController.php, and put the code below in it.

Laravel Open Redirect Security Guide
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class LoginController extends Controller {

  public function showLogin(Request $request) {
    // If we're logged in, don't show login page
    if ($request->session()->get('is-logged-in')) {
      return redirect('/');
    }
    //If the session has a redirect URL, use that. If not, use the default of /
    $redirectUrl = $request->get('redirect_url', $request->session()->get('redirect', '/'));
    $request->session()->put('redirect', $redirectUrl);
    return view('login');
  }

  public function confirmLogin(Request $request) {
    $email = $request->get('username');
    $password = $request->get('password');

    // This is the only username/password combo that is allowed
    if ($email == 'bob' && $password == '1234') {
      $request->session()->put('is-logged-in', 1); //set the logged in flag
      return redirect($request->session()->get('redirect', '/')); 
      // Did you spot it yet The line above is where the vulnerability exists. 
    } else {
      $request->session()->flash('login-message', 'Username or password was wrong');
      return redirect('/login');
    }
  }

  public function logout(Request $request) {
    $request->session()->flush();
    return redirect('/');
  }
}

And finally, create the login view by creating a new file named resources/views/login.blade.php. Put the code below in it.

Laravel Open Redirect Security Guide
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>My Bank</title>
       <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet">
    </head>
    <body>
        <div class="container ">
            <h2 class="pb-2 border-bottom">My Bank : Login</h2>
            @if(Session::has('login-message'))
              <p class="alert alert-danger">{{ Session::get('login-message') }}</p>
            @endif
            <form class="row g-3" method="post" action="/login">
              <div class="col-auto">
                <input type="text" class="form-control" name="username" placeholder="Username">
              </div>
              <div class="col-auto">
                <input type="password" class="form-control" name="password" placeholder="Password">
              </div>
              <div class="col-auto">
                <button type="submit" class="btn btn-primary mb-3">Login</button>
              </div>
              @csrf
            </form>
        </div>
    </body>
</html>

That wraps up all of the controllers and the views we needed to create! Now, we just need to wire it all together.

Setting Up the Routes

Open the router file in /routes/web.php. Then, paste the following code right at the bottom.

Laravel Open Redirect Security Guide
//Connect the login page
Route::get('/login', [LoginController::class, 'showLogin']); 
Route::post('/login', [LoginController::class, 'confirmLogin']); 
Route::get('/logout', [LoginController::class, 'logout']);

//Connect the public and private pages
Route::get('/contact', [PageController::class, 'showAddress']);
Route::get('/balance', [PageController::class, 'showBalance']);
Route::get('/account', [PageController::class, 'showAccount']);

Your app should now be fully functional.

Try reloading the page, and see if you get something like this:

Laravel Open Redirect Security Guide image

If the page loads correctly for you, then everything's as it should be.

Let's go ahead and use our makeshift site to learn more.

Exploring the Vulnerability

Try out the following steps in your website:

  1. Load the landing page.

  2. Click the Contact Us link. You should see the address of the bank, since this is public information.

  3. Click back, and now click the View Account Balance link. You'll see a message asking you to log in.

  4. Use the username of "bob'' and a password of "1234" to log in. You're automatically redirected to the account balance page.

On the surface, things seem to be working fine. Try going back to the home page and logging out. And then click the View Account Balance link again.

Laravel Open Redirect Security Guide image

The redirect_url parameter is part of the URL.

Pay attention to the URL. The redirect_url parameter tells the page where to go after login. This is a problem because the user can change this URL. The danger isn't to the current user. Instead, the danger is in the website allowing unrestricted redirects.

Try copying that URL and changing it to http://localhost/login?redirect_url=https://www.stackhawk.com/ and putting it into your browser.

Once you log in, you now get redirected to the StackHawk home page!

A Plausible Attack

So what's the danger here?

Imagine the attacker knows that a user named Bob has an account at MyBank. They buy the nybank.com domain because it's visually similar. And then they create a website login page that's identical to the one that MyBank maintains. They add a generic message to the page informing the user that their credentials are wrong and to retry logging in. This page simply captures the username and password entered into the login form and redirects the user to mybank.com.

They then craft their URL so that it uses MyBank's own unrestricted redirect functionality to redirect to their malicious website.

Laravel Open Redirect Security Guide image

They can then send Bob an email pretending to be from the bank, using an email spoofing attack, and include this special URL in the email.

Let's say that Bob is in the minority of technically savvy users who check the hostname before clicking on links. The link is genuine and correctly has mybank.com as the hostname.

And let's say that he's in the even tinier minority of users who check the SSL certificate when they load a website. Again, everything checks out because the website is genuine.

Bob logs in, and then MyBank redirects him to nybank.com, which looks exactly the same and asks him to log in again. He assumes he made a typo and logs in again.

The malicious website simply captures the information and redirects Bob to mybank.com. Bob doesn't even know that someone has stolen his credentials!

As you can see, malicious users can exploit open redirect vulnerabilities with far-reaching consequences. Fortunately, there are multiple ways to guard against this vulnerability. Let's have a look at those now.

Fixing Open Redirect Vulnerabilities

Since we already introduced this vulnerability to our application, let's explore our options and apply a fix.

Not Just a Login Page Problem

Remember, we use this login page vulnerability as an example only. This vulnerability can exist in any place that you do redirects based on user inputs.

The easiest solution to fix this vulnerable login example is to use the existing Auth package. However, that doesn't give us a chance to discuss possible approaches to the actual issue. So make sure you take away the problem-solving pattern behind these solutions, rather than just the specific implementation.

All of the examples below will reference the app.Http/Controllers/LoginController.php file and confirmLogin() method. I've taken out the other methods for brevity.

Fixed Redirects Solution

Removing the user's ability to change the value of the URL is possibly the most secure way to fix this. In this case, we simply remove the variable redirection and always send the user to the home page after logging in. So your confirmLogin would then be updated to something like this:

public function confirmLogin(Request $request) {
  ...
  if ($email == 'bob' && $password == '1234') {
    $request->session()->put('is-logged-in', 1); //set the logged in flag
    return redirect('/'); // <-- Always send the user to the landing page after login
  } else {
  ...
}

The downside of this approach is that it's not very friendly for the user. They'll have to re-navigate to the link that they were trying to access before being asked to log in. The next approach fixes this limitation.

Whitelisted Redirects Solution

If we don't want the user's navigation flow reset, then we can try whitelisting the allowed redirect URLs. A whitelist is just an array of pages that the redirect function is allowed to redirect to. If the redirect URL isn't in the list, then we just redirect the user to the home page.

This solution should look something like this:

public function confirmLogin(Request $request) {
  ...
  if ($email == 'bob' && $password == '1234') {
    ...
    $requestedUrl = $request->session()->get('redirect', '/');
    $allowedUrls = ['/balance', '/account']; //<-- Only these two URLs can be redirected to
    if (in_array($requestedUrl, $allowedUrls))  {
      return redirect($requestedUrl);
    } else {
      return redirect('/');
    }
  } else {
  ...
}

This allows you to define which URLs are safe to redirect to, and it won't affect the user's navigation.

But what if you have a lot of links or the links are dynamic? Let's consider another option.

Domain-Based Redirects Solution

We can use this approach if there are lots of URLs or the URLs have dynamic components to them. In this approach, we restrict the code to redirection to within the current hostname.

public function confirmLogin(Request $request) {
    ...
    if ($email == 'bob' && $password == '1234') {
      ...
      // Append the domain name so it always redirects internally
      $redirectUrl = 'https://mybank.com/' . $request->session()->get('redirect', '/');  
      if (filter_var($redirectUrl, FILTER_VALIDATE_URL) === FALSE) {
        return redirect('/'); // By, default redirect to landing page if the URL is bad
      } 
      return redirect($redirectUrl);
    } else {
    ...
  }

This approach works by making sure the user is never accidentally redirected out of the website you control.

Another twist on this approach is to display a warning to the user if you're redirecting away from your own website. For example, here's Google's warning when navigating away from some of its websites.

Laravel Open Redirect Security Guide image

Next Steps and Digging Deeper

In this post, you learned about the open redirect vulnerability and how dangerous it can be. You also learned about possible approaches that you can take to fix these vulnerabilities on Laravel.

If you're interested in further reading on the subject, a good place to start would be to see how this vulnerability affects other platforms and even more approaches to dealing with them.

StackHawk can also help you stay educated with its helpful blog. You can sign up for a free account.

This post was written by John Pereira. John is a technology enthusiast who's passionate about his work and all forms of technology. With over 15 years in the technology space, his area of expertise lies in API and large scale web application development, and its related constellation of technologies and processes.


StackHawk  |  July 17, 2021