A comprehensive guide on securing Node.js applications, aligned with OWASP WSTG and industry best practices.
If you’ve been working with Node.js, you know how it enables fast, scalable web application development. Its event-driven, non-blocking I/O model is ideal for building efficient, real-time applications. However, in the rush to develop new features and deploy code quickly, security can sometimes become a secondary consideration. Balancing feature development with security is a common challenge.
This is where the OWASP Web Security Testing Guide (WSTG) becomes invaluable. If you’re unfamiliar, OWASP (Open Web Application Security Project) is a nonprofit organization dedicated to improving software security. Their WSTG is a comprehensive resource filled with insights on identifying common web vulnerabilities.
In this blog post, I’ll highlight some typical Node.js vulnerabilities as outlined by the OWASP WSTG. I’ll show vulnerable code snippets for each category and, more importantly, walk you through how to fix them. Whether you’re a seasoned Node.js professional or just starting with backend development, this guide will help you build more secure and robust applications.
Let’s dive into the world of Node.js security.
Before we jump into code examples, let’s look at key categories from the OWASP Web Security Testing Guide (WSTG) and how they relate to Node.js applications.
Know what’s out there.
This step involves collecting information useful to an attacker, such as server details, application entry points, and technologies used. Even small details can help piece together an attack. In Node.js applications, default settings or exposed metadata can inadvertently reveal critical details.
Don’t leave the back door open.
This category checks that servers and applications are configured securely. Misconfigurations like default passwords, unnecessary services, or improper file permissions can make it easy for attackers to gain access. Node.js applications need careful configuration to avoid exposing sensitive information or functionality.
Who gets to be who?
This category focuses on how user identities are created and managed. Weak registration processes or poorly defined roles can lead to unauthorized access. Ensuring robust identity management in Node.js applications helps prevent privilege escalation and unauthorized actions.
Are you really who you say you are?
This involves ensuring that only legitimate users can log in. Flaws in authentication mechanisms can allow attackers to bypass login screens altogether. Proper authentication practices are essential in Node.js applications to verify user identities securely.
Just because you’re in doesn’t mean you can do anything.
Even after logging in, users should only have access to what they’re permitted to. Testing ensures that users can’t escalate their privileges or access restricted areas. Implementing strict authorization checks in Node.js applications prevents unauthorized access to resources.
Keep track of who’s who—securely.
Sessions link users to their activities on the server. Poor session management can lead to issues like session hijacking, where an attacker takes over a user’s session. Secure session handling in Node.js is crucial for maintaining user integrity.
Never trust user input—seriously.
This is about ensuring all user-supplied data is validated and sanitized. It helps prevent attacks like SQL injection and Cross-Site Scripting (XSS). Node.js applications must implement robust input validation to safeguard against injection attacks.
Don’t spill secrets when things go wrong.
Proper error handling ensures that error messages don’t reveal sensitive information that could aid an attacker. In Node.js, unhandled exceptions or detailed error messages can expose application internals.
Encryption done right.
Using strong, up-to-date encryption methods is crucial. Weak cryptography can expose sensitive data if an attacker intercepts it. Node.js applications should use secure cryptographic practices to protect data at rest and in transit.
Does the app make sense?
This involves checking that the application behaves as intended and that attackers can’t exploit logical flaws to manipulate processes. Ensuring that business logic is correctly implemented in Node.js prevents misuse of application functionality.
The front end matters too.
We need to test client-side code for vulnerabilities like DOM-based XSS or insecure storage, which can compromise user data. Node.js applications often include client-side JavaScript that requires careful security considerations.
Secure the gateways.
APIs often expose backend functionalities. Testing ensures they don’t provide attackers with direct access to sensitive operations or data. Securing APIs in Node.js is essential to prevent unauthorized access and data breaches.
Understanding these areas helps us identify vulnerabilities in our Node.js applications and figure out how to fix them. In the following sections, we’ll explore each category in detail, providing examples and best practices to enhance your application’s security.
Information Gathering is often the first step an attacker takes to learn more about your application. The more information they can collect, the easier it becomes for them to identify and exploit vulnerabilities.
By default, Express.js includes settings that can inadvertently reveal information about your server. A common example is the X-Powered-By
HTTP header, which indicates that your application is using Express.
In the following setup, every HTTP response includes the X-Powered-By: Express
header:
const express = require('express');
const app = express();
// Your routes here
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Why This Is a Problem
Mitigation
You can disable this header to make it harder for attackers to fingerprint your server:
const express = require('express');
const app = express();
// Disable the X-Powered-By header
app.disable('x-powered-by');
// Your routes here
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Enhanced Mitigation with Helmet
A better approach is to use the helmet
middleware, which sets various HTTP headers to improve your app’s security:
const express = require('express');
const helmet = require('helmet');
const app = express();
// Use Helmet to secure headers
app.use(helmet());
// Your routes here
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Why Use Helmet?
Attackers often use API fuzzing to discover hidden endpoints by sending a high volume of requests with different inputs. If you have an unprotected API documentation endpoint like /api/docs
, you may inadvertently provide attackers with a roadmap to your API.
Imagine you’ve set up Swagger UI for API documentation:
const express = require('express');
const app = express();
const swaggerUi = require('swagger-ui-express');
const swaggerDocument = require('./swagger.json');
// Expose API docs publicly
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
// Your routes here
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Why This Is a Problem
/api/docs
publicly reveals detailed information about your API endpoints, request parameters, and expected responses.Mitigation
Restrict access to your API documentation:
Serve Documentation Only in Non-Production Environments
if (process.env.NODE_ENV !== 'production') {
const swaggerUi = require('swagger-ui-express');
const swaggerDocument = require('./swagger.json');
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
}
Protect Documentation with Authentication
Alternatively, require authentication to access the documentation:
const express = require('express');
const basicAuth = require('express-basic-auth');
const app = express();
const swaggerUi = require('swagger-ui-express');
const swaggerDocument = require('./swagger.json');
// Set up basic authentication for the docs
app.use(
'/api/docs',
basicAuth({
users: { admin: 'password' },
challenge: true,
}),
swaggerUi.serve,
swaggerUi.setup(swaggerDocument)
);
// Your routes here
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Preventing API Fuzzing
While you cannot completely prevent someone from attempting to fuzz your API, you can make it less effective:
Implement Rate Limiting
Limit the number of requests a user can make within a specific time frame to mitigate brute-force and fuzzing attacks:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per window
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
});
// Apply the rate limiting middleware to all requests
app.use(limiter);
Why This Helps
Configuration and deployment management are critical aspects of application security. Misconfigurations can serve as open doors for attackers. Below are common issues in Node.js applications and how to address them.
Running your application in development mode on a production server can expose detailed error messages and stack traces.
// app.js
const express = require('express');
const app = express();
// Error handling middleware
app.use((err, req, res, next) => {
res.status(500).send(err.stack); // Sends stack trace to the client
});
// Your routes here
app.listen(3000);
Why This Is a Problem
Mitigation
Set NODE_ENV
to 'production'
and use generic error messages in production:
// app.js
const express = require('express');
const app = express();
// Your routes here
// Error handling middleware
if (app.get('env') === 'production') {
// Production error handler
app.use((err, req, res, next) => {
// Log the error internally
console.error(err);
res.status(500).send('An unexpected error occurred.');
});
} else {
// Development error handler (with stack trace)
app.use((err, req, res, next) => {
res.status(500).send(`<pre>${err.stack}</pre>`);
});
}
app.listen(3000);
Best Practices
NODE_ENV
is set to 'production'
in your production environment.Using default or weak credentials, such as a simple secret key for signing JSON Web Tokens (JWTs), is a common security mistake.
const express = require('express');
const jwt = require('jsonwebtoken');
const app = express();
// Weak secret key
const SECRET_KEY = 'secret';
app.post('/login', (req, res) => {
// Authenticate user (authentication logic not shown)
const userId = req.body.userId;
// Sign the JWT with a weak secret
const token = jwt.sign({ userId }, SECRET_KEY);
res.json({ token });
});
app.get('/protected', (req, res) => {
const token = req.headers['authorization'];
try {
// Verify the token using the weak secret
const decoded = jwt.verify(token, SECRET_KEY);
res.send('Access granted to protected data');
} catch (err) {
res.status(401).send('Unauthorized');
}
});
app.listen(3000, () => {
console.log('Server started on port 3000');
});
Why This Is a Problem
'secret'
makes it easy for attackers to guess or brute-force the key.Mitigation
Implementation
// Secure secret key from environment variables
const SECRET_KEY = process.env.JWT_SECRET;
if (!SECRET_KEY) {
throw new Error('JWT_SECRET environment variable is not set.');
}
app.post('/login', (req, res) => {
// Authenticate user
const userId = req.body.userId;
// Sign the JWT with the secure secret
const token = jwt.sign({ userId }, SECRET_KEY, { expiresIn: '1h' });
res.json({ token });
});
Best Practices
Not setting essential HTTP security headers can leave your application vulnerable to various attacks such as Cross-Site Scripting (XSS), Clickjacking, and MIME-type sniffing.
Potential Risks
X-Frame-Options
header, attackers can embed your site in iframes to trick users into performing unintended actions.X-Content-Type-Options
header, browsers may interpret files as a different MIME type, leading to security risks.Strict-Transport-Security
header, browsers may not enforce HTTPS connections, exposing data to man-in-the-middle attacks.Mitigation
Use the helmet
middleware to set these headers appropriately:
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'trusted-cdn.com'],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
},
frameguard: { action: 'deny' },
referrerPolicy: { policy: 'no-referrer' },
hsts: {
maxAge: 31536000, // 1 year in seconds
includeSubDomains: true,
preload: true,
},
xssFilter: true,
noSniff: true,
})
);
// Your routes here
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
Explanation of Key Headers
frameguard
): Protects against Clickjacking by controlling whether your site can be embedded in iframes.hsts
): Instructs browsers to only use HTTPS for all future requests to your domain.xssFilter
): Enables the Cross-site scripting (XSS) filter built into most browsers.noSniff
): Prevents browsers from MIME-sniffing a response away from the declared content type.Best Practices
Identity management focuses on how user identities are created, managed, and secured within your application. Flaws in this area can lead to unauthorized access and compromised accounts.
Allowing weak usernames or revealing information about user accounts can make it easier for attackers to breach your system.
// User registration route
app.post('/register', async (req, res) => {
const { username, password } = req.body;
// No validation on username
const user = new User({ username, password });
await user.save();
res.send('User registered successfully');
});
Issues:
Similarly, insecure password reset functionality can reveal too much information, which can be exploited.
// Password reset route
app.post('/reset-password', async (req, res) => {
const { email } = req.body;
const user = await User.findOne({ email });
if (!user) {
res.status(404).send('Email not found');
} else {
// Send reset email (implementation not shown)
res.send('Password reset email sent');
}
});
Issue:
Implement Username Validation and Use Generic Error Messages
Enforce strong username policies and avoid disclosing whether a username is available.
const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
// User registration route with validation
app.post(
'/register',
[
body('username')
.isAlphanumeric()
.isLength({ min: 5 })
.withMessage('Username must be at least 5 alphanumeric characters'),
body('password')
.isStrongPassword()
.withMessage('Password must be strong'),
],
async (req, res) => {
// Handle validation results
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).send('Invalid input');
}
const { username, password } = req.body;
// Check if username is taken
const existingUser = await User.findOne({ username });
if (existingUser) {
// Send generic error message
return res.status(400).send('Registration failed');
}
// Hash the password and create new user
const hashedPassword = await bcrypt.hash(password, 10);
const user = new User({ username, password: hashedPassword });
await user.save();
res.send('User registered successfully');
}
);
Explanation:
Provide Uniform Responses in Password Reset Functionality
Always respond with the same message regardless of whether the email exists.
// Secure password reset route
app.post('/reset-password', async (req, res) => {
const { email } = req.body;
// Always respond with the same message
res.send('If an account exists for this email, a password reset link has been sent.');
// Proceed without revealing if the email exists
const user = await User.findOne({ email });
if (user) {
// Send reset email (implementation not shown)
}
});
Explanation:
Authentication testing focuses on verifying users’ identities securely. Weaknesses in authentication mechanisms can lead to unauthorized access.
Attackers may attempt to guess user passwords or two-factor authentication (2FA) codes by trying numerous combinations—a technique known as brute-force attacking.
// Login route without protections
app.post('/login', async (req, res) => {
const { username, password, twoFactorCode } = req.body;
// Find the user
const user = await User.findOne({ username });
if (!user) {
return res.status(401).send('Invalid username or password');
}
// Check the password
const passwordMatch = await bcrypt.compare(password, user.password);
if (!passwordMatch) {
return res.status(401).send('Invalid username or password');
}
// If 2FA is enabled, verify the code
if (user.isTwoFactorEnabled) {
if (twoFactorCode !== user.twoFactorCode) {
return res.status(401).send('Invalid 2FA code');
}
}
// Generate a session or token
res.send('Login successful');
});
Issues:
Implement Rate Limiting
Limit the number of login attempts from a single IP address within a specific time frame.
const rateLimit = require('express-rate-limit');
// Apply rate limiting to login route
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 login attempts per window
message: 'Too many login attempts. Please try again later.',
});
app.post('/login', loginLimiter, async (req, res) => {
// Existing login logic
});
Explanation:
Use CAPTCHA After Failed Attempts
Introduce a CAPTCHA after a certain number of failed login attempts to verify that a human is interacting with the application.
// Middleware to check if CAPTCHA is needed
function checkCaptcha(req, res, next) {
if (req.session.loginAttempts >= 3) {
// Verify CAPTCHA (implementation depends on the CAPTCHA service used)
const captchaValid = verifyCaptcha(req.body.captchaToken);
if (!captchaValid) {
return res.status(400).send('CAPTCHA verification failed');
}
}
next();
}
app.post('/login', loginLimiter, checkCaptcha, async (req, res) => {
// Existing login logic
});
Explanation:
Use Time-Based One-Time Passwords (TOTP) for 2FA
Enhance 2FA by using time-based one-time passwords instead of static codes.
const speakeasy = require('speakeasy');
// When enabling 2FA for a user
app.post('/enable-2fa', async (req, res) => {
const secret = speakeasy.generateSecret();
// Save secret.base32 in the user's record
req.user.twoFactorSecret = secret.base32;
await req.user.save();
res.send({ otpauthUrl: secret.otpauth_url });
});
// Verify 2FA code during login
if (user.isTwoFactorEnabled) {
const tokenValidates = speakeasy.totp.verify({
secret: user.twoFactorSecret,
encoding: 'base32',
token: req.body.twoFactorCode,
});
if (!tokenValidates) {
return res.status(401).send('Invalid 2FA code');
}
}
Explanation:
Allowing users to choose weak passwords like “123456” or “password” increases the risk of unauthorized access.
Enforce password complexity requirements using validation.
const { body, validationResult } = require('express-validator');
app.post(
'/register',
body('password')
.isStrongPassword({
minLength: 8,
minLowercase: 1,
minUppercase: 1,
minNumbers: 1,
minSymbols: 1,
})
.withMessage(
'Password must be at least 8 characters long and include uppercase, lowercase, number, and symbol'
),
async (req, res) => {
// Handle validation errors
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).send('Invalid password');
}
// Proceed with registration
}
);
Explanation:
Additionally, check if the chosen password has been compromised in known data breaches using services like Have I Been Pwned (HIBP).
const crypto = require('crypto');
const axios = require('axios');
// Function to check if the password has been pwned
async function isPasswordPwned(password) {
// Hash the password using SHA-1
const sha1Hash = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
// Split the hash into prefix (first 5 chars) and suffix
const prefix = sha1Hash.slice(0, 5);
const suffix = sha1Hash.slice(5);
// API URL with the prefix
const url = `https://api.pwnedpasswords.com/range/${prefix}`;
try {
// Make a GET request to the HIBP API
const response = await axios.get(url);
const hashes = response.data.split('\\r\\n');
// Check if the suffix exists in the returned hashes
for (const line of hashes) {
const [hashSuffix] = line.split(':');
if (hashSuffix === suffix) {
return true; // Password has been pwned
}
}
return false; // Password is safe
} catch (error) {
console.error('Error checking password against HIBP:', error);
// Decide how to handle errors (e.g., block registration or allow)
return false;
}
}
// Registration route with HIBP check
app.post('/register', async (req, res) => {
const { username, password } = req.body;
// Validate username and password (omitted for brevity)
// Check if the password has been pwned
const passwordPwned = await isPasswordPwned(password);
if (passwordPwned) {
return res
.status(400)
.send('This password has been compromised in a data breach. Please choose a different password.');
}
// Proceed with registration
const hashedPassword = await bcrypt.hash(password, 10);
const user = new User({ username, password: hashedPassword });
await user.save();
res.send('Registration successful');
});
Explanation:
Insecure password reset mechanisms can be exploited to gain unauthorized access.
// Password reset without token expiration
app.post('/reset-password', async (req, res) => {
const { email } = req.body;
// Generate reset token
const resetToken = crypto.randomBytes(20).toString('hex');
// Save token to user record without expiration
const user = await User.findOne({ email });
if (user) {
user.resetToken = resetToken;
await user.save();
// Send email with reset link
sendResetEmail(user.email, `https://example.com/reset-password/${resetToken}`);
}
res.send('If your email is registered, you will receive a password reset link.');
});
Issues:
Use Secure, Expiring Tokens
Generate cryptographically secure tokens and set an expiration time.
const crypto = require('crypto');
// Generate a secure token with expiration
app.post('/reset-password', async (req, res) => {
const { email } = req.body;
// Find the user
const user = await User.findOne({ email });
if (user) {
// Generate secure token
const resetToken = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');
// Set token and expiration (e.g., 1 hour)
user.resetToken = tokenHash;
user.resetTokenExpires = Date.now() + 3600000; // 1 hour
await user.save();
// Send email with the plain reset token
sendResetEmail(user.email, `https://example.com/reset-password/${resetToken}`);
}
res.send('If your email is registered, you will receive a password reset link.');
});
// Verify the token during password reset
app.post('/reset-password/:token', async (req, res) => {
const resetToken = req.params.token;
const tokenHash = crypto.createHash('sha256').update(resetToken).digest('hex');
// Find user with matching token and valid expiration
const user = await User.findOne({
resetToken: tokenHash,
resetTokenExpires: { $gt: Date.now() },
});
if (!user) {
return res.status(400).send('Invalid or expired token');
}
// Reset the password
user.password = await bcrypt.hash(req.body.newPassword, 10);
user.resetToken = undefined;
user.resetTokenExpires = undefined;
await user.save();
res.send('Password has been reset successfully');
});
Explanation:
Notify Users of Password Changes
Send an email notification when a password is changed.
// After password reset
sendNotificationEmail(user.email, 'Your password has been changed');
Explanation:
Poorly implemented lockout mechanisms can either allow brute-force attacks or cause denial of service.
Issues:
Implement Temporary Account Lockouts
Lock accounts temporarily after several failed attempts.
// During login attempt
if (user.loginAttempts >= 5) {
// Lock account for an exponential time
const lockTime = Math.min(Math.pow(2, user.loginAttempts - 5) * 1000, MAX_LOCK_TIME);
user.lockUntil = Date.now() + lockTime;
await user.save();
}
Explanation:
Avoid Account Enumeration
Provide generic error messages during authentication processes.
// Instead of specific messages, use a generic one
return res.status(401).send('Invalid credentials');
Explanation:
Authorization Testing ensures that users can only access resources and perform actions they are permitted to. Even after a user is authenticated, we need to verify that they do not exceed their privileges. Let’s examine some common pitfalls and how to address them.
A common mistake is not validating a user’s identity before performing actions on their behalf. For example, fetching a resource by its ID without verifying that the requesting user is authorized to access it.
// Fetch a user's order without checking ownership
app.get('/orders/:orderId', async (req, res) => {
const order = await Order.findById(req.params.orderId);
if (!order) {
return res.status(404).send('Order not found');
}
res.json(order);
});
Issue:
orderId
in the URL, potentially accessing other users’ orders.Validate that the order belongs to the requesting user. Ensure the user is authenticated and fetch the order only if it belongs to them.
// Middleware to verify authentication
function isAuthenticated(req, res, next) {
// Assume user ID is stored in req.user.id after authentication
if (req.user && req.user.id) {
next();
} else {
res.status(401).send('Unauthorized');
}
}
app.get('/orders/:orderId', isAuthenticated, async (req, res) => {
const order = await Order.findOne({ _id: req.params.orderId, userId: req.user.id });
if (!order) {
return res.status(404).send('Order not found or access denied');
}
res.json(order);
});
Explanation:
userId: req.user.id
, we ensure that the order belongs to the authenticated user.Occurs when a user accesses resources or functions of another user with the same permission level.
app.get('/users/:userId/profile', isAuthenticated, async (req, res) => {
const user = await User.findById(req.params.userId);
if (!user) {
return res.status(404).send('User not found');
}
res.json(user.profile);
});
Issue:
userId
parameter to access another user’s profile.Occurs when a user gains higher-level privileges than intended.
app.post('/admin/create-user', isAuthenticated, async (req, res) => {
// Logic to create a new user
res.send('User created');
});
Issue:
Ensure users can only access their own profile unless they have elevated permissions.
app.get('/users/:userId/profile', isAuthenticated, async (req, res) => {
if (req.user.id !== req.params.userId && req.user.role !== 'admin') {
return res.status(403).send('Forbidden');
}
const user = await User.findById(req.params.userId);
if (!user) {
return res.status(404).send('User not found');
}
res.json(user.profile);
});
Explanation:
req.user.id
with req.params.userId
to ensure the user is accessing their own profile.Ensure that only users with admin privileges can access admin routes.
function isAdmin(req, res, next) {
if (req.user && req.user.role === 'admin') {
next();
} else {
res.status(403).send('Forbidden');
}
}
app.post('/admin/create-user', isAuthenticated, isAdmin, async (req, res) => {
// Logic to create a new user
res.send('User created');
});
Explanation:
isAuthenticated
and isAdmin
middleware to verify the user’s role.IDOR vulnerabilities occur when an application provides direct access to objects based on user-supplied input without proper authorization checks.
app.get('/invoices/:invoiceNumber', isAuthenticated, async (req, res) => {
const invoice = await Invoice.findOne({ invoiceNumber: req.params.invoiceNumber });
if (!invoice) {
return res.status(404).send('Invoice not found');
}
res.json(invoice);
});
Issue:
invoiceNumber
.Assign unique identifiers that are hard to guess and ensure the user is authorized to access the resource.
app.get('/invoices/:invoiceId', isAuthenticated, async (req, res) => {
const invoice = await Invoice.findOne({ _id: req.params.invoiceId, userId: req.user.id });
if (!invoice) {
return res.status(404).send('Invoice not found or access denied');
}
res.json(invoice);
});
Explanation:
userId: req.user.id
in the query to ensure the invoice belongs to the user._id
field, which is not easily guessable.By carefully implementing authorization checks and validating user permissions, you significantly reduce the risk of unauthorized access in your application. Always verify that users have the right to perform an action before executing it.
Session management is critical for maintaining the security of user interactions with your application. JSON Web Tokens (JWTs) are commonly used in Node.js applications for authentication and session management. While JWTs offer convenience, improper implementation can lead to serious security issues.
const jwt = require('jsonwebtoken');
// Generating a token without expiration
function generateToken(user) {
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
return token;
}
Issue:
Always set an expiration time when generating JWTs.
// Generating a token with expiration
function generateToken(user) {
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
expiresIn: '1h', // Token expires in 1 hour
});
return token;
}
Explanation:
expiresIn
ensures tokens are valid only for a specified duration.// Using a weak, hard-coded secret
const jwtSecret = 'secret';
function generateToken(user) {
const token = jwt.sign({ userId: user.id }, jwtSecret, { expiresIn: '1h' });
return token;
}
Issue:
Use a strong, randomly generated secret key stored securely.
// Using a strong secret from environment variables
const jwtSecret = process.env.JWT_SECRET;
function generateToken(user) {
const token = jwt.sign({ userId: user.id }, jwtSecret, { expiresIn: '1h' });
return token;
}
Generating a Strong Secret:
Generate a long, random string using the Node.js crypto module:
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
Explanation:
// Storing token in localStorage on the client side
localStorage.setItem('token', token);
Issue:
localStorage
can be accessed by JavaScript, making them vulnerable to cross-site scripting (XSS) attacks.Store tokens in HTTP-only cookies.
// On the server side, set the token in an HTTP-only cookie
res.cookie('token', token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // Only send cookie over HTTPS
sameSite: 'Strict', // Helps prevent CSRF
});
Explanation:
Tokens remain valid until they expire, and there’s no way to invalidate them server-side upon user logout or if a token is compromised.
Consequences:
Implement a token blacklist or token versioning.
const tokenBlacklist = new Set();
// On logout
app.post('/logout', (req, res) => {
const token = req.cookies.token;
tokenBlacklist.add(token);
res.clearCookie('token');
res.send('Logged out successfully');
});
// Middleware to check if token is blacklisted
function checkBlacklist(req, res, next) {
const token = req.cookies.token;
if (tokenBlacklist.has(token)) {
return res.status(401).send('Token has been revoked');
}
next();
}
// Use the middleware for protected routes
app.use(checkBlacklist);
Explanation:
// Token payload includes sensitive information
const token = jwt.sign(
{
userId: user.id,
email: user.email,
secretCode: user.secretCode, // Sensitive data
role: user.role,
},
jwtSecret,
{ expiresIn: '1h' }
);
Issue:
Include only essential information in the token payload.
// Minimal token payload
const token = jwt.sign(
{
userId: user.id,
role: user.role,
},
jwtSecret,
{ expiresIn: '1h' }
);
Explanation:
Many developers believe that using JWTs eliminates the risk of CSRF attacks, especially when tokens are stored in client-side storage like localStorage
or sessionStorage
. However, CSRF vulnerabilities can still exist, particularly when JWTs are stored in cookies.
User Authentication: The user logs in, and the server sets a JWT in an HTTP-only cookie.
// Server-side: Setting the JWT in a cookie
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'Lax', // Controls cross-site requests
});
Forged Request: The malicious site initiates a request to your application.
<!-- Malicious site's HTML -->
<img src="<https://your-app.com/api/transfer-funds?amount=1000&toAccount=attackerAccount>" />
Use the SameSite
Cookie Attribute
Set the SameSite
attribute to Strict
or Lax
to control when cookies are sent.
res.cookie('token', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict', // Cookie sent only for same-site requests
});
Explanation:
Implement CSRF Tokens
Use CSRF tokens to verify the authenticity of state-changing requests.
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
app.use(cookieParser());
app.use(csrf({ cookie: true }));
// In your routes
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
app.post('/process', (req, res) => {
res.send('Data is being processed');
});
Explanation:
Input validation is crucial for ensuring that all user-supplied data is properly validated, sanitized, and securely handled. Improper input validation can lead to various attacks such as SQL Injection, Cross-Site Scripting (XSS), and others. In this section, we will explore different aspects of input validation, discuss the importance of strict input validation schemes for each route, and highlight best practices such as triggering errors when unnecessary parameters are provided.
It is imperative to never trust user input. Always validate and sanitize data coming from all possible sources, including:
Implementing comprehensive validation helps prevent malicious data from entering your system.
Example using express-validator
:
const { body, validationResult } = require('express-validator');
app.post(
'/register',
[
body('username')
.isAlphanumeric()
.withMessage('Username must be alphanumeric'),
body('email')
.isEmail()
.withMessage('Enter a valid email address'),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters long'),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Proceed with registration
}
);
Explanation:
express-validator
library provides middleware for validating and sanitizing user input.Sanitizing and escaping user input prevents injection attacks by removing or neutralizing malicious code.
Example using sanitize-html
:
const sanitizeHtml = require('sanitize-html');
app.post('/comment', (req, res) => {
const sanitizedComment = sanitizeHtml(req.body.comment);
// Save sanitizedComment to the database
res.send('Comment submitted successfully');
});
Explanation:
sanitize-html
removes HTML tags and attributes that could be used for XSS attacks.Defining exactly what is acceptable (whitelisting) is more secure than filtering out known bad input (blacklisting), as attackers may find ways around blacklists.
const allowedRoles = ['admin', 'user', 'guest'];
body('role')
.isIn(allowedRoles)
.withMessage('Invalid user role');
Explanation:
Protect against SQL/NoSQL injection by using parameterized queries or Object-Relational Mapping (ORM) methods that handle input sanitization.
Example using Mongoose (for MongoDB):
app.get('/user/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id); // Mongoose handles input sanitization
if (!user) {
return res.status(404).send('User not found');
}
res.json(user);
} catch (error) {
res.status(500).send('An error occurred');
}
});
Explanation:
findById
, input is properly handled, preventing injection attacks.When accepting file uploads, validate file types and sizes, and store files securely to prevent malicious files from compromising your system.
Example using multer
:
const multer = require('multer');
const path = require('path');
const storage = multer.diskStorage({
destination: 'uploads/',
filename: (req, file, cb) => {
// Use a unique filename to prevent overwriting
cb(null, Date.now() + path.extname(file.originalname));
},
});
const upload = multer({
storage: storage,
limits: { fileSize: 1024 * 1024 }, // 1MB limit
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif'];
if (!allowedTypes.includes(file.mimetype)) {
return cb(new Error('Only images are allowed'));
}
cb(null, true);
},
});
app.post('/upload', upload.single('avatar'), (req, res) => {
res.send('File uploaded successfully');
});
Explanation:
Accepting extra parameters can introduce security risks. If unnecessary parameters are provided, respond with an error to enforce strict API contracts.
function validateParams(allowedParams) {
return (req, res, next) => {
const extras = Object.keys(req.body).filter(
key => !allowedParams.includes(key)
);
if (extras.length) {
return res.status(400).send(`Unexpected parameters: ${extras.join(', ')}`);
}
next();
};
}
app.post('/update', validateParams(['name', 'email']), (req, res) => {
// Update logic
res.send('User updated successfully');
});
Explanation:
child_process.execFile
instead of exec
.Proper error handling is crucial for both application security and user experience. It ensures that your application fails gracefully without exposing sensitive information that could be leveraged by attackers. Below are key considerations and best practices for error handling in Node.js applications.
Implement a centralized error-handling middleware to catch unhandled errors and prevent them from crashing your application.
// Global error handler
app.use((err, req, res, next) => {
// Log the error details internally
console.error('Unhandled error:', err);
// Send a generic error response to the client
res.status(500).send('An unexpected error occurred');
});
Explanation:
Ensure that all asynchronous code has proper error handling to prevent unhandled promise rejections or exceptions that could crash the server.
app.get('/data', (req, res) => {
fetchDataFromAPI() // Returns a promise
.then((data) => res.json(data));
// Missing .catch() block to handle errors
});
Issue:
Always handle promise rejections:
app.get('/data', (req, res) => {
fetchDataFromAPI()
.then((data) => res.json(data))
.catch((error) => {
console.error('Error fetching data:', error);
res.status(500).send('Failed to retrieve data');
});
});
Or use async/await with try-catch blocks:
app.get('/user/:id', async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).send('User not found');
}
res.json(user);
} catch (error) {
console.error('Error fetching user:', error);
res.status(500).send('An unexpected error occurred');
}
});
Explanation:
.catch()
method to handle rejections.To reduce boilerplate code, you can use a wrapper function that handles exceptions in async route handlers.
Example using express-async-handler
:
const asyncHandler = require('express-async-handler');
app.get(
'/user/:id',
asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).send('User not found');
}
res.json(user);
})
);
Explanation:
Never expose stack traces or detailed error messages to the client, as they may reveal sensitive information about your application’s internals.
// Vulnerable error response
app.use((err, req, res, next) => {
res.status(500).send(err.stack); // Exposes stack trace
});
Mitigation:
Many runtime errors occur due to invalid input. Implement comprehensive input validation to prevent these errors from occurring.
app.post(
'/submit',
[
body('email').isEmail(),
body('age').isInt({ min: 0 }),
],
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Proceed with processing
}
);
Explanation:
Set up handlers for uncaught exceptions and unhandled promise rejections to prevent the application from crashing.
process.on('uncaughtException', (err) => {
console.error('Uncaught Exception:', err);
// Consider exiting the process after handling
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Consider exiting the process after handling
});
Explanation:
Cryptography is essential for securing sensitive data in your Node.js applications. However, improper use of cryptographic functions can introduce vulnerabilities that compromise data confidentiality and integrity. In this section, we will explore common cryptographic weaknesses and provide best practices for addressing them.
crypto
ModuleImproper use of the Node.js crypto
module can lead to weak encryption or hashing mechanisms.
const crypto = require('crypto');
// Insecure password hashing using SHA-1
function hashPassword(password) {
return crypto.createHash('sha1').update(password).digest('hex');
}
Why This Is a Problem
Use a dedicated password hashing library designed for security. Libraries like bcrypt
incorporate salting and multiple iterations, making them resistant to brute-force and rainbow table attacks.
const bcrypt = require('bcrypt');
// Secure password hashing with bcrypt
async function hashPassword(password) {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
}
Best Practices
Storing secret keys, API keys, or credentials directly in your code is a critical security risk. If the codebase is ever exposed, these secrets can be compromised.
// Hardcoded API key
const apiKey = '1234567890abcdef';
// Using the API key in a function
function callExternalService() {
// Use the apiKey here
}
Why This Is a Problem
Store secrets in environment variables or use a secrets management service.
// Load API key from environment variables
const apiKey = process.env.API_KEY;
if (!apiKey) {
throw new Error('API_KEY is not defined in environment variables');
}
Best Practices
.env
are not checked into version control systems.Using non-cryptographically secure random number generators for tokens, session identifiers, or security codes can lead to predictable values.
// Insecure token generation using Math.random()
function generateToken() {
return Math.random().toString(36).substring(2);
}
Why This Is a Problem
Math.random()
is not designed for cryptographic purposes and can produce predictable results.Use crypto.randomBytes()
or crypto.randomInt()
for generating cryptographically secure random values.
const crypto = require('crypto');
// Secure token generation
function generateToken() {
return crypto.randomBytes(32).toString('hex'); // Generates a 256-bit token
}
Explanation
crypto.randomBytes()
provides high-quality random data suitable for security-sensitive applications.Saving personal data, credentials, or sensitive information in plaintext in the database exposes it to potential breaches.
// Storing plaintext sensitive data
const user = new User({
username: req.body.username,
password: req.body.password, // Plaintext password
email: req.body.email,
});
await user.save();
Why This Is a Problem
Encrypt sensitive data before storing it, and securely hash passwords.
const crypto = require('crypto');
const bcrypt = require('bcrypt');
// Encryption function for sensitive fields
function encrypt(text) {
const algorithm = 'aes-256-cbc';
const key = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
// Storing encrypted data and securely hashed password
async function createUser(req, res) {
const encryptedEmail = encrypt(req.body.email);
const hashedPassword = await bcrypt.hash(req.body.password, 12);
const user = new User({
username: req.body.username,
password: hashedPassword,
email: encryptedEmail,
});
await user.save();
res.send('User created successfully.');
}
Best Practices
Creating password reset tokens that are predictable, reusable, or do not expire can be exploited by attackers to gain unauthorized access.
// Weak reset token generation
const resetToken = `${user.id}${Date.now()}`;
// Sending reset link
sendResetEmail(user.email, `https://example.com/reset/${resetToken}`);
Why This Is a Problem
Use secure, random tokens with expiration times, and ensure they are invalidated after use.
const crypto = require('crypto');
// Generate secure reset token
function generateResetToken() {
return crypto.randomBytes(32).toString('hex'); // 256-bit token
}
// Request password reset
app.post('/reset-password', async (req, res) => {
const user = await User.findOne({ email: req.body.email });
if (user) {
const resetToken = generateResetToken();
user.resetPasswordToken = crypto.createHash('sha256').update(resetToken).digest('hex');
user.resetPasswordExpires = Date.now() + 3600000; // Token valid for 1 hour
await user.save();
// Send reset email with the plain token
sendResetEmail(user.email, `https://example.com/reset/${resetToken}`);
}
res.send('If your email is registered, you will receive a password reset link.');
});
// Verify reset token and reset password
app.post('/reset/:token', async (req, res) => {
const hashedToken = crypto.createHash('sha256').update(req.params.token).digest('hex');
const user = await User.findOne({
resetPasswordToken: hashedToken,
resetPasswordExpires: { $gt: Date.now() },
});
if (!user) {
return res.status(400).send('Invalid or expired token.');
}
user.password = await bcrypt.hash(req.body.password, 12);
user.resetPasswordToken = undefined;
user.resetPasswordExpires = undefined;
await user.save();
res.send('Password has been reset successfully.');
});
Best Practices
Logging sensitive information such as encryption keys, tokens, or passwords can lead to their exposure.
console.log(`User ${user.id} logged in with token: ${token}`);
Why This Is a Problem
Avoid logging sensitive data and implement proper logging practices.
console.log(`User ${user.id} logged in successfully.`);
Best Practices
By carefully implementing cryptography and adhering to best practices, you can significantly enhance the security of your Node.js applications. Always use strong, up-to-date algorithms, manage keys securely, and ensure that sensitive data is properly encrypted both at rest and in transit.
Business logic vulnerabilities are flaws in the design and implementation of an application that allow attackers to manipulate legitimate functionality to achieve unintended outcomes. These vulnerabilities arise from the way the application handles data and processes, often slipping past automated scanners because they require an understanding of the application’s logic.
In this section, we will explore common business logic vulnerabilities in Node.js applications, provide practical examples, and discuss how to prevent them.
Attackers may exploit bulk operations to overwhelm the system, causing performance degradation or crashes.
// Route to export data without restrictions
app.get('/export-data', async (req, res) => {
const data = await Data.find(); // Fetches all records without limits
res.json(data);
});
Issue
Implement pagination and enforce limits on data retrieval to prevent resource exhaustion.
// Secure route to export data with pagination and limits
app.get('/export-data', async (req, res) => {
const { page = 1, limit = 100 } = req.query;
// Enforce maximum limit to prevent excessive data retrieval
const maxLimit = 1000;
const safeLimit = Math.min(parseInt(limit), maxLimit);
const data = await Data.find()
.skip((page - 1) * safeLimit)
.limit(safeLimit);
res.json(data);
});
Best Practices
Attackers may exploit weaknesses in account linking or merging functionalities to gain unauthorized access to other users’ accounts.
// Route to link social media account without proper verification
app.post('/link-account', async (req, res) => {
const { socialMediaId } = req.body;
// Link account without verifying ownership
await User.updateOne({ _id: req.user.id }, { socialMediaId });
res.send('Account linked successfully.');
});
Issue
Implement verification steps to confirm ownership of the social media account before linking it.
// Secure route to link social media account with verification
app.post('/link-account', async (req, res) => {
const { socialMediaToken } = req.body;
// Verify token with the social media API to confirm ownership
const socialMediaId = await verifySocialMediaToken(socialMediaToken);
if (!socialMediaId) {
return res.status(400).send('Invalid social media token.');
}
// Check if the social media account is already linked
const existingUser = await User.findOne({ socialMediaId });
if (existingUser) {
return res.status(400).send('Social media account already linked to another user.');
}
// Link the social media account to the authenticated user
await User.updateOne({ _id: req.user.id }, { socialMediaId });
res.send('Social media account linked successfully.');
});
Best Practices
Applications that grant elevated privileges based on email domains must ensure proper validation to prevent attackers from exploiting this mechanism.
// Route to register a new user with flawed email validation
app.post('/register', async (req, res) => {
const { email, password } = req.body;
// Incorrect validation: checks if email contains '@company.com' anywhere
if (email.includes('@company.com')) {
// Assign admin role
const user = new User({
email,
password: await hashPassword(password),
role: 'admin',
});
await user.save();
} else {
// Assign regular user role
const user = new User({
email,
password: await hashPassword(password),
role: 'user',
});
await user.save();
}
res.send('Registration successful.');
});
Issue
admin@company.com@attacker.com
to bypass the check.Implement proper email validation to ensure that only the domain part of the email is used for privilege assignment.
// Secure route to register a new user with proper email domain validation
app.post('/register', async (req, res) => {
const { email, password } = req.body;
// Validate the email format
const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).send('Invalid email address.');
}
// Extract and normalize the domain from the email
const emailDomain = email.split('@')[1].toLowerCase();
// Check if the email domain matches exactly 'company.com'
if (emailDomain === 'company.com') {
// Assign admin role
const user = new User({
email,
password: await hashPassword(password),
role: 'admin',
});
await user.save();
} else {
// Assign regular user role
const user = new User({
email,
password: await hashPassword(password),
role: 'user',
});
await user.save();
}
res.send('Registration successful.');
});
Best Practices
Client-side testing focuses on identifying vulnerabilities that can be exploited within the user’s browser. These vulnerabilities can lead to unauthorized access, data theft, or manipulation of client-side logic. It’s crucial to understand these risks and implement measures to mitigate them effectively.
Cross-Site Scripting (XSS) is one of the most common client-side vulnerabilities. It occurs when an attacker injects malicious scripts into webpages viewed by other users. There are three main types of XSS attacks: Stored XSS, Reflected XSS, and DOM-based XSS.
Consider a simple Express.js route that renders user input without proper sanitization:
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Search Results for "${query}"</h1>`);
});
Issue:
query
parameter is directly inserted into the HTML response without any sanitization, allowing attackers to inject malicious scripts.An attacker could craft a URL like:
<http://localhost:3000/search?q=><script>alert('XSS')</script>
When a user visits this URL, the script tag is rendered and executed in the user’s browser.
Sanitize User Input
Use libraries to sanitize input and prevent script injection.
const escape = require('escape-html');
app.get('/search', (req, res) => {
const query = escape(req.query.q);
res.send(`<h1>Search Results for "${query}"</h1>`);
});
Use Templating Engines with Auto-Escaping
Use a templating engine like EJS, Pug, or Handlebars that automatically escapes output.
// Using EJS
app.set('view engine', 'ejs');
app.get('/search', (req, res) => {
const query = req.query.q;
res.render('search', { query });
});
In search.ejs
template:
<h1>Search Results for "<%= query %>"</h1>
EJS escapes special characters by default, preventing XSS attacks.
Content Security Policy (CSP)
Implement CSP headers to restrict sources of executable scripts.
const helmet = require('helmet');
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
})
);
Explanation:
Clickjacking involves tricking users into clicking on something different from what they perceive, potentially leading to unauthorized actions.
An attacker embeds your website in an invisible iframe and overlays it with their own content. When users interact with the page, they unknowingly perform actions on your site.
Set X-Frame-Options Header
Prevent your site from being embedded in iframes.
app.use(helmet.frameguard({ action: 'deny' }));
Use Content Security Policy
Specify that your site should not be framed.
app.use(
helmet.contentSecurityPolicy({
directives: {
frameAncestors: ["'none'"],
},
})
);
Explanation:
frame-ancestors
: Defines valid sources that can embed your content.DOM-based vulnerabilities occur when client-side scripts manipulate the DOM based on untrusted input, potentially leading to XSS attacks.
// In client-side JavaScript
const userInput = location.search.substring(1);
document.getElementById('output').innerHTML = userInput;
Issue:
innerHTML
can execute malicious scripts.Use textContent
or innerText
const params = new URLSearchParams(window.location.search);
const userInput = params.get('input');
document.getElementById('output').textContent = userInput;
textContent
safely inserts text without parsing HTML.Sanitize Input
Use a client-side sanitization library.
const DOMPurify = require('dompurify');
const userInput = location.search.substring(1);
const sanitizedInput = DOMPurify.sanitize(userInput);
document.getElementById('output').innerHTML = sanitizedInput;
Explanation:
innerHTML
with Untrusted Data: Prevents execution of injected scripts.eval()
Using eval()
or similar functions with untrusted input can execute arbitrary code, leading to serious security issues.
eval()
Useapp.get('/calculate', (req, res) => {
const expression = req.query.expression;
const result = eval(expression);
res.send(`Result: ${result}`);
});
Issue:
Example attack:
/calculate?expression=process.exit()
Avoid eval()
Do not use eval()
on untrusted input.
Use Safe Evaluation Libraries
Use libraries that safely evaluate expressions.
const { evaluate } = require('mathjs');
app.get('/calculate', (req, res) => {
const expression = req.query.expression;
try {
const result = evaluate(expression);
res.send(`Result: ${result}`);
} catch (error) {
res.status(400).send('Invalid expression');
}
});
Explanation:
mathjs
parse and evaluate mathematical expressions securely.Including secrets such as API keys in client-side code exposes them to anyone who inspects your website’s source code.
// In client-side JavaScript
const apiKey = 'YOUR_SECRET_API_KEY';
fetch(`https://api.example.com/data?apiKey=${apiKey}`)
.then(response => response.json())
.then(data => {
// Process data
});
Issue:
Move API Calls to Server-side
Handle API requests on the server, keeping secrets secure.
// Client-side code
fetch('/api/data')
.then(response => response.json())
.then(data => {
// Process data
});
// Server-side code
app.get('/api/data', async (req, res) => {
try {
const response = await fetch(`https://api.example.com/data?apiKey=${process.env.API_KEY}`);
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).send('Error fetching data');
}
});
Use Environment Variables
Store secrets in environment variables, not in code.
Explanation:
A Content Security Policy helps mitigate XSS attacks by restricting the sources from which content can be loaded.
const helmet = require('helmet');
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", 'trusted-cdn.com'],
styleSrc: ["'self'", 'trusted-cdn.com'],
imgSrc: ["'self'", 'images.com'],
objectSrc: ["'none'"],
upgradeInsecureRequests: [],
},
})
);
Explanation:
API testing focuses on identifying vulnerabilities in your application’s API endpoints. APIs often expose sensitive data and functionality, making them attractive targets for attackers. Ensuring the security of your APIs is crucial for protecting your application’s integrity and user data.
GraphQL APIs introduce unique challenges and potential vulnerabilities, including:
GraphQL’s introspection feature allows clients to query the schema for types and fields, which is helpful during development. However, leaving introspection enabled in production can expose sensitive schema information to attackers.
An attacker can send an introspection query:
{
__schema {
types {
name
fields {
name
}
}
}
}
This reveals detailed information about your API’s schema, aiding attackers in crafting targeted attacks.
Disable Introspection in Production
const { ApolloServer } = require('apollo-server');
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
Use Whitelisting
Only allow specific queries and mutations.
Schema Stitching
Expose only necessary parts of the schema.
Explanation:
GraphQL allows clients to construct complex queries. Without limitations, attackers can create deeply nested queries that consume excessive server resources.
Example of a malicious query:
query {
user(id: "1") {
friends {
friends {
friends {
# ...continues indefinitely
}
}
}
}
}
Limit Query Depth
Use graphql-depth-limit
:
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(5)],
});
Complexity Analysis
Use graphql-validation-complexity
:
const { createComplexityLimitRule } = require('graphql-validation-complexity');
const complexityLimitRule = createComplexityLimitRule(1000);
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [complexityLimitRule],
});
Explanation:
Improper implementation of authorization checks can allow users to access or manipulate data they shouldn’t.
const resolvers = {
Query: {
user: async (_, { id }) => {
return await User.findById(id);
},
},
};
Issue:
id
.Implement Authorization Checks
const { AuthenticationError, ForbiddenError } = require('apollo-server');
const resolvers = {
Query: {
user: async (_, { id }, { user }) => {
if (!user) {
throw new AuthenticationError('You must be logged in');
}
if (user.id !== id && user.role !== 'admin') {
throw new ForbiddenError('Not authorized');
}
return await User.findById(id);
},
},
};
Use Middleware
Apply authentication and authorization logic through middleware.
Explanation:
APIs may return more data than necessary, revealing sensitive information.
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
Issue:
Explicitly Select Fields
app.get('/api/users/:id', async (req, res) => {
const user = await User.findById(req.params.id).select('username email');
res.json(user);
});
Use DTOs (Data Transfer Objects)
Map database models to DTOs that contain only necessary fields.
Explanation:
Allowing users to update object properties directly can lead to unauthorized modifications.
app.put('/api/users/:id', async (req, res) => {
const updatedUser = await User.findByIdAndUpdate(req.params.id, req.body, { new: true });
res.json(updatedUser);
});
Issue:
role
, password
, or isAdmin
.Whitelist Allowed Fields
app.put('/api/users/:id', async (req, res) => {
const allowedUpdates = ['email', 'username'];
const updates = {};
for (const key of allowedUpdates) {
if (req.body.hasOwnProperty(key)) {
updates[key] = req.body[key];
}
}
const updatedUser = await User.findByIdAndUpdate(req.params.id, updates, { new: true });
res.json(updatedUser);
});
Use Mongoose Schema Options
Set select: false
on sensitive fields and use Schema.methods
for updates.
Explanation:
APIs without rate limiting are vulnerable to brute-force attacks, resource exhaustion, and abuse.
Implement Rate Limiting
Use middleware to limit the number of requests per IP address.
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later.',
});
app.use('/api/', apiLimiter);
Use API Gateways or WAFs
Employ API gateways or Web Application Firewalls that provide rate limiting and DDoS protection.
Explanation:
Failing to validate input can lead to injection attacks and other vulnerabilities.
Validate and Sanitize Input
Use validation libraries like Joi
or express-validator
.
const { body, validationResult } = require('express-validator');
app.post('/api/users', [
body('email').isEmail(),
body('username').isAlphanumeric().isLength({ min: 3 }),
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Proceed with creating the user
});
Use Parameterized Queries
Prevent SQL/NoSQL injection by using parameterized queries or ORM methods.
Explanation:
Without proper logging and monitoring, attacks may go unnoticed.
Implement Logging
Log important events, errors, and security-related information.
const winston = require('winston');
const logger = winston.createLogger({
level: 'info',
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: 'combined.log' }),
],
});
app.use((req, res, next) => {
logger.info(`${req.method} ${req.url}`);
next();
});
Set Up Monitoring and Alerts
Use monitoring tools to track application performance and security events.
Explanation:
Securing web applications requires a comprehensive approach that addresses multiple layers of potential vulnerabilities. Throughout this guide, we’ve explored critical areas of web security, aligning with the OWASP Web Security Testing Guide (WSTG):
Key Takeaways:
By staying informed and continuously applying best practices, you contribute to a more secure web ecosystem. Remember that security is an ongoing process that evolves with emerging threats and technologies. Regularly revisiting and updating your security measures is essential to protect your applications and users effectively.
This guide was authored by Alex Rozhniatovskyi, the CTO of Sekurno. With over 7 years of experience in development and cybersecurity, Alex is an AWS Open-source Contributor dedicated to advancing secure coding practices. His expertise bridges the gap between software development and security, providing valuable insights into protecting modern web applications.
Sekurno is a leading cybersecurity company specializing in Penetration Testing and Application Security. At Sekurno Cybersecurity, we dedicate all our efforts to reducing risks to the highest extent, ensuring High-Risk Industries and Enterprise-SaaS businesses stand resilient against any threat. You can contact us by scheduling a meeting at the website (https://sekurno.com) or by writing to us at team@sekurno.com.
To deepen your understanding of web application security and stay updated on best practices, consider exploring the following resources: