DEV Community

Cover image for Your node-postgres Data Layer Fails 4 Ways in Production. SQL Injection Is Only the First.
Ofri Peretz
Ofri Peretz

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

Your node-postgres Data Layer Fails 4 Ways in Production. SQL Injection Is Only the First.

Ask a backend engineer how the database layer fails and you'll hear "SQL
injection." It's real, it's CWE-89, and it's one of four structural ways a
node-postgres data layer breaks in production. The other three — identifier
hijacking, connection-pool exhaustion, insecure transport — don't make the OWASP
headlines, but they page you at 3 AM all the same.

Each is a structural pattern (it survives code review precisely because each
line looks fine in isolation), and each has a dedicated rule in
eslint-plugin-pg. Here's the threat model and the rule that closes it.

# Failure mode What an attacker (or load) controls The pg rule CWE
1 Injection via values a value spliced into the query string no-unsafe-query CWE-89
2 Identifier hijacking a table/schema name (search_path) no-unsafe-search-path CWE-426
3 Connection exhaustion a leaked pool client → pool empties no-missing-client-release CWE-404
4 Insecure transport TLS turned off to the database no-insecure-ssl CWE-319

1. Injection via values — no-unsafe-query (CWE-89)

The classic. A user-controlled value is concatenated or interpolated into the
SQL text instead of being passed as a parameter:

client.query(`SELECT * FROM users WHERE id = ${req.query.id}`); // ❌
client.query("SELECT * FROM users WHERE id = $1", [req.query.id]); // ✅
Enter fullscreen mode Exit fullscreen mode

The $1 placeholder + values array is pg's escaping contract — the driver
handles quoting and types, and the pattern can't be accidentally broken.

src/users.js
  3:3  error  🔒 CWE-89 OWASP:A03-Injection CVSS:9.8 | Unsafe SQL query detected. Variable interpolation found. | CRITICAL [SOC2,PCI-DSS,NIST-CSF]
             Fix: Use parameterized queries ($1, $2) instead of string concatenation.
Enter fullscreen mode Exit fullscreen mode

(The ESLint CLI also appends the rule's doc URL to the Fix: line; it's trimmed
here for width.) The rule fires on +-concatenation, ${…} template expressions, and
cross-line tainted variables in .query() calls — the full taxonomy is in
Three SQL Injection Patterns That Still Ship.

2. Identifier hijacking — no-unsafe-search-path (CWE-426)

Here's the part parameterization can't help with: $1 binds values, not
identifiers. A table, column, or schema name can't be a bind parameter — so
when the schema is dynamic, teams fall back to interpolation, and an attacker who
controls search_path re-points an unqualified SELECT * FROM users at their
own table.

await client.query(`SET search_path TO ${tenant}`); // ❌ identifier injection
Enter fullscreen mode Exit fullscreen mode

SET rejects parameters, so the fix is identifier-escaping (pg-format's %I)
or an allow-list — not a bind. The full attack and the defenses are in
search_path Hijacking.

3. Connection exhaustion — no-missing-client-release (CWE-404)

Not an attacker — just normal load against a leak. A pool.connect() whose
client is never released drains a 100-connection pool in minutes, and then
every query times out:

const client = await pool.connect();
const rows = await client.query("..."); // ❌ no client.release() — leaked
Enter fullscreen mode Exit fullscreen mode

Release in a finally, or use pool.query() for single-shot queries. The 3 AM
post-mortem is in
The Connection Leak That Exhausted Our Pool.

4. Insecure transport — no-insecure-ssl (CWE-319)

The one-line config that ships secrets in cleartext to the database:

new Pool({ ssl: { rejectUnauthorized: false } }); // ❌ accepts any cert (MITM)
Enter fullscreen mode Exit fullscreen mode

rejectUnauthorized: false disables certificate validation — convenient against
a self-signed dev cert, catastrophic in production. no-insecure-ssl flags it;
use a real CA bundle (ssl: { ca: fs.readFileSync(...) }) instead.


One config turns on all four

# npm
npm install --save-dev eslint-plugin-pg
# yarn
yarn add -D eslint-plugin-pg
# pnpm
pnpm add -D eslint-plugin-pg
# bun
bun add -d eslint-plugin-pg
Enter fullscreen mode Exit fullscreen mode
// eslint.config.mjs — `configs` is a NAMED export (default export is the plugin)
import { configs } from "eslint-plugin-pg";

export default [
  // scope to where the database code lives
  {
    files: ["**/db/**", "**/repositories/**", "**/models/**"],
    ...configs.recommended,
  },
];
Enter fullscreen mode Exit fullscreen mode
# CI — block the PR on any new data-layer finding
- run: npx eslint . --max-warnings 0
Enter fullscreen mode Exit fullscreen mode

Compatibility

Surface Support
Package managers npm, yarn, pnpm, bun
Node >= 18.0.0
ESLint `^8.0.0 \
{% raw %}pg driver peer `^6 \
Module system CommonJS — {% raw %}eslint.config.js or .mjs
Oxlint Loads under Oxlint's JS-plugin runner via the interlace-pg port, parity-gated in CI

Where this fits

This is the data-layer threat map. Each failure mode has a dedicated deep-dive,
and the full plugin tour covers the rest of the 13 rules:


Links

⭐ Star on GitHub if your data layer fails any of these four ways.


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.

ofriperetz.dev · LinkedIn · GitHub

Top comments (0)