One of the fundamental components of the Internet is the Uniform Resource Locator (URL). URLs let us seamlessly link together documents and websites from all across the Internet. It's essentially the digital version of a postal address, letting us humans remember resource locations without having to remember complicated IP addresses. Now imagine if there was a vulnerability that exploited the convenience of linked resources.
In this post, you'll learn about the open redirect security vulnerability and how it can affect your Node.js application. We'll also create a vulnerable example application and look at how this exploit works in the wild. And finally, we'll discuss several approaches to fixing this issue.
Let's start by learning a little bit more about open redirect vulnerabilities.
What is an Open Redirect Vulnerability?
Open redirect vulnerabilities can occur when a website accepts user-modifiable content as part of a parameter during a URL redirection. If the parameter is not validated correctly, an attacker can craft a malicious URL that looks trustworthy at a glance, but will likely compromise the user's experience. The malicious URL might look similar to the URL of the trusted website. Alternatively, the URL might take the user to a malicious website that looks identical to the trusted website, tricking the user into entering their credentials. Open redirect vulnerabilities are also sometimes called external redirect vulnerabilities.
Let's look at an example. Assume that the MyBank website is vulnerable to the open redirect attack vector. After a successful login, MyBank uses the redirect_url parameter to redirect a user to the page they wanted to access.
An example of a redirect URL that could be used as a phishing attack.
In this scenario, the MyBank website assumes that the redirect_url parameter will point to a page within the webpage. However, because the parameter is user-modifiable, an attacker could change the value to point to a website that they control. This website could look identical to the MyBank website and the user wouldn't know something was amiss unless they paid particular attention to the URL.
Furthermore, the attacker could have the user fill in a fake login form and steal the user's credentials. Since the user arrived at this destination through a trusted user path, it's unlikely that any of this would raise any red flags for the user.
The best way to learn about the damage this sort of vulnerability can cause is by looking at an example. Let's create one for ourselves!
Creating a Vulnerable Example
In this section, we're going to create an example website for MyBank that is vulnerable to the open redirect flaw. The code in this post assumes that you have Node.js and npm available. Any version of Node.js should suffice as long as it's not too old.
Creating the Package File
Let's start by setting up the directories for our MyBank website.
mkdir nodejs-open-redirect
cd nodejs-open-redirect
Next, create a package.json file inside the nodejs-open-redirect folder and copy the JSON below:
{
"name": "nodejs-open-redirect",
"version": "1.0.0",
"description": "An example app to learn about open redirect vulnerability",
"main": "index.js",
"scripts": {},
"author": "Me",
"license": "ISC",
"dependencies": {
"express": "^4.17.1",
"express-session": "^1.17.2",
"pug": "^3.0.2"
}
}
Once you've created the package.json file, type npm install to install the dependencies. For our project, we've installed express and express-session for web-server functionality and pug for template handling.
Creating the Node.js File
Next, create a file named index.js and put the code below into it.
const express = require('express');
const session = require('express-session');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
// Inititalize the app and add middleware
app.set('view engine', 'pug'); // Setup the pug
app.use(bodyParser.urlencoded({extended: true})); // Setup the body parser to handle form submits
app.use(session({secret: 'super-secret'})); // Session setup
/** Handle login display and form submit */
app.get('/login', (req, res) => {
if (req.session.isLoggedIn === true) {
return res.redirect('/');
}
res.render('login', {error: false});
});
app.post('/login', (req, res) => {
const {username, password} = req.body;
if (username === 'bob' && password === '1234') {
req.session.isLoggedIn = true;
res.redirect(req.query.redirect_url ? req.query.redirect_url : '/');
} else {
res.render('login', {error: 'Username or password is incorrect'});
}
});
/** Handle logout function */
app.get('/logout', (req, res) => {
req.session.isLoggedIn = false;
res.redirect('/');
});
/** Simulated bank functionality */
app.get('/', (req, res) => {
res.render('index', {isLoggedIn: req.session.isLoggedIn});
});
app.get('/balance', (req, res) => {
if (req.session.isLoggedIn === true) {
res.send('Your account balance is $1234.52');
} else {
res.redirect('/login?redirect_url=/balance');
}
});
app.get('/account', (req, res) => {
if (req.session.isLoggedIn === true) {
res.send('Your account number is ACL9D42294');
} else {
res.redirect('/login?redirect_url=/account');
}
});
app.get('/contact', (req, res) => {
res.send('Our address : 321 Main Street, Beverly Hills.');
});
/** App listening on port */
app.listen(port, () => {
console.log(`MyBank app listening at http://localhost:${port}`);
});
The code here is pretty simple. We create an express server and bind some routes to it. For the /balance and /account methods, we check the session isLoggedIn flag and see if the user is logged in. If they aren't, they're redirected to the login page.
Once the user submits the login form, we simply check if the username and password match and then set the isLoggedIn flag. Once that's done, we redirect the user based on the redirect_url parameter. Next, we're going to create the template files.
Creating Our Templates
First, create the /nodejs-open-redirect/views/index.pug template to show on our landing page by copying the code below.
html
head
title="MyBank"
link(href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet")
body
div(class="container")
h2(class="pb-2 border-bottom")="MyBank"
if isLoggedIn
small
a(href="/logout")="Logout"
div(class="row")
div(class="col")
a(href="/balance")="View Account Balance"
div(class="col")
a(href="/account")="View Account Number"
div(class="col")
a(href="/contact")="Contact Us"
Next, create a template named /nodejs-open-redirect/views/login.pug for our login page and add the code below to it.
html
head
title="MyBank"
link(href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css" rel="stylesheet")
body
div(class="container")
h2(class="pb-2 border-bottom")="MyBank Login"
if error
p(class="alert alert-danger")=error
form(class="row g-3" method="post" action="")
div(class="col-auto")
input(type="text" class="form-control" name="username" placeholder="Username")
div(class="col-auto")
input( type="password" class="form-control" name="password" placeholder="Password")
div(class="col-auto")
button(type="submit" class="btn btn-primary mb-3")="Login"
Finally, go to the nodejs-open-redirect folder in the terminal and type the following command: node index.js.
If everything went well, then you should be able to access the running website on http://localhost:3000/.
Landing page for our example website.
Now let's use our simulated website to learn more about the open direct vulnerability.
Understanding the Vulnerability
To experience the vulnerability firsthand, carry out the following steps:
Navigate to http://localhost:3000/
Click the Contact Us link. You should see the address of the bank. This link works as expected because it's meant to be public information and we don't check whether you're logged in.
Go back to your browser and click on the View Account Balance link and you'll see a login page. This also works as expected because it's behind an auth check.
Enter the username bob and password 1234 to log in. You should be automatically redirected to the account balance page.
At a glance, everything seems perfectly fine. But there's definitely something amiss here. Try loading the landing page again and logging out. Once you've logged out, click the View Account Balance link again.
URL redirection used in the login page.
Pay attention to the URL of the login page. The website uses the redirect_url parameter to figure out where the user needs to be redirected once they log in. This is a problem because this parameter can be modified by the user.
It's dangerous because users can be redirected away from a trusted website and onto an untrusted website without ever realizing what's going on.
To demonstrate the vulnerability, try changing the URL to http://localhost:3000/login?redirect_url=https://www.stackhawk.com/ and loading the page. Once the page loads, try logging in and you'll notice you're on the StackHawk home page!
So how can the attacker make use of this vulnerability?
Anatomy of an Attack
Here's a scenario. Assume the attacker knows that Bob has an account at mybank.com. The attacker buys the URL nybank.com because it's visually similar to the mybank.com URL. The attacker then creates a website login page identical to the one that My Bank maintains. This fake login page can contain an error message asking the user to log in again. The login page would be programmed to capture whatever the user enters into the form and then redirect the user back to the original mybank.com website.
The attacker can then craft a URL that takes advantage of My Bank's open redirect vulnerability to redirect users to their own malicious website.
They can then send Bob an email pretending to be from the bank, using an email spoofing attack or XSS attack, and include this special URL in the email.
Let's assume that Bob is tech-savvy. So Bob checks the hostname of the URL before clicking it and it's valid. And let's say that he's in the even tinier minority of people who check the SSL certificate of the website they visit. Again, everything looks fine there.
Bob logs in, and then My Bank's website redirects him to nybank.com because that's the value in the redirect_url parameter. On the nybank.com login page, everything looks identical to the trusted My Bank login page. Bob sees the error message asking him to reenter his credentials—this seems like a reasonable request because maybe he made a typo earlier. Once he reenters them, nybank.com captures his credentials and he's redirected to the My Bank website. Since he's already logged in on My Bank, the bank will simply show him his personalized page.
At this point, Bob's credentials have been stolen and he's none the wiser about it.
Potential Fixes for Open Redirect Vulnerabilities
As you can see, this type of vulnerability can have very damaging consequences for users of your website. So let's look at ways we can mitigate these attacks.
Fixed Redirect Approach
Removing the redirect_url parameter from consideration is possibly the most secure way to deal with this issue.
app.post('/login', (req, res) => {
const {username, password} = req.body;
if (username === 'bob' && password === '1234') {
req.session.isLoggedIn = true;
res.redirect('/personal-homepage');
} else {
res.render('login', {error: 'Username or password is incorrect'});
}
});
The downside of this fix is that it's not a great user experience because the user will need to renavigate to the page they were originally trying to access before they hit the auth step. The next approach is a little friendlier.
AllowList Based Redirect Approach
If we don't want to reset the user's navigation flow, then we can try allowing the redirect_url parameter by adding it to an allowlist of possible values.
app.post('/login', (req, res) => {
const {username, password} = req.body;
if (username === 'bob' && password === '1234') {
req.session.isLoggedIn = true;
const allowlist = ['/account', '/balance'];
if (allowlist.indexOf(req.query.redirect_url) > -1) {
res.redirect(req.query.redirect_url);
} else {
res.redirect('/');
}
} else {
res.render('login', {error: 'Username or password is incorrect'});
}
});
The allowlist variable is just an array of acceptable paths. If we have a matching path, we send the user there, and if not, we go to the default home page.
This option is viable if you have a known quantity of redirect paths. But what if you have hundreds or thousands of redirect paths?
Domain-Based Redirect Approach
If you have a lot of possible redirect paths, then you can redirect based on the domain.
app.post('/login', (req, res) => {
const {username, password} = req.body;
if (username === 'bob' && password === '1234') {
req.session.isLoggedIn = true;
const redirect = req.query.redirect_url ? req.query.redirect_url : '';
res.redirect('https://mybank.com/' + redirect);
} else {
res.render('login', {error: 'Username or password is incorrect'});
}
});
This approach works by appending the redirect_url parameter to your website's domain. This will force the redirects to always be within your own website. If the URL has been tampered with, then the user will simply be redirected to an error page within your website.
An extension to this approach is to display a warning if the user navigates away from your own website. Here's how Google handles the external navigation warning:
Google handing user's redirecting away from their website.
Next Steps and Digging Deeper
In this post, we learned about the open redirect vulnerability and how dangerous it can be. We also discussed a few approaches you can use to fix this vulnerability.
If you want to learn more about this subject, a good place to start would be to see how this vulnerability affects other platforms, like Laravel and Ruby on Rails. The best way to protect yourself is to stay educated.
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.