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
| Aspect | Details |
|---|---|
| CWE Reference | CWE-89 (SQL Injection) |
| Severity | Critical (CVSS 9.8) |
| Auto-Fix | ❌ No auto-fix available |
| Category | Security |
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:
- String concatenation —
sequelize.query('SELECT ... ' + value) - Template interpolation —
sequelize.query(`SELECT ... ${value}`) - A variable tainted by either, including via
+=, then passed to a sink
Sinks
sequelize.query()— raw SQL executionSequelize.literal()— raw SQL spliced into a builder query, the usualORDER 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 spellingsequelize['query'](...)namesqueryand 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-injectiondoes 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 anIN (…)orVALUES (…)list, afterLIMIT— 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 BYtarget, 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 asids.map(() => '?').join(','), a lookup into a fixed map, a value checked against an anchored-regex or fixed-list allowlist first, a local helper everyreturnof 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{ "jsPlugins": ["oxlint-plugin-sequelize-security"], "rules": { "sequelize-security/no-unsafe-query": "error" }}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.