Skip to main content
interlace
Plugin: sequelizeRules

no-unsafe-query

Detects SQL injection in raw Sequelize queries built with string concatenation or template literals

Keywords: SQL injection, CWE-89, OWASP A03:2021, Sequelize, sequelize.query, Sequelize.literal, replacements, bind, ORM

CWE: CWE-89 OWASP: A03:2021 – Injection

Detects SQL injection in Sequelize's two raw-SQL escapes. This rule is part of eslint-plugin-sequelize-security.

💼 This rule is set to error in the recommended config.

Quick Summary

AspectDetails
CWE ReferenceCWE-89 (SQL Injection)
SeverityCritical (CVSS 9.8)
Auto-Fix❌ No auto-fix available
CategorySecurity

Why this rule exists

An ORM is not a defence against SQL injection — it just narrows the surface to the raw escapes. OWASP Juice Shop's two flagship injections are both sequelize.query() template literals:

// routes/search.ts — full product table disclosure via UNION
models.sequelize.query(
  `SELECT * FROM Products WHERE ((name LIKE '%${criteria}%' ...`,
);

// routes/login.ts — authentication bypass with ' OR 1=1--
models.sequelize.query(
  `SELECT * FROM Users WHERE email = '${req.body.email}' ...`,
);

Both are pinned as test cases in this rule's suite.

Rule Details

Reports three shapes when they reach a raw-SQL sink:

  1. String concatenation — sequelize.query('SELECT ... ' + value)
  2. Template interpolation — sequelize.query(`SELECT ... ${value}`)
  3. A variable tainted by either, including via +=, then passed to a sink

Sinks

  • sequelize.query() — raw SQL execution
  • Sequelize.literal() — raw SQL spliced into a builder query, the usual ORDER BY / column-name injection

Both names are matched by method name, so models.sequelize.query(...) and a destructured literal(...) assigned to an object property both report. There is no SQL-keyword filter: a literal() holding nothing but an interpolated column name is a real injection, and carries no SQL keyword of its own.

❌ Incorrect

// Interpolated raw query
await sequelize.query(`SELECT * FROM Users WHERE id = ${userId}`);

// Concatenated raw query
await sequelize.query('DELETE FROM Sessions WHERE token = ' + token);

// ORDER BY injection through literal()
Product.findAll({ order: Sequelize.literal(`${sortColumn} DESC`) });

// Built up across statements
let sql = 'SELECT * FROM Products WHERE 1=1';
sql += ` AND name = '${name}'`;
await sequelize.query(sql);

✅ Correct

// Named replacements
await sequelize.query('SELECT * FROM Users WHERE id = :id', {
  replacements: { id: userId },
});

// Bind parameters (sent to the driver, never interpolated)
await sequelize.query('SELECT * FROM Users WHERE email = $1', {
  bind: [email],
});

// Let the query builder generate the SQL
await User.findAll({ where: { id: userId } });

// ORDER BY against an allowlist, not user input
const column = ALLOWED_SORTS.includes(sortColumn) ? sortColumn : 'createdAt';
Product.findAll({ order: [[column, 'DESC']] });

Known limitations

  • Only identifier member access is matched, so sequelize['query'](...) is a false negative.
  • Taint tracking is single-scope and name-based — it does not follow a query string across function boundaries.
  • Sequelize.literal() is matched by method name. A same-named method on an unrelated object in a Sequelize codebase would also report.

When Not To Use It

  • In migration or seed files whose SQL is fully static and never sees user input.

Implementation

The detection is shared across the driver plugins via createSqlInjectionRule in @interlace/eslint-devkit; this rule supplies Sequelize's sinks and Sequelize's remediation copy. pg/no-unsafe-query is the same detector with the pg sink and $1, $2 guidance — install the one matching your stack and you get exactly one finding per line.

Further Reading

Did this rule catch something? Star the repo to get new CWE coverage as we ship it — or follow the AI-code-security benchmarks behind these rules.