Description
SQL injection occurs when user input is concatenated into query text without parameterization. The database may interpret it as SQL syntax rather than a value, allowing changes to WHERE conditions, UNION queries, subqueries, or function calls. Attackers can combine quotes, OR, UNION, or comments such as -- to bypass authentication or read, modify, or delete data. Concatenating input in raw queries through sequelize.query creates this risk.
Potential impact
- Authentication bypass through manipulated login or authorization queries
- Exposure of sensitive data through injected unions or subqueries
- Data modification, deletion, or loss through commands such as
INSERT,UPDATE,DELETE, orDROP - Denial of service from expensive queries or delay functions such as
SLEEP
Remediation
- Pass values separately using
sequelize.query'sreplacementsorbindoptions instead of concatenating input into SQL. Sequelize escapes replacements and inserts them into query text; bind parameters are sent to the database separately from that text. - Value placeholders cannot represent identifiers such as table or column names. Select those from a server-defined allow-list.
- Prefer structured options such as
attributesandwherewithModel.findAllorfindOne. Using an ORM does not make manually assembled raw SQL safe. - Validate lengths and formats too. Do not rely on manual escaping or use the MySQL-specific
sqlstringpackage for PostgreSQL queries.
Examples
Before
javascript
const express = require("express");
const { Sequelize } = require("sequelize");
const app = express();
const sequelize = new Sequelize("postgres://user:pass@localhost:5432/appdb");
// Before: concatenate user input directly into raw SQL.
app.get("/items", async (req, res) => {
const col = req.query.col; // Example: name
const term = req.query.term; // Example: a'
const sql = `SELECT ${col} FROM products WHERE name LIKE '%${term}%'`;
try {
const [rows] = await sequelize.query(sql);
res.json(rows);
} catch (e) {
res.status(500).send("error");
}
});
After
javascript
const express = require("express");
const { Sequelize, QueryTypes } = require("sequelize");
const app = express();
const sequelize = new Sequelize("postgres://user:pass@localhost:5432/appdb");
// After: use replacements for values and an allowlist for identifiers.
app.get("/items", async (req, res) => {
const allowedCols = new Set(["name", "price", "created_at"]);
const requested = String(req.query.col || "name");
if (!allowedCols.has(requested)) {
return res.status(400).send("invalid column");
}
const term = String(req.query.term || "");
// Pass values separately instead of concatenating them into SQL.
const sql = `SELECT ${requested} FROM products WHERE name ILIKE ?`;
try {
const rows = await sequelize.query(sql, {
replacements: [`%${term}%`],
type: QueryTypes.SELECT,
});
res.json(rows);
} catch (e) {
res.status(500).send("error");
}
});
// Alternative: use structured query options in the ORM API.
// Product.findAll({ attributes: [requested], where: { name: { [Op.iLike]: `%${term}%` } } });
Explanation:
- Before: Inserting
termandcoldirectly into SQL lets input change query conditions or structure. - After: Values use
replacements, while column names come only from a fixed allow-list. Replacements and database binding work differently, but both let the application supply values separately from SQL text to the API.