Skip to main content
interlace
Plugin: secure-codingRules

no-sql-injection

Detects SQL statements built from attacker-controlled input in files that import no SQL driver

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

Detects SQL injection where the query is executed through a handle the file never imported. This rule is part of eslint-plugin-secure-coding.

💼 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 — the fix changes the call's argument shape
CategorySecurity

Why this rule exists (and why it is not in a driver plugin)

The driver-scoped rules — postgresql-security/no-unsafe-query, mysql-security, typeorm-security, knex-security, drizzle-security, sqlite-security, sequelize-security, prisma-security — each abstain in a file that does not import their own driver. That gate is deliberate: keying on a method name alone made .query() mean typeorm and pg and mysql2 at once, and one defect was billed up to three times.

It leaves a gap. The most common Node layout puts the pool in one module:

// db.js
const { Pool } = require('pg');
module.exports = new Pool();

and every route file then does db.query(...) with no driver import at all. Those files are injectable and no driver rule can see them.

This rule owns exactly that complement: it reports only in files that import no SQL driver, so any given query site is owned by exactly one rule and recommended never reports the same line twice.

What it takes to report

All four must hold — the rule is deliberately quiet otherwise:

  1. A raw-SQL sink.query(...) or .execute(...).
  2. A built string — concatenation or an interpolated template literal. A plain literal cannot be injected into.
  3. A statement shape — the static text reads as SQL: SELECT … FROM, INSERT INTO, UPDATE … SET, DELETE FROM, REPLACE INTO, MERGE INTO. A lone verb is not enough; 'update available for ' + pkg is a status message, not a statement.
  4. An attributable source — the interpolated value traces to an inbound request (req / request / ctx with body, query, params, headers, cookies, url, path), directly or through a written-once local binding.

"I cannot prove this is safe" is not a finding. A query built from a function parameter, a module constant or a config value is a query builder doing its job.

Examples

❌ Incorrect

const userId = req.params.id;
const query = 'SELECT * FROM users WHERE id = ' + userId;
db.query(query);
const name = req.body.name;
db.query(`SELECT * FROM users WHERE name = '${name}'`);
// Identifiers cannot be bound as parameters — allow-list them instead.
const sortColumn = req.query.sort;
db.query('SELECT * FROM users ORDER BY ' + sortColumn);

✅ Correct

// Bind the value as a parameter.
db.query('SELECT id, name, email FROM users WHERE id = $1', [req.params.id]);
// A prepared-statement object is not a built string.
db.query({ name: 'get-user', text: 'SELECT * FROM users WHERE id = $1', values: [id] });
// A column name cannot be a bound parameter — validate it against an allow-list.
const SORTABLE = new Set(['name', 'created_at']);
const column = SORTABLE.has(req.query.sort) ? req.query.sort : 'name';
db.query('SELECT * FROM users ORDER BY ' + column);
// A file that imports its driver belongs to that driver's rule, not this one.
import { Pool } from 'pg';
pool.query('SELECT * FROM users WHERE id = ' + req.params.id); // → pg/no-unsafe-query

Known limits

  • A reassigned query builder (let sql = '…'; sql += ' AND x = ' + x) is not followed. The driver-scoped rules track += because they already know the file is a database file; guessing at it in a rule that runs on every file with no driver evidence at all is how a precise rule becomes a noisy one.
  • A value wrapped in a call — escapeIdentifier(req.query.sort) — breaks attribution on purpose. That call is the documented fix for this very finding, so reporting it would flag code that is already correct.

Why this rule exists alongside the driver plugins

This rule is the complement of postgresql-security, mysql-security, prisma-security and the rest: it reports only in a file that imports no known SQL driver, so exactly one rule owns any given db.query(...) site.

That is not a small remainder. Most applications do not import pg in every route file — they import their own module:

import { db } from '../lib/db';
db.query(`SELECT * FROM users WHERE email = '${req.query.email}'`);   // reported here

Measured on benchmarks/rule-corpus/secure-coding__no-sql-injection/, where every vulnerable fixture takes this shape:

PluginCaught
secure-coding/no-sql-injection5 of 8 (7 of 8 with the option below)
postgresql-security/no-unsafe-query0 of 8
sonarjs0 of 8
eslint-plugin-security0 of 8

The driver plugins report zero because the driver import is in a different file. Without this rule, that code is uncovered.

Options

OptionTypeDefaultDescription
reportUnattributedInterpolationbooleanfalseReport SQL built by interpolation even when the interpolated value cannot be traced to a request in this file (a property of this, a property of a non-request object, a helper return). Parameterise either way. Set false to report only attributable taint.
treatParametersAsUntrustedbooleantrueTreat a function parameter spliced into statement text as an untrusted inlet — nothing in this file constrains what a caller passes. Set false to report only values traceable to a request within the linted file.
requestRootsstring[]["req","request","ctx","event"]Identifier roots that denote an inbound request. Matched exactly.
requestPropertiesstring[]["query","params","body","headers","cookies","url","path"]Request properties that carry caller-supplied data. Matched exactly.
sinkMethodsstring[]["query","execute"]Method names that execute a raw SQL string. Matched exactly against the called member name.
queryTextPropertiesstring[]["text","sql"]Properties of a driver query-config object that hold the statement text. Matched exactly.
transparentCallsstring[]["String"]Ambient global calls that pass their argument through unchanged, so taint survives them. Matched exactly, and only when the file declares no binding of that name.

reportUnattributedInterpolation (default: false)

Report SQL built by interpolation even when the value cannot be traced to a request in this file — a function parameter, a property of this, a helper's return.

// silent by default, reported with the option on
export function search(term) {
  return db.query("SELECT * FROM items WHERE name LIKE '%" + term + "%'");
}

The fix for CWE-89 is to parameterise regardless of provenance, so there is a good argument for turning this on. It is off by default because this rule's attribution model distinguishes caller-supplied from server-set values — req.locals.id is set by middleware, and reporting it is a false positive. Defaulting the stricter mode on would reintroduce findings that were measured away, on a rule that ships at error in recommended.

An escaping call is never reported at either setting:

db.query('SELECT * FROM users ORDER BY ' + escapeIdentifier(req.query.sort));  // quiet

None.

When Not To Use It

If your project reaches its database exclusively through an imported driver, the driver-specific plugin already covers you and this rule will simply never fire.

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.