CWE-89: SQL Injection - JavaScript
Overview
SQL Injection in JavaScript/Node.js applications occurs when untrusted user input is incorporated into SQL queries without proper sanitization or parameterization. Attackers can exploit this to manipulate query logic, extract sensitive data, modify database contents, or execute administrative operations. Node.js database libraries (mysql, pg, better-sqlite3, etc.) all support parameterized queries as the primary defense.
Common Node.js SQL Injection Scenarios:
- Template literals building SQL queries
- String concatenation with user input
- Using string formatting with untrusted data
- Raw SQL in Sequelize, TypeORM, or Knex without proper escaping
- Dynamic ORDER BY, table names, or column names
Popular Node.js Database Libraries:
- mysql / mysql2: MySQL client
- pg (node-postgres): PostgreSQL client
- better-sqlite3: SQLite client
- Sequelize: Multi-database ORM
- TypeORM: TypeScript ORM
- Knex.js: SQL query builder
Primary Defence: Use parameterized queries with placeholders (? for mysql/mysql2/sqlite, $1, $2 for pg), ORM query methods (Sequelize, TypeORM), or query builder parameterization (Knex).
Common Vulnerable Patterns
Template Literals
// VULNERABLE - Template literals in SQL queries
const mysql = require('mysql2');
const connection = mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
function getUser(username) {
// VULNERABLE - Template literal with user input
const query = `SELECT * FROM users WHERE username = '${username}'`;
connection.query(query, (err, results) => {
if (err) throw err;
return results[0];
});
}
// Attack: username = "admin' OR '1'='1"
// Query becomes: SELECT * FROM users WHERE username = 'admin' OR '1'='1'
// Returns first user (authentication bypass!)
Why this is vulnerable:
- Template literals embed variables directly
- Single quote breaks out of string context
- Allows SQL syntax injection
- Authentication bypass
String Concatenation
// VULNERABLE - String concatenation in SQL
const { Pool } = require('pg');
const pool = new Pool({
user: 'dbuser',
host: 'localhost',
database: 'myapp',
password: 'password'
});
async function deleteLog(logId) {
// VULNERABLE - String concatenation
const query = "DELETE FROM logs WHERE id = " + logId;
await pool.query(query);
}
// Attack: logId = "1 OR 1=1"
// Query becomes: DELETE FROM logs WHERE id = 1 OR 1=1
// Deletes ALL logs!
Why this is vulnerable:
- String concatenation allows injection
- No type validation
- Can inject SQL operators
- Data destruction
Express with SQLite
// VULNERABLE - Express route with SQL injection
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const app = express();
const db = new sqlite3.Database('app.db');
app.get('/user/:id', (req, res) => {
const userId = req.params.id;
// VULNERABLE - User input in template literal
const query = `SELECT * FROM users WHERE id = ${userId}`;
db.all(query, (err, rows) => {
if (err) {
return res.status(500).send(err.message);
}
res.json(rows);
});
});
// Attack: /user/1 UNION SELECT password, null FROM admin_users
// Extracts admin passwords
Why this is vulnerable:
- User input from URL parameter
- Template literal concatenation
- UNION injection possible
- Sensitive data exposure
Sequelize Raw Queries
// VULNERABLE - Sequelize raw SQL with string interpolation
const { Sequelize, QueryTypes } = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'postgres'
});
async function searchProducts(category) {
// VULNERABLE - String interpolation in raw query
const query = `SELECT * FROM products WHERE category = '${category}'`;
const results = await sequelize.query(query);
return results[0];
}
// Attack: category = "electronics' UNION SELECT username, password FROM users--"
// Exfiltrates user credentials
Why this is vulnerable:
- Raw SQL bypasses ORM safety
- Template literal substitution
- UNION injection
- Data exfiltration
Knex Raw with Concatenation
// VULNERABLE - Knex raw() with string concatenation
const knex = require('knex')({
client: 'mysql',
connection: {
host: 'localhost',
user: 'root',
password: 'password',
database: 'myapp'
}
});
async function getOrders(status) {
// VULNERABLE - String concatenation in knex.raw()
const query = knex.raw("SELECT * FROM orders WHERE status = '" + status + "'");
return await query;
}
// Attack: status = "pending' OR role='admin'--"
// Bypasses authorization checks
Why this is vulnerable:
- knex.raw() requires proper bindings
- String concatenation bypasses safety
- Authorization bypass
- Can access unauthorized data
Dynamic ORDER BY
// VULNERABLE - User-controlled ORDER BY clause
const mysql = require('mysql2/promise');
async function getUsersSorted(sortColumn, sortOrder) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// VULNERABLE - Column name and order from user input
const query = `SELECT * FROM users ORDER BY ${sortColumn} ${sortOrder}`;
const [rows] = await connection.execute(query);
return rows;
}
// Attack: sortColumn = "id; DROP TABLE users--"
// SQL injection via ORDER BY
Why this is vulnerable:
- ORDER BY columns can't be parameterized traditionally
- String interpolation required
- Allows SQL injection
- No allowlist validation
TypeORM Query Builder Misuse
// VULNERABLE - TypeORM with raw SQL concatenation
import { getRepository } from 'typeorm';
import { User } from './entity/User';
async function findUsers(searchTerm: string) {
const userRepository = getRepository(User);
// VULNERABLE - String interpolation in createQueryBuilder
const users = await userRepository
.createQueryBuilder('user')
.where(`user.username LIKE '%${searchTerm}%'`)
.getMany();
return users;
}
// Attack: searchTerm = "%' OR '1'='1"
// Returns all users
Why this is vulnerable:
- Template literal in WHERE clause
- Bypasses TypeORM parameterization
- LIKE injection
- Data exposure
Next.js API Route
// VULNERABLE - Next.js API route with SQL injection
// pages/api/products.js
import mysql from 'mysql2/promise';
export default async function handler(req, res) {
const { category } = req.query;
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'shop'
});
// VULNERABLE - Query parameter in SQL
const query = `SELECT * FROM products WHERE category = '${category}'`;
const [rows] = await connection.execute(query);
res.json(rows);
}
// Attack: /api/products?category=electronics' OR '1'='1
// Returns all products
Why this is vulnerable:
- User input from query string
- Template literal concatenation
- No parameterization
- Data exposure
Secure Patterns
MySQL with Placeholders
// SECURE - MySQL parameterized queries
const mysql = require('mysql2/promise');
async function getUserSecure(username) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// SECURE - Use ? placeholder for parameters
const query = 'SELECT * FROM users WHERE username = ?';
const [rows] = await connection.execute(query, [username]);
return rows[0];
}
async function getUserByIdAndRole(userId, role) {
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// SECURE - Multiple parameters
const query = 'SELECT * FROM users WHERE id = ? AND role = ?';
const [rows] = await connection.execute(query, [userId, role]);
return rows[0];
}
Why this works:
- The
?placeholders are replaced with parameterized values by mysql2. - The library sends the SQL structure and values separately to the database, ensuring values are treated as data only, not executable SQL code.
PostgreSQL with Parameterization
// SECURE - PostgreSQL parameterized queries
const { Pool } = require('pg');
const pool = new Pool({
user: 'dbuser',
host: 'localhost',
database: 'myapp',
password: 'password'
});
async function deleteLogSecure(logId) {
// SECURE - Use $1, $2 placeholders
const query = 'DELETE FROM logs WHERE id = $1';
await pool.query(query, [logId]);
}
async function insertUser(username, email, role) {
// SECURE - Named placeholders with $1, $2, $3
const query = `
INSERT INTO users (username, email, role)
VALUES ($1, $2, $3)
RETURNING id
`;
const result = await pool.query(query, [username, email, role]);
return result.rows[0].id;
}
Why this works:
- PostgreSQL's
$1,$2,$3syntax creates positional parameters. - The pg library binds array values to these positions, sending them as parameters that the database treats as data, preventing SQL syntax injection.
SQLite with Parameterization
// SECURE - SQLite parameterized queries
const sqlite3 = require('better-sqlite3');
const db = new sqlite3('app.db');
function updateUserRoleSecure(userId, role) {
// Validate role against allowlist
const allowedRoles = ['user', 'moderator', 'admin'];
if (!allowedRoles.includes(role)) {
throw new Error(`Invalid role. Must be one of: ${allowedRoles.join(', ')}`);
}
// SECURE - Use named parameters or ? placeholders
const stmt = db.prepare('UPDATE users SET role = ? WHERE id = ?');
stmt.run(role, userId);
}
function getUserSecure(username) {
// SECURE - Named parameters
const stmt = db.prepare('SELECT * FROM users WHERE username = @username');
return stmt.get({ username });
}
Why this works:
- better-sqlite3 supports named parameters (
@username) and positional parameters (?). - When you pass an object or array, the library binds values as parameters, ensuring they're treated as data, not SQL code.
Sequelize ORM (Recommended)
// SECURE - Sequelize ORM methods (type-safe)
const { Sequelize, DataTypes } = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'postgres'
});
const Product = sequelize.define('Product', {
name: DataTypes.STRING,
category: DataTypes.STRING,
price: DataTypes.DECIMAL
});
async function searchProductsSecure(category) {
// SECURE - Sequelize automatically parameterizes
const products = await Product.findAll({
where: { category }
});
return products;
}
async function getExpensiveProducts(minPrice) {
const { Op } = require('sequelize');
// SECURE - ORM operators are safe
const products = await Product.findAll({
where: {
price: {
[Op.gte]: minPrice
}
}
});
return products;
}
Why this works:
- Sequelize ORM translates object-based queries into parameterized SQL automatically.
- Operators like
Op.gte(greater than or equal) generate safe SQL with bound parameters, never concatenating user input into queries.
Sequelize Raw with Replacements
// SECURE - Sequelize raw queries with proper bindings
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize('database', 'username', 'password', {
host: 'localhost',
dialect: 'postgres'
});
async function searchProductsRawSecure(category) {
// SECURE - Use :param syntax with replacements
const [results] = await sequelize.query(
'SELECT * FROM products WHERE category = :category',
{
replacements: { category },
type: QueryTypes.SELECT // Older Sequelize: Sequelize.QueryTypes.SELECT
}
);
return results;
}
async function complexQuerySecure(minAmount, maxAmount) {
// SECURE - Multiple parameters
const [results] = await sequelize.query(
`SELECT * FROM orders
WHERE amount BETWEEN :minAmount AND :maxAmount
ORDER BY created_at DESC`,
{
replacements: { minAmount, maxAmount },
type: QueryTypes.SELECT // Older Sequelize: Sequelize.QueryTypes.SELECT
}
);
return results;
}
Why this works:
- The
replacementsobject maps named parameters (:minAmount,:maxAmount) to values. - Sequelize converts these to database-specific parameterized queries, ensuring values are sent as parameters, not concatenated into SQL.
Knex with Bindings
// SECURE - Knex with proper parameter bindings
const knex = require('knex')({
client: 'mysql',
connection: {
host: 'localhost',
user: 'root',
password: 'password',
database: 'myapp'
}
});
async function getOrdersSecure(status) {
// SECURE - Knex query builder automatically parameterizes
const orders = await knex('orders')
.where('status', status)
.select('*');
return orders;
}
async function getOrdersRawSecure(status) {
// SECURE - knex.raw() with ? bindings
const orders = await knex.raw(
'SELECT * FROM orders WHERE status = ?',
[status]
);
return orders[0];
}
Why this works:
- Knex's query builder automatically parameterizes all conditions.
- For raw SQL, passing an array as the second argument binds values to
?placeholders, creating parameterized queries that prevent injection.
Dynamic ORDER BY with Allowlist
// SECURE - Dynamic ORDER BY with column allowlist
const mysql = require('mysql2/promise');
async function getUsersSortedSecure(sortColumn, sortOrder) {
// SECURE - Allowlist allowed columns
const allowedColumns = ['id', 'username', 'email', 'created_at'];
const allowedOrders = ['ASC', 'DESC'];
if (!allowedColumns.includes(sortColumn)) {
throw new Error(`Invalid column. Allowed: ${allowedColumns.join(', ')}`);
}
if (!allowedOrders.includes(sortOrder.toUpperCase())) {
throw new Error(`Invalid order. Allowed: ${allowedOrders.join(', ')}`);
}
const connection = await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
// SECURE - Use validated allowlist values (can't parameterize ORDER BY)
const query = `SELECT * FROM users ORDER BY ${sortColumn} ${sortOrder.toUpperCase()}`;
const [rows] = await connection.execute(query);
return rows;
}
Why this works:
- Column and order names cannot be parameterized in SQL.
- Instead, we validate user input against a allowlist of allowed values.
- Only values from the allowlist are used in the query, ensuring no malicious input can reach the SQL.
Express with Validation
// SECURE - Express with parameterized queries and validation
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
async function getDb() {
return await mysql.createConnection({
host: 'localhost',
user: 'root',
database: 'app'
});
}
app.get('/user/:id', async (req, res) => {
const userId = parseInt(req.params.id, 10);
if (!userId || userId < 1) {
return res.status(400).json({ error: 'Invalid user ID' });
}
try {
const connection = await getDb();
// SECURE - Parameterized query
const [rows] = await connection.execute(
'SELECT id, username, email FROM users WHERE id = ?',
[userId]
);
if (rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(rows[0]);
} catch (err) {
res.status(500).json({ error: 'Database error' });
}
});
app.get('/search', async (req, res) => {
const searchTerm = req.query.q || '';
if (searchTerm.length > 50) {
return res.status(400).json({ error: 'Search term too long' });
}
try {
const connection = await getDb();
// SECURE - Parameterized LIKE query
const [rows] = await connection.execute(
'SELECT id, username FROM users WHERE username LIKE ?',
[`%${searchTerm}%`]
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: 'Database error' });
}
});
app.listen(3000);
Why this works:
- Express routes combine parameterized queries with input validation at the application layer.
- The
parseInt()function ensures type safety, length checks limit unbounded input size, andexecute()with placeholders ensures all user input is parameterized. - This defense-in-depth approach validates inputs before they reach the database, while parameterization ensures even bypassed validation cannot cause injection.
- Error handling prevents information leakage that could aid attackers in probing for vulnerabilities.
Verification
After implementing the recommended secure patterns, verify the fix through multiple approaches:
- Manual testing: Submit malicious payloads relevant to this vulnerability and confirm they're handled safely without executing unintended operations
- Code review: Confirm all instances use the secure pattern (parameterized queries, safe APIs, proper encoding) with no string concatenation or unsafe operations
- Static analysis: Use security scanners to verify no new vulnerabilities exist and the original finding is resolved
- Regression testing: Ensure legitimate user inputs and application workflows continue to function correctly
- Edge case validation: Test with special characters, boundary conditions, and unusual inputs to verify proper handling
- Framework verification: If using a framework or library, confirm the recommended APIs are used correctly according to documentation
- Authentication/session testing: Verify security controls remain effective and cannot be bypassed (if applicable to the vulnerability type)
- Rescan: Run the security scanner again to confirm the finding is resolved and no new issues were introduced