DEV Community

Cover image for PostgreSQL Injection in Node.js: 4 Patterns That Pass Code Review, and the 13 Rules That Catch Them
Ofri Peretz
Ofri Peretz

Posted on • Edited on • Originally published at ofriperetz.dev

PostgreSQL Injection in Node.js: 4 Patterns That Pass Code Review, and the 13 Rules That Catch Them

Four pg patterns 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.


Install it first (60 seconds)

npm install --save-dev eslint-plugin-pg
Enter fullscreen mode Exit fullscreen mode

Flat config (eslint.config.js):

import { configs } from "eslint-plugin-pg";

export default [
  configs.recommended, // all 13 rules
];
Enter fullscreen mode Exit fullscreen mode

Run it:

npx eslint .
Enter fullscreen mode Exit fullscreen mode

That is the entire setup. Everything below is what the 13 rules are actually for.


Pattern 1: String interpolation in query() (CVSS 9.8, CWE-89)

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);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.


Pattern 2: Dynamic SET search_path (CVSS 7.5, CWE-426)

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}`);
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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.


Pattern 3: COPY FROM with untrusted path (CVSS 9.5, CWE-73)

The vulnerable code:

// ❌ no-unsafe-copy-from (CWE-73, CVSS 9.5)
await client.query(`COPY staging FROM '${req.body.filePath}'`);
Enter fullscreen mode Exit fullscreen mode

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")),
);
Enter fullscreen mode Exit fullscreen mode

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.


Pattern 4: Missing client.release() (CWE-404)

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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.


Picking a preset, and reading the output

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
];
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

The full rule set (all 13, v1.4.6, CWE-tagged)

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

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 missing release(). It can't see your actual schema, your GRANTs, 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-disable line 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.

Where this fits in the broader picture

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


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. 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

Top comments (0)