Skip to content

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, $3 syntax 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.
// 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 replacements object 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, and execute() 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

Additional Resources