Protecting Your Node.js App from SQL Injection & XSS Attacks
Learn to protect Node.js apps from SQL injection and XSS attacks. This hands-on guide covers vulnerable code examples, attack demonstrations, and practical security measures to safeguard your application.
Injection attacks, particularly SQL Injection and Cross-Site Scripting (XSS), consistently rank among the top vulnerabilities in web applications. According to the OWASP Top Ten, a widely recognized list of web application security risks, injection flaws have been a persistent threat for years.
SQL Injection attacks can have devastating consequences:
Data Breach: Attackers can extract sensitive information from your database, including user credentials, personal data, and confidential business information.
Data Manipulation: Malicious actors can alter, delete, or insert unauthorized data, compromising the integrity of your system.
System Takeover: In severe cases, attackers might gain administrative access to the database server, potentially leading to full system compromise.
Cross-Site Scripting (XSS) attacks, while often underestimated, can be equally harmful:
Session Hijacking: Attackers can steal user session tokens, allowing them to impersonate legitimate users.
Malware Distribution: XSS can be used to inject malicious scripts into your web pages, potentially infecting visitors with malware.
Phishing: Attackers can inject convincing phishing forms into your trusted website, tricking users into revealing sensitive information.
Despite being well-known threats, injection vulnerabilities continue to plague web applications. By understanding the severity and prevalence of injection attacks, we can better appreciate the importance of the security measures we'll be implementing in this guide. Remember, securing your application is not just about protecting your data – it's about safeguarding your users' trust and your organization's reputation.
Step 1: Setting Up Your Basic Node.js App
Initialize the Project
Create a new directory for your project and initialize it:
Here we’re installing express (a minimalist web framework for Node.js), typescript (a strongly typed language that helps catch errors early and improve code quality), tsx (a TypeScript execution engine that compiles and runs TypeScript code directly), sqlite3 (a library for interacting with SQLite databases), and TypeScript definitions for node and the express and escape-html packages.
This setup ensures that you’re using compatible versions of Node.js (version 18 or up) and the required dependencies.
TypeScript Configuration
Create a tsconfig.json file in the project root with the following:
{
"compilerOptions": {
"target": "ES2020", // JavaScript version target for compiled code
"module": "ESNext", // Module code generation method
"moduleResolution": "node", // Use Node.js module resolution strategy
"outDir": "./dist", // Output directory for the compiled JavaScript
"rootDir": "./src", // Root directory of input files
"esModuleInterop": true, // In part, allowing default imports
"skipLibCheck": true, // Skips type checking of declaration files
"forceConsistentCasingInFileNames": true, // Enforce consistent file naming
"strict": true // Enables all strict type-checking options
},
"include": ["src/**/*"] // Includes all TypeScript files in the src directory and its subdirectories
}
Step 2: Creating the Vulnerable Demo
Create/src/index.ts which will set up a simple Express server with two example routes: one vulnerable and one secured. It will render a set of forms for interacting with those endpoints. In a real-world application, you would use a templating engine like EJS or Pug to manage HTML templates more effectively.
Create /src/database.ts which initializes an in-memory SQLite database.
import sqlite3 from 'sqlite3';
export const db = new sqlite3.Database(':memory:');
export const initDatabase = () => {
db.serialize(() => {
db.run('CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, password TEXT, role TEXT)');
db.run('INSERT INTO users (username, password, role) VALUES ("admin", "admin", "admin")');
});
};
Create /src/vulnerable.ts, which handles the /login, /signup, and /comment endpoints without any security considerations.
import { Router } from 'express';
import { db } from './database';
export const vulnerableRoutes = Router();
vulnerableRoutes.post('/login', (req, res) => {
const { username, password } = req.body;
// Vulnerable to SQL Injection due to lack of input
// sanitization and direct query concatenation
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
db.get(query, (err, user) => {
if (err) {
res.status(500).send('Error occurred');
} else if (user) {
res.send(`Welcome, ${user.username}. Your role is ${user.role}`);
} else {
res.send('Invalid credentials');
}
});
});
vulnerableRoutes.post('/signup', (req, res) => {
const { username, password } = req.body;
// Vulnerable to SQL Injection allowing role manipulation
const query = `INSERT INTO users (username, password, role) VALUES ('${username}', '${password}', 'user')`;
db.run(query, (err) => {
if (err) {
res.status(500).send('Error occurred while signing up');
} else {
res.redirect('/'); // Redirect back to the homepage
}
});
});
vulnerableRoutes.post('/comment', (req, res) => {
const { comment } = req.body;
// Vulnerable to XSS
res.send(`Received comment: ${comment}`);
});
Create /src/secured.ts, which handles the /login, /signup, and /comment endpoints with parameterized queries for database operations, and escapes comments before rendering them to the browser.
import { Router } from 'express';
import { db } from './database';
import escapeHtml from 'escape-html';
export const securedRoutes = Router();
securedRoutes.post('/login', (req, res) => {
const { username, password } = req.body;
// Parameterized queries prevent SQL injection by using
// placeholders instead of directly inserting user input
db.get(
'SELECT * FROM users WHERE username = ? AND password = ?',
[username, password],
(err, user) => {
if (err) {
res.status(500).send('Error occurred');
} else if (user) {
res.send(`Welcome, ${user.username}. Your role is ${user.role}`);
} else {
res.send('Invalid credentials');
}
}
);
});
securedRoutes.post('/signup', (req, res) => {
const { username, password } = req.body;
// Secured with Parameterized Query
db.run(
'INSERT INTO users (username, password, role) VALUES (?, ?, ?)',
[username, password, 'user'],
(err) => {
if (err) {
res.status(500).send('Error occurred while signing up');
} else {
res.redirect('/'); // Redirect back to the homepage
}
}
);
});
securedRoutes.post('/comment', (req, res) => {
const { comment } = req.body;
// Escaping Output to Prevent XSS
res.send(`Received comment: ${escapeHtml(comment)}`);
});
Step 3: Attacking Your Demo
Start your application with the following command, and head to http://localhost:3000/ in your browser.
npx tsx src/index.ts
You’ll see the contents of the user table, and a number of forms that allow you to login, signup, and comment, with a choice of hitting the vulnerable or secured endpoints:
Now for the fun part - try some of these out:
Log in with a known username and no password
Behind the scenes, both login endpoints will execute some version of the following SQL in order to determine if your credentials match an existing user
SELECT * FROM users WHERE username='...' AND password='...';
If we manipulate the username we can inject our own conditions into this query. Consider if we wanted the application to execute the following instead:
SELECT * FROM users WHERE username='admin'; --' AND password='...';
The two dashes (--) in SQL represent a comment, so this would find the admin user without checking the password at all. Give it a try - use the username:
admin'; --
and put anything you like in the password field. You can even just leave it blank.
Log in without a username
Sometimes, you don’t know the username of the account you want to log in as, but you want an admin account. Oftentimes, the first user in any database is the admin, so let’s just get the script to load the first users. To do this, we want to synthesize the following query:
SELECT * FROM users WHERE username='' OR '1'='1' AND password='' OR '1'='1';
Because 1 will always equal 1, the username and password are essentially bypassed. A list of all users will be returned, and the login script will return the first. Try this by entering the following for both the username and password.
' OR '1'='1
Log in as any admin
Perhaps the first user wasn’t an admin, and you don’t know the username you want to log in as, but you can surmise that there’s a role attribute that you can target. What if we can craft the following query:
SELECT * FROM users WHERE username='' OR role='admin'; --' AND password='';
We’re using the SQL-comment trick to ignore the password again, and this time we’re injecting a condition on a field that was missing from any queries until now. Use the following username with any or no password.
' OR role='admin'; --
Creating your own admin account
If an attacker is still having trouble logging in as an existing admin user, they might try and create their own. Consider the signup form and the SQL that is likely to be used in that scenario. It will likely look something like:
INSERT INTO users (username, password, role) VALUES ('...', '...', '...');
Perhaps the role isn’t specified during creation, and instead has a default value in the database, but the above SQL would overrode the default, allowing us to manipulate the form fields to ensure we aren’t created as a mere “user”.
In this case, we would want to inject into the username or password value, to affect the role value. Let’s just work with the username field and try and effect the following query:
Finding the characters that exist between the single quotes from the original username, we can see that the following username should create the account we’re looking for. Give it a try!
attacker', 'letmein', 'admin'); --
Now you should be able to log in with username “attacker” and password “letmein” in the vulnerable or the secured form, and gain admin privileges.
Injecting code
We haven’t looked at the comment form yet. If you try submitting something, you’ll see the output echoed to the web page in the same way that it would on a blog comment system, for example. Rather than trying to affect your experience, what this allows you to do is affect the experience of others, with a view to benefitting from that.
Perhaps you want to run a bitcoin miner in the browser of all the viewers of a popular article. You might craft the comment to run some javascript:
There’s no trickery to work out what to extract from the desired payload. If you copy that whole line into the comment field and hit submit, the vulnerable endpoint won’t protect you. Then, every visitor to the page that shows your comment will be running this code.
Of course, that URL won’t work, so let’s try something that you can see in your demo code:
<script>alert('This Cross-Site Scripting Attack could be much worse')</script>
When you submit that to the vulnerable comment endpoint, you’ll see the alert in the next page:
Step 4: Protecting Your Endpoints
For each of the examples above, you can see the secured alternatives, but they aren’t an exhaustive list of solutions. In the secured.ts codebase, you can see we use two protection mechanisms, parameterized queries, and HTML escaping. Let’s have a look at these and a number of other mitigation recommendations:
Parameterized Queries
Parameterized queries, also known as prepared statements, help mitigate SQL injection by separating SQL queries from data. Instead of embedding user inputs directly into SQL strings, parameterized queries use placeholders for inputs and bind values at execution time, ensuring that user inputs are treated strictly as data, not executable code.
For example, replace:
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
With:
db.get('SELECT * FROM users WHERE username = ? AND password = ?', [username, password]);
This ensures that inputs cannot alter the query’s structure, protecting against injection.
HTML Escaping
Escaping output properly is critical to mitigating Cross-Site Scripting (XSS) attacks. By encoding special characters like <, >, and & into their HTML entity equivalents, you prevent the browser from interpreting them as executable code.
Applying the principle of least privilege means granting users and services only the permissions necessary to perform their tasks, minimizing potential damage from compromised accounts or exploited vulnerabilities.
Ensuring the database user account used by your application only has permissions needed for the application’s functions will reduce the attack surface and limit the impact of potential SQL injection attacks.
This can include restricting permissions at the table or column level, such as denying the application account the ability to modify sensitive fields like role, ensuring that only designated, secure processes can alter these fields.
-- Create a dedicated user for the application
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'secure_password';
-- Grant only necessary permissions, excluding the `role` column
GRANT SELECT, INSERT (username, password), UPDATE (password) ON users TO 'app_user'@'localhost';
By implementing these restrictions, you can prevent unauthorized elevation of privileges and maintain tighter control over your application's security posture.
Input Validation
Validate inputs on both the client and server side to ensure they conform to expected formats, lengths, and values. Reject or sanitize any inputs that don't meet criteria.
For example, use a validation library like express-validator:
import { body, validationResult } from 'express-validator';
app.post('/signup', [
body('username').isAlphanumeric().withMessage('Username must contain letters and numbers only'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters long')
], (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Continue with signup process
});
Proper input validation reduces the risk of malicious input being processed by the application.
Content Security Policy (CSP)
Implementing a Content Security Policy (CSP) is an effective way to mitigate XSS attacks by restricting the sources from which scripts, styles, and other resources can be loaded. However, it’s not a substitute for proper input validation and output encoding. Define a CSP header in your server configuration:
This policy only allows scripts from the same origin, preventing unauthorized scripts from executing, even if they are injected into your pages. Consider using security-focused middleware like helmet to set various HTTP headers for enhanced security.
Closing Thoughts
Securing your Node.js application against injection attacks is not just about addressing vulnerabilities as they arise but building a mindset of proactive defense. By applying best practices like parameterized queries, escaping output, and enforcing the principle of least privilege, you create a robust barrier that keeps your data and users safe.
Remember, security is a continuous journey – one that evolves as threats become more sophisticated. As you refine your applications, keep iterating on these foundational techniques and explore advanced tools to further enhance your defenses.
An experimental feature in Node 22 will finally allow ESM code to be required on CommonJS environments, making it much easier to use ESM-only libraries in frameworks like NestJS
Build a modern, secure Node.js API with Express, TypeScript, and ESM. Learn to configure TypeScript with Express, enable hot-reloading with nodemon, and secure your API using Arcjet for rate-limiting and bot protection.