Preventing broken access control in express Node.js applications
Ben Smitthimedhin
22 mai 2024
0 minutes de lectureAccess control in backend Node.js applications is fundamental to web applications built with the Express web framework. It ensures users can access only the data and functionality they're authorized to use. However, when access control is compromised, users can access data that they shouldn't be able to. This is especially problematic if attackers attempt to manipulate or steal private data.
It's critical that you understand and prevent access control vulnerabilities from happening because leaked private data can be disastrous for many companies. In this article, you'll learn more about broken access control in Node.js applications and strategies to prevent such vulnerabilities when building web applications based on the Express web framework.
What is broken access control?
Access control is the practice of limiting what a user can do and see within an application. A broken access control occurs when there are flaws in the application's implementation of these access control rules, allowing unauthorized users to access sensitive information or perform restricted actions.
For example, if an ordinary user is allowed to view another user's private data, change that user's password, or even escalate the user's privileges to an administrator level due to faulty access control, this can have catastrophic consequences, ranging from data leaks to unauthorized system modifications.
In the following section, you'll take a look at some code examples showing different types of access control vulnerabilities.
Broken vertical access control in Express middleware
One type of access control vulnerability involves instances where there is a malfunction or weakness in vertical access control. This involves scenarios where a regular user gains access to admin-level features or other privileges when they should not have that access. This regular user, therefore, moves up vertically in privilege and can perform modifications, deletions, or other admin-like functions when they shouldn't be able to.
Following are some specific code vulnerabilities that could lead to broken vertical access control:
Unprotected admin panel in Express routes
An unprotected admin panel occurs when routes leading to admin-level features on a web page or server are unprotected by any kind of authorization:
// The express server right below and the `app.listen()` will not appear in other code snippets but // is assumed to be present
const express = require('express');
const app = express();
app.get('/admin', (req, res) => {
res.send('Welcome to the Admin Panel!')
});
app.post('login', (req, res) => {
// code relating to logging in
}
app.listen(3000, () => {
console.log(`Server is running on port 3000`);
});
In this scenario, whoever makes a GET request to the route /admin
is automatically granted access to the message Welcome to the Admin Panel!
regardless of whether or not they have previously logged in or should have the necessary permissions. The /admin
panel is decoupled from the /login
route when it shouldn't be and is, therefore, a form of broken access control.
Express admin panel based on query parameters
An admin panel based on URL parameters is simple to implement; however, without any sort of validation, it can be fairly easy to manipulate:
app.get('/admin', (req, res) => {
if (req.query.isAdmin) {
res.send('Admin panel');
} else {
res.send('Access denied');
}
});
In this example, a user who makes a GET request directly to the /admin
route receives the response Access denied
. The only query required to bypass this denial is to add ?isAdmin=true
to the URL. Since these parameters can be added regardless of who is accessing the admin panel, it perpetuates the same vulnerability as the first example — a regular user having access to admin privileges.
Access control in Express routes through obscurity
In theory, one could obscure the admin panel route by making the route difficult to guess, such as the following Express route middleware:
app.get('/897928394834023-12', (req, res) => {
res.send('Admin Panel')
})
This seemingly makes the admin panel safer, in a sense, but it's an insecure and erroneous way to secure an application. Attackers can find a way to hack into the site map, exposing all the endpoints that a website contains. They can also use fuzzing techniques to test all possible endpoints and see what sort of response gets returned, or they may even want to try accessing the log files to find the logged URL that they can access the admin panel with. This method, while attempting to be clever, does not actually create or provide any sensible security and ultimately perpetuates a broken access control vulnerability.
Clear text logging in Express applications
Developers should also exercise caution with logging mechanisms to prevent the inadvertent disclosure of critical information to application users. For example, an application may create a log entry for a login action with sensitive information without removing the password from the log data:
// Dummy user data
const users = [
{email: 'logan@test.com', password: '1234', role: 'user', id: '1'},
{email: 'megan@test.com', password: '1234', role: 'admin', id: '2' },
];
app.post('/login', (req, res) => {
const user = users.find((u) => u.email === req.body.email);
if (user.role === 'admin') {
console.log(user.email + ' logged in as Admin with password:' + user.password)
}
})
Notice the console.log
statement at the bottom. Whoever has access to the log files would also have access to user data allowing them to log in as anyone with administrative access. This is why logging only necessary information within a given context is important. Additionally, it's generally a good idea to make sure that no one logs critical information that would allow hackers to exploit data in case of a breach.
Broken horizontal access control in Express middleware
While vertical access control issues include scenarios that would allow users to escalate their privileges, horizontal access control issues occur when a user gains access to resources or data that belong to other users of the same level.
Let's take a look at some specific code examples of horizontal access control vulnerabilities used with the Express web framework for Node.js.
User ID in request parameter passing to an express route
An application that allows users to see their user ID in the request parameter could be problematic, especially if a user's data is accessible using that same user ID in the request parameters. This means simply knowing other people's IDs can grant you access to their data.
For example, if your ID is 123
, accessing your account information on a website would mean going to www.[website name].com/account?userId=123
:
// Dummy user data
const users = [
{email: 'logan@test.com', role: 'user', id: 1, accountData: 'secret!'},
{email: 'megan@test.com', role: user, id: 2, accountData: 'secret two!' },
];
app.get('/account', (req, res) => {
const userId = req.query.userId; // Get the user ID from the request parameter
if (userId) {
const user = users.find((u) => u.id === Number(userId));
if (user) {
res.send(`Account information for ${user.username}: ${user.accountData}`);
} else {
res.send('User not found');
}
} else {
res.send('User ID not provided');
}
});
In this example, by making a GET request to /account
with the query userId=1
, you can easily get the accountData
object from logan@test.com
, even though you may be logged in as someone else. Doing the same GET request with the query userId=2
grants you the accountData
object of megan@test.com
simply by using the ID associated with a given user in the parameter with the query results in getting the accountData
object
This security vulnerability is commonly referred to as insecure direct object references (IDOR) and is apparent in Java, Ruby, and other programming languages.
Unpredictable user ID in a request parameter flows into an Express route middleware
Following the previous example, one may think a well-designed access control means simply obfuscating the user ID. For example, a user with the ID 1
with some modifications, such as hashing and encoding, may become 8a76dh3t
in the query so that accessing other people's user data becomes more difficult:
// Dummy user data
const users = [
{email: 'logan@test.com', role: 'user', id: 1, accountData: 'secret!'},
{email: 'megan@test.com', role: user, id: 2, accountData: 'secret two!' },
];
app.get('/account', (req, res) => {
const obfuscatedId = req.query.id; // Get the obfuscated user ID from the request parameter
if (obfuscatedId) {
const user = users.find((u) => obfuscateUserId(u.id) === obfuscatedId);
if (user) {
res.send(`Account information for ${user.email}: ${user.accountData}`);
} else {
res.send('User not found');
}
} else {
res.send('User ID not provided');
}
});
// Obfuscation function
const obfuscateUserId = (userId) => {
// custom obfuscation logic here
return obfuscatedId;
}
In this example, an obfuscateUserId
function takes the user ID from the list of existing users and obfuscates it. Therefore, making a GET request to the /account
route to gain account information requires knowing and putting the obfuscated ID in the request parameter (for example, userId=8a76dh3t
).
This method doesn’t rely on practical security controls and only perpetuates the broken access control issue. Given time and dedication, hackers can still fuzz their way through and guess the right obfuscated user ID to get in, leaving you with broken access control yet again.
Leveraging missing CSRF protections in Express applications as an attack vector
Lastly, one common way malicious actors might attempt to exploit your Express application's broken access control is by using cross-site request forgery (CSRF) attacks. Simply put, these attacks create false web pages or links that trick you into submitting your information. This information is then attached to a function that sends a legitimate request to the actual website where your data is now accessible.
Unless CSRF protection is implemented, all the example server applications you've reviewed can be attached to a false form on a web page where a user inputs data and sends it along for a malicious party to exploit.
How to prevent broken access control vulnerabilities in Node.js
Now that you've seen some examples of vertical and horizontal access control vulnerabilities, it's time to look at general strategies for preventing them.
Use the latest versions of libraries
One way to ensure your access control is built to higher standards is to use well-respected npm packages that are maintained and secured. However, Node.js and its package ecosystem frequently receive updates that address security vulnerabilities, so it is essential also keep them updated.
Keeping your Node.js runtime and packages up-to-date is essential to reducing the risk of known security issues. One way to do this is simply to check the health of your package.json
file using Snyk Advisor, which tells you which library contains vulnerabilities that need to be addressed or if an npm package becomes deprecated or unmaintained.
Dragging and dropping an example package.json
file to Snyk Advisor will show you that there are several potentially problematic npm packages in this project:
Looking closer, Snyk Advisor shows you that loader
hasn’t had new releases in eight years and is not very popular among npm users compared to other packages. Regularly checking for updates and promptly applying them or even replacing your outdated libraries in your project are good initial steps to ensure your access control remains secure.
Don't rely on obfuscation alone
As you saw in some of the example applications, security through obscurity is not a reliable strategy. If an attacker can ultimately guess their way into gaining access (or fuzz their way through), your application is unsafe. You should implement proper access control mechanisms and authentication checks to ensure only authorized users can access sensitive features and data.
In the previous example, you had a route with obscure numbers as the endpoint:
app.get('/897928394834023-12', (req, res) => {
res.send('Admin Panel')
})
Practically, it's usually safer to trust the tried-and-true method of securing data with proper authentication schemes before access is granted. While using more secure login libraries like Passport.js
or AWS Cognito
is recommended, a rough sketch of a login page without any external libraries is shown here for educational purposes:
// Dummy user data
const users = [
{email: 'logan@test.com', password: '1234', role: 'user', id: '1'},
{email: 'megan@test.com', password: '1234', role: 'admin', id: '2' }
];
app.post('/login', (req, res) => {
// Generally, we should ensure HTTPS is used to encrypt the request body
const user = users.find((u) => u.email === req.body.email && u.password === req.body.password);
if (!user) {
res.send('Error page')
}
if (user.role === 'admin') {
res.send('Admin panel');
}
res.send('User panel')
})
Instead of obfuscating the admin panel route, users can directly access /login
but must provide the correct email and password in the request body before they are given access to the admin panel. This way, you're not relying on risky obfuscation methods and can ensure you have more control over which information and/or response each user gets.
Deny access by default and grant granular permissions
When deciding which users should gain access to different data types in your application, it's often wise to begin from the ground up. You should adopt the principle of least privilege, where users are initially denied access to everything and gain access to specific resources and actions only when necessary.
Additionally, you should implement fine-grained access control that moves up slowly, which ensures users can perform only the actions they are explicitly authorized to perform. Using the previous example, having additional roles may allow you to ensure more fine-grained controls:
app.post('/login', (req, res) => {
// Generally, we should ensure HTTPS is used to encrypt the request body
const user = users.find((u) => u.email === req.body.email && u.password === req.body.password);
if (!user) {
res.send('Error page')
}
switch (user.role) {
case 'admin':
res.send('Level 3 panel');
break;
case 'manager':
res.send('Level 2 panel');
break;
case 'user+':
res.send('Level 1 panel');
break;
case 'user':
res.send('User panel');
break;
default:
res.send('Unauthorized access');
}
})
Here, the highest privileges are reserved for the “Level 3 panel”
, which belongs only to users with an admin
role. Other users with a manager
role logging in will have access to a “Level 2 panel”
, which, for example, might have information regarding each user but does not contain a delete button.
Anyone not explicitly given a role will automatically receive the response “Unauthorized access”
. Granting granular permissions to users and slowly escalating them as necessary ensures that you do not give extra privileges to users who should not have access to them.
Perform thorough audits and tests
Regularly audit your codebase and conduct security testing, such as static code analysis, dependency scanning, and secure code review processes, to identify access control vulnerabilities and other security issues. It may be helpful to bookmark this (or another) list of possible access control vulnerabilities and use it to ensure your application does not contain them. Additionally, adding unit tests throughout your application is a standard way to ensure every line of code works the way you expect it to work.
Automated scanning tools, such as static application security testing (SAST), should always be considered to help you identify common security problems. With Snyk’s VS Code extension, you get a quick feedback loop of security scans when you save your JavaScript files, including automated remediation advice.
Use Snyk IDE extension for Visual Studio Code
One powerful tool worth highlighting is Snyk. While Snyk has various tools to secure your application, its Visual Studio (VS) Code extension, in particular, can help you detect and fix broken access control vulnerabilities in your Node.js code as you're writing it.
Here's how you can set up the Snyk extension and use it effectively:
Navigate to the Extensions tab in VS Code and search for "Snyk" to find the Snyk Security - Code, Open Source Dependencies, IaC Configurations extension. Click Install:
Once installed, Snyk will ask you to authenticate your account by connecting to your IDE. Click Connect VS Code with Snyk, which opens up an external Snyk web page asking for your permission to connect. Once there, click Authenticate.
Once authenticated, click Scan to scan your code for vulnerabilities. You can also configure the extension as needed.
Using the previous examples of the /admin
, /login
, and /account
routes, Snyk was able to catch twelve security vulnerabilities. Not only that, but Snyk also scans your code for possible code quality, configuration, and open source security issues that pop up in either your application or the libraries that are part of your application:
The Snyk extension scans your code for known security vulnerabilities, including those related to access control. It provides real-time feedback and suggests fixes for the issues it identifies. With this extension, you can catch and address broken access control vulnerabilities before they enter your production environment.
Additionally, Snyk also has a variety of code-scanning tools that you can use. Similar to the VS Code extension, these tools are designed to find security issues in your code as you write it, making it an essential tool for identifying and addressing access control issues in real time.
Conclusion
In this article, you learned about broken access control in Node.js, which is a critical security concern that can lead to unauthorized access to data and privileges in applications built on the Express web framework. From unprotected admin panel Express routes to unpredictable user IDs in request parameters, the examples used in this article illustrate the potential risks of broken access control.
To prevent broken access control vulnerabilities in your Node.js applications, you can implement several key strategies, including using the latest library versions, avoiding security-through-obscurity methods, applying the principle of least privilege, conducting thorough audits and tests, and leveraging tools such as the Snyk Visual Studio Code extension — together, these methods can significantly enhance your application's security. Remember that creating and maintaining a secure application is an ongoing process. Vigilance is key to maintaining the integrity of your applications and data.