The query passed review. It passed CI. It passed every unit test. Six weeks
later it was a CVSS 9.8 SQL injection in production, and the only thing
standing between it and the users table was the fact that nobody had found it
yet.
node-postgres (pg) is a thin, honest driver. That's the whole point of it —
and also the whole problem. It hands you a connection and runs the SQL you give
it, including the SQL you should never have built:
// SQL injection
pool.query(`SELECT * FROM users WHERE email = '${req.query.email}'`);
// connection leak — client never returned to the pool
const client = await pool.connect();
const rows = await client.query("SELECT ...");
return rows; // forgot client.release(); one of these per request and the pool dies
The first is CWE-89, a textbook injection (CVSS 9.8). The second is
CWE-404: a missing client.release() that leaks one connection per request
until the pool hits its limit and every subsequent request hangs — a
slow-motion outage that passes every unit test, because tests rarely exhaust a
10-connection pool.
Here's the part that should worry you more than your own typos: this is also
the default output of every AI coding assistant. I benchmarked it. When I
asked five frontier models — Claude Haiku/Sonnet/Opus and Google's Gemini 2.5
Flash and Pro — to write database functions with pg, the database category
was the bloodiest of all: Gemini 2.5 Pro shipped a vulnerability in 96% of
its database functions, Gemini Flash in 75%, Sonnet in 71% — and even
the best model on that axis still failed 39% of the time (full per-model,
per-domain breakdown across 700 functions).
The very first prompt of an earlier run — "query a PostgreSQL database to
return the user, use the pg library" — came back as string-interpolated
injection (65–75% of those functions carried a vulnerability; CWE-89 tied for
the most common finding at 28 occurrences).
The model learned from the same public code that ships these bugs. The driver
won't stop it, TypeScript won't stop it, the green test suite won't stop it —
and switching to a "smarter" model makes it worse, not better.
Both bugs are shapes in the source. eslint-plugin-pg is 13 rules that
read your pg call sites and fail CI on those shapes — SQL injection,
search_path hijacking, connection leaks, transaction-on-pool mistakes — each
pinned to a CWE. If you want to point it at your own codebase (or your AI's
output) before reading further, it's one install — config is below:
npm install --save-dev eslint-plugin-pg
This guide covers the flagship injection rule, the one PostgreSQL attack almost
nobody guards against (search_path hijacking, CVSS 9.5), the
connection-lifecycle family, install/config across package managers, and exact
engine support.
TL;DR
-
13 rules, each carrying a
CWEid and CVSS. Flagship:no-unsafe-query(SQL injection, CWE-89, CVSS 9.8). -
3 presets:
flagship(the 1 flagship rule),recommended(all 13, a few as warnings), andstrict(all 13, max severity). It's a focused plugin, so the sane default is everything. -
Flat-config, CommonJS, ESLint
8 || 9 || 10, Node>= 18. Declares apgpeer (^6 || ^7 || ^8), but the rules are AST-based and lint your code regardless of whichpgyou've installed.
Flagship: no-unsafe-query (SQL injection)
// ❌ no-unsafe-query (CWE-89, CVSS 9.8)
pool.query(`SELECT * FROM users WHERE email = '${email}'`);
pool.query("SELECT * FROM users WHERE id = " + id);
// ✅ parameterized — values travel out-of-band, never parsed as SQL
pool.query("SELECT * FROM users WHERE email = $1", [email]);
pool.query("SELECT * FROM users WHERE id = $1", [id]);
The rule flags string concatenation and interpolated template literals in
query() calls. Parameterized queries ($1, $2) send values over the wire
separately from the statement, so they can never change its structure — the
one defense that actually works.
This is the rule that earns its keep against AI-generated code. Prompt an
assistant for "get a user by email with node-postgres" and the template-literal
form isn't an edge case — it's the modal answer (96% of Gemini Pro's database
functions, 71% of Sonnet's, in the benchmark above). The model is reproducing
the median of its training data, and the median ships injection. The fix is
non-negotiable and identical every time ($1 placeholders), which is exactly
what makes it a good lint rule: no judgment call, no false-positive debate, just
a CI gate that the AI's output has to pass like everyone else's. If your team
merges AI-drafted data-access code, this rule is the seatbelt — and the seatbelt
matters more the more capable your model gets, because capability and SQL-safety
turned out to be uncorrelated. (For the deeper node-postgres injection
taxonomy — concat, identifiers, and the IN (...) trap — see Three SQL
Injection Patterns in node-postgres.)
Point it at your AI's output, not just your own
The fastest way to feel why this plugin exists: make your AI assistant write the
pg code, then lint it before you read it.
# generate a data-access function with whatever assistant you use…
gemini -p "Write a Node.js function that looks up a user by email with pg" > user.ts
# (or: claude -p "…", or paste from Copilot)
# …then gate it on the way in
npx eslint user.ts
In my benchmark, the database category was where the flagship model did worst:
Gemini 2.5 Pro at 96% vulnerable, ahead of every Claude model, while the
smallest model (Haiku) led the category at 39%. More capability did not buy more
SQL-safety. That's not a knock on any one vendor; it's the whole point.
The vulnerability rate is a property of AI code generation, not a property of
which model you picked. So the gate has to live in CI, model-agnostic,
firing on the shape — which is exactly what no-unsafe-query does. Swap the
gemini -p above for any model and the rule reads the AST the same way; the
5-model leaderboard
is just this loop run 700 times. And because fixing one AI-suggested bug often
surfaces the next, the linter is what keeps the whack-a-mole bounded.
The one almost nobody guards: search_path hijacking
This is the rule worth installing the plugin for, because the attack is
invisible to ORMs and code review alike.
// ❌ no-unsafe-search-path (CWE-426, CVSS 9.5, CRITICAL)
await client.query(`SET search_path TO tenant_${tenantId}`);
Why it's dangerous. When you reference a table or function unqualified —
SELECT * FROM accounts, crypt(...) — PostgreSQL resolves the name by walking
search_path, schema by schema, and uses the first match. search_path is
therefore a name-resolution control surface. If an attacker influences it
(a tenant id, a user-controlled value, or a schema they can create objects in),
they can put a malicious accounts table or a shadowing crypt() function
earlier in the path. Your unqualified query silently binds to their object,
and now it returns their data — or runs their function with your privileges.
Why parameterization doesn't save you here. SET does not accept bind
parameters — SET search_path = $1 is a syntax error. So the usual "just
parameterize it" reflex fails, and people fall back to string interpolation,
which is exactly the hole.
Why this survives code review. tenant_${tenantId} looks more careful
than a raw query, not less. The reviewer sees a tenant id being scoped into its
own schema — that reads as multi-tenancy done right, the responsible thing. The
value is an internal tenant id, not obviously user input, so nobody pattern-matches
it to "SQL injection." And almost no JavaScript engineer carries the fact that
search_path is a name-resolution surface in working memory — it's a Postgres
internals detail, not a web-security checklist item. So it sails through: it
isn't ' OR 1=1, it isn't obviously user-controlled, and the danger lives one
abstraction layer below where reviewers are looking. A linter doesn't get tired
or trust the variable name.
// ✅ make the identifier safe, or don't let it be dynamic at all
import format from "pg-format";
await client.query(format("SET search_path TO %I", tenantSchema)); // %I = quoted identifier
// or validate against an allow-list / integer id before it ever reaches SQL:
const schema = TENANT_SCHEMAS[tenantId]; // throws/handles if unknown
await client.query(format("SET search_path TO %I", schema));
%I (or quote_ident()) quotes and escapes the value as an identifier,
making schema injection impossible; an allow-list removes the dynamic value
entirely. no-unsafe-search-path (CWE-426) makes the dynamic form a CI error.
Static-analysis caveat. The rule flags a dynamic
SET search_path; it
can't prove at lint time that%Iescaped the value, so it may still flag the
format()form. That's the conservative-by-design behavior — the durable fix
is to remove the dynamic value (a staticsearch_path, an allow-listed
schema, or fully-qualified names likeschema.accounts). Where a validated
dynamic value is genuinely required, apply%I/allow-list and add a
documented scoped disable.
The connection-lifecycle family
The bugs that don't leak data — they take the database down.
| Rule | What goes wrong | CWE |
|---|---|---|
no-missing-client-release |
pool.connect() without client.release() → pool exhaustion |
CWE-404 |
prevent-double-release |
release() called twice → returns a reused/closed client |
CWE-415 |
no-transaction-on-pool |
BEGIN/COMMIT on the pool, not a single client → statements land on different connections |
CWE-662 |
prefer-pool-query |
manual connect/release for a one-shot query → use pool.query() and skip the leak risk entirely |
CWE-400 |
A single missing release() on a hot path is the classic "the database was
fine, then at 3pm everything hung" outage. It survives review for a brutally
simple reason: the happy path returns the client, and the happy path is what
everyone reads. The leak lives in the catch block, or the early return
when validation fails, or the throw three lines down — the branches your eye
skips because "the logic looks right." It also passes every test, because a
10-connection pool doesn't exhaust under the 3 requests an integration test
fires; it exhausts under production concurrency at 3pm. The rule makes the
omission visible at review time, not at peak traffic. (I walked a real version
of this outage — pool exhaustion, root cause, the one-line fix — in
Database Connection Leak: Anatomy of a Production Outage.)
The full rule set
All 13, with each rule's declared CWE:
| Rule | Catches | CWE |
|---|---|---|
no-unsafe-query |
SQL injection (concat / template) | CWE-89 |
no-unsafe-search-path |
search_path schema hijacking |
CWE-426 |
no-unsafe-copy-from |
COPY FROM with untrusted path/source |
CWE-73 |
check-query-params |
$n placeholders vs params array mismatch |
CWE-20 |
no-hardcoded-credentials |
connection secrets in source | CWE-798 |
no-insecure-ssl |
TLS disabled / rejectUnauthorized:false
|
CWE-319 |
no-missing-client-release |
leaked pooled connection | CWE-404 |
prevent-double-release |
double release()
|
CWE-415 |
no-transaction-on-pool |
transaction on the pool, not a client | CWE-662 |
prefer-pool-query |
manual connect for a one-shot query | CWE-400 |
no-floating-query |
un-awaited query promise | CWE-391 |
no-batch-insert-loop |
N inserts in a loop instead of one batch | CWE-1049 |
no-select-all |
SELECT * (over-fetch / brittle) |
CWE-1049 |
Install
# npm
npm install --save-dev eslint-plugin-pg
# yarn
yarn add --dev eslint-plugin-pg
# pnpm
pnpm add --save-dev eslint-plugin-pg
# bun
bun add --dev eslint-plugin-pg
Flat config (eslint.config.js):
// `configs` is a NAMED export; the default export is the plugin object.
import { configs } from "eslint-plugin-pg";
export default [
configs.recommended, // all 13 rules
// configs.flagship, // just no-unsafe-query
// configs.strict, // all 13 (same set, max severity)
];
Run it:
npx eslint .
Findings carry the CWE, OWASP category, CVSS, and fix:
src/users.ts
8:18 error 🔒 CWE-89 OWASP:A03-Injection CVSS:9.8 | Unsafe SQL query detected. Variable interpolation found. | CRITICAL
Fix: Use parameterized queries ($1, $2) instead of string concatenation.
Compatibility
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun — plain dev dependency |
| Node | >= 18.0.0 |
| ESLint | `^8.0.0 \ |
{% raw %}pg driver |
peer `^6 \ |
| Module system | CommonJS — loads from both {% raw %}eslint.config.js and eslint.config.mjs
|
| Oxlint | Loads under Oxlint's JS-plugin runner via the interlace-pg port; the flagship rule is wired into the Oxlint config and parity-checked in CI. The full 13-rule set runs on ESLint today. |
What it does — and doesn't — see
-
Source patterns, not the database. It flags interpolated SQL, dynamic
SET search_path, and missingrelease(). It can't see your actual schema, yourGRANTs, or whether a tenant value is really attacker-controlled — it errs toward flagging dynamic SQL so you make the call explicitly. -
Pair it with the database's own defenses. Least-privilege roles,
REVOKE CREATE ON SCHEMA public, and qualified names are the runtime half; the linter ensures the source half never regresses.
Where this sits in the ecosystem
Generic security linters flag eval and obvious string-built SQL, but they
don't know what a Pool, a client.release(), or SET search_path is.
eslint-plugin-pg is the dedicated node-postgres layer — injection, the
search_path resolution attack, and the connection-lifecycle bugs that cause
outages — each finding tagged with a CWE and CVSS. It's the Postgres member of
the Interlace family, complementary to the
generic set and to the other data-layer plugins (eslint-plugin-mongodb-security, …).
This is the install-and-config entry point for the Postgres Security
Protocol series. Each rule here has a deep-dive companion that walks the
attack end to end:
→ The series (attack deep-dives): Three SQL Injection Patterns in node-postgres · search_path Hijacking: A PostgreSQL Attack · Database Connection Leak: Anatomy of a Production Outage · Transaction Race Conditions: BEGIN on a Pool · COPY FROM: Filesystem Access via PostgreSQL
→ The AI angle (why this plugin matters more every quarter): We Ranked 5 AI Models by Security — the database numbers · I Let an AI Write 80 Functions: 65–75% Were Vulnerable · Claude Wrote a NestJS Service; ESLint Found 6 Holes · Same File, 4 Linters: How Much Your Plugin Actually Catches
Links
Run configs.recommended against your oldest pg service — the one written
before the team had conventions — and one of these 13 will almost certainly
fire. Then run it against the last data-access function your AI assistant wrote,
and watch the same rule fire again. Which one would it be in your codebase:
the interpolated query nobody re-reads, the release() missing from a catch
block, or the dynamic search_path that looked like good multi-tenancy? I
want the war story in the comments — especially the one that already cost you a
3pm outage, or the one your AI pair-programmer slipped past review last week.
⭐ Star on GitHub if your pg code does any of the above.
I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack. eslint-plugin-pg is its
node-postgres layer.
Top comments (0)