Skip to main content
interlace
Plugin: sequelizeRules

no-unsafe-query

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

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

A literal() is a fragment, so it has no SQL-keyword filter. sequelize.escape() counts as escaping.

❌ 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(`${req.query.sort} 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

  • A method chosen at runtime — sequelize[verb](...) — names nothing to match against the sink list, so it is not reported. The quoted spelling sequelize['query'](...) names query and IS reported.
  • Values are followed within the file, and through RELATIVE imports read from disk. A path alias (@/lib/db) is not resolved, so a handle imported through one is judged by the call's own shape.
  • A file that reaches the driver only through a relative module is checked for the sinks secure-coding/no-sql-injection does not cover; query() / execute() in such a file belong to that rule, so the line is never reported twice.

When Not To Use It

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

What is reported, and what is not (2026-10 review)

A value spliced into the SQL text is reported only when this file cannot prove it fixed:

  • Value positions — after =, <, LIKE, inside '…', in an IN (…) or VALUES (…) list, after LIMIT — always have a bind parameter, so any value the rule cannot prove fixed is a finding there: a parameter, request data, an unknown call.
  • Fragment positions — a table or column name, an ORDER BY target, a whole clause or subquery spliced in — have no bind parameter, so composing them is how such statements are built. There a hole is reported once the file traces it to the inbound request (ORDER BY ${req.query.sort}), not merely because it is unproven.
  • Provably fixed values are never reported: literals and constants, enum and const-object members, a ternary between two literals, numbers (Number(), Math.*, arithmetic, .length), a ? placeholder list such as ids.map(() => '?').join(','), a lookup into a fixed map, a value checked against an anchored-regex or fixed-list allowlist first, a local helper every return of which is fixed, and the driver's own escapers and fragments. An escaper's output carries its own quotes, so inside '…' or an identifier quote it closes the author's token: there an escaped value is reported unless what was escaped is itself fixed (Quint oracle, 2026-10).
  • Request data handed over as the whole SQL text is reported too.

The receiver is judged by where it is written, never by its name: a method of another library that shares the name (/re/.exec, child_process.exec, a GraphQL client's query) is not a sink, and the statement-shape check (SELECT … FROM, INSERT INTO, UPDATE … SET, …) applies only when the receiver cannot be traced to the driver. Test files are skipped.

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

Use this rule

npm install -D oxlint-plugin-sequelize-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-sequelize-security"],  "rules": {    "sequelize-security/no-unsafe-query": "error"  }}
vite.config.ts
import { defineConfig } from 'vite-plus';export default defineConfig({  lint: {    jsPlugins: ['oxlint-plugin-sequelize-security'],    rules: {      'sequelize-security/no-unsafe-query': 'error',    },  },});

oxlint-plugin-sequelize-security re-exports eslint-plugin-sequelize-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.

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.