Four
pgpatterns that pass code review every time. One dev dependency turns all four into CI errors. Here is the sixty-second install, then each pattern, why it survives review, and the rule that stops it.
node-postgres (pg) is a thin, honest driver. It hands you a connection and runs whatever SQL you give it, including the SQL you should never have built. It has no opinion about the difference — which means the opinion has to live somewhere else. Your linter is the cheapest place to put it.
npm install --save-dev eslint-plugin-pg
Flat config (eslint.config.js):
import { configs } from "eslint-plugin-pg";
export default [
configs.recommended, // all 13 rules
];
Run it:
npx eslint .
That is the entire setup. Everything below is what the 13 rules are actually for.
The vulnerable code:
// ❌ no-unsafe-query (CWE-89, CVSS 9.8)
pool.query(`SELECT * FROM users WHERE email = '${req.query.email}'`);
pool.query("SELECT * FROM users WHERE id = " + userId);
Why it survived review. Template literals look like TypeScript, not SQL injection. The reviewer mentally traces req.query.email and sees a string — which it is. What they don't see is that the string value ' OR '1'='1 is also valid SQL that changes the query's structure entirely. String interpolation in a template literal doesn't look like "SQL injection" unless you've already been burned by it; it looks like normal string formatting that developers do everywhere else.
The ESLint rule:
// ✅ no-unsafe-query: parameterized — values travel out-of-band, never parsed as SQL
pool.query("SELECT * FROM users WHERE email = $1", [req.query.email]);
pool.query("SELECT * FROM users WHERE id = $1", [userId]);
no-unsafe-query 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 rule ships tagged CWE-89 with a CVSS base score of 9.8 (v1.4.6).
It also catches the split-line form, which is the one that actually reaches production:
// ❌ still flagged — the taint is tracked across the assignment
const sql = "SELECT * FROM users WHERE id = " + userId;
await client.query(sql);
That is taint tracking across the assignment, not a pattern match on the query() argument — a generic "no string concatenation" rule cannot follow the variable, so it misses exactly the version a reviewer misses.
This is also the rule that earns its keep against AI-generated code. In my five-model benchmark (700 generated functions, run 2026-02-09), the database category was the bloodiest: Gemini 2.5 Pro shipped a flagged pattern in 96% of its database functions, Claude Sonnet 4.5 in 71%. Caveat worth stating: that 96% counts hardening rules like no-select-all alongside the real injection sinks, and counting sinks only collapses it toward Sonnet's 71%. Either way, the template-literal form is the modal answer, not an edge case — the models learned from the same public code that ships these bugs.
The vulnerable code:
// ❌ no-unsafe-search-path (CWE-426, CVSS 7.5) — prints CRITICAL, see below
await client.query(`SET search_path TO tenant_${tenantId}`);
Why it survived review. This code 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 PostgreSQL internals detail, not a web-security checklist item.
Here's the attack: when you reference a table unqualified — SELECT * FROM accounts — PostgreSQL resolves the name by walking search_path, schema by schema, and uses the first match. If an attacker influences search_path (via a tenant ID, a user-controlled value, or a schema they can create objects in), they put a malicious accounts table earlier in the path. Your query silently binds to their object and returns their data.
The additional trap: SET does not accept bind parameters. SET search_path = $1 is a syntax error. So the usual "just parameterize it" reflex fails, and developers fall back to string interpolation.
The ESLint rule:
// ✅ 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 before it reaches SQL:
const schema = TENANT_SCHEMAS[tenantId]; // throws if unknown
await client.query(format("SET search_path TO %I", schema));
no-unsafe-search-path (CWE-426) makes the dynamic form a CI error. The durable fix is to remove the dynamic value: a static search_path, an allow-listed schema, or fully qualified names like schema.accounts.
You will notice something the moment this rule fires: it prints CRITICAL next to a base score of 7.5, which is the High band. My own audit of 203 rules caught that — 16% of our severity words had drifted from their scores, in both directions, and this is one of them. Fix filed, not yet shipped. So: gate CI on the numeric score, never on the severity word. Advice worth taking from a linter that just failed its own spelling test.
The vulnerable code:
// ❌ no-unsafe-copy-from (CWE-73, CVSS 9.5)
await client.query(`COPY staging FROM '${req.body.filePath}'`);
Why it survived review. COPY FROM is a legitimate PostgreSQL command for bulk data loading, and the reviewer understands that. What's easy to miss: when running as a superuser, COPY FROM reads directly from the server's filesystem — not the client's. An attacker who controls filePath can read /etc/passwd, private keys, or any file the PostgreSQL process has access to. The command looks like an import operation; it reads like file access from the database server's perspective.
The ESLint rule:
// ✅ COPY FROM STDIN — the client streams the bytes, the server
// never touches its own filesystem
import { from as copyFrom } from "pg-copy-streams";
import { createReadStream } from "node:fs";
import { resolve } from "node:path";
import { pipeline } from "node:stream/promises";
const IMPORT_DIR = "/var/app/imports/";
const filePath = resolve(IMPORT_DIR, req.body.fileName); // resolve first
if (!filePath.startsWith(IMPORT_DIR)) throw new Error("Disallowed import path");
await pipeline(
createReadStream(filePath),
client.query(copyFrom("COPY staging FROM STDIN")),
);
Two details bite people here. COPY is a utility statement, so it takes no bind parameters at all — COPY staging FROM $1 is a syntax error, the same trap as SET search_path = $1 in Pattern 2, and the same reason people fall back to interpolation. And resolve the path before comparing it: a raw filePath.startsWith(dir) check happily accepts /var/app/imports/../../etc/passwd.
no-unsafe-copy-from (CWE-73, CVSS 9.5) flags dynamic path values in COPY FROM. STDIN is the durable fix because it moves the read to the client, where the file paths are yours.
The vulnerable code:
// ❌ no-missing-client-release (CWE-404) — pool exhaustion
const client = await pool.connect();
try {
const result = await client.query("SELECT ...");
return result.rows;
} catch (err) {
throw err; // client never released — one of these per request and the pool dies
}
Why it survived review. The happy path returns correctly, and the happy path is what reviewers read. The missing client.release() lives in the catch block, the early return when validation fails, or the throw three lines down — the branches your eye skips because "the logic looks right." This also passes every test because a 10-connection pool doesn't exhaust under 3 requests in an integration test. It exhausts under production concurrency at 3pm. The outage is slow-motion: the first few connections drain silently, and then every subsequent request hangs indefinitely.
The ESLint rule:
// ✅ release in finally — runs on return, on throw, on every branch
const client = await pool.connect();
try {
const result = await client.query("SELECT ...");
return result.rows;
} finally {
client.release(); // guaranteed
}
// or: use pool.query() for one-shot queries and skip the lifecycle entirely
const result = await pool.query("SELECT ...");
return result.rows;
no-missing-client-release (CWE-404) catches the omission at lint time, not at peak traffic. If you're using pool.connect() for a single query, prefer-pool-query will also flag it — pool.query() handles acquire-and-release internally.
Three presets ship, and the one you want depends on whether you are adopting or gating:
import { configs } from "eslint-plugin-pg";
export default [
configs.recommended, // all 13 rules — 9 error, 4 warn
// configs.flagship, // just no-unsafe-query — the CI-gate subset
// configs.strict, // all 13 as errors
];
Before you wire this into a pipeline: recommended sets the four quality rules (check-query-params, no-select-all, prefer-pool-query, no-batch-insert-loop) to warn, so they surface without failing a build that only blocks on errors. A first run on an old service should not be a wall. Switch to strict when you want them to gate.
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.
| 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 |
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun — plain dev dependency |
| Node | >= 18.0.0 |
| ESLint | ^8.0.0 || ^9.0.0 || ^10.0.0, flat config |
pg driver | peer ^6 || ^7 || ^8; rules are AST-based and lint regardless of installed version |
| Module system | CommonJS — loads from both 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. |
- 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. A deliberate trade: it buys recall with the occasional false positive on a value you know is safe. A missed injection ships; a wrong flag costs thirty seconds and an
eslint-disableline with a reason on it. Budget for a handful on the first run of an old codebase. - 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.
For where static analysis fits into the broader security workflow across an onboarding sprint, see The 30-Minute Security Audit: A Static Analysis Protocol for Onboarding.
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 — that gap between a general-purpose linter and a driver-aware one is the whole static-analysis-versus-linting question in miniature. eslint-plugin-pg is the dedicated node-postgres layer — injection, the search_path resolution attack, the COPY FROM filesystem path, and the connection-lifecycle bugs that cause outages — each finding tagged with a CWE and CVSS.
This is the install-and-config entry point for the Postgres Security Protocol series. Each rule here has a deep-dive companion:
→ 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: We Ranked 5 AI Models by Security — the database numbers · I Let an AI Write 80 Functions: 65–75% Were Vulnerable
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. That is the good outcome: the patterns were already there, and now they have names, scores, and line numbers. Fix the errors, read the warnings, ship. Then read Three SQL Injection Patterns in node-postgres for the layer underneath this one — the identifier case and the IN (...) trap that survive a naive parameterization pass.
Have you ever had a SQL injection close call in a pg codebase — a query that got as far as staging before someone caught it? What pattern was it? Drop it in the comments.
📦 npm install --save-dev eslint-plugin-pg — 13 rules, one dev dependency.
eslint-plugin-pg is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz