Skip to main content
interlace
Plugin: pgRules

no-missing-client-release

Ensures acquired pool clients are released back to the pool.

CWE: CWE-693

⚠️ This rule errors by default in the recommended config.

Quick Summary

AspectDetails
CWE ReferenceCWE-772 (Missing Release of Resource)
SeverityHigh (CVSS: 7.5)
CategorySecurity

Rule Details

Failing to release clients causes connection pool exhaustion, leading to application hangs.

❌ Incorrect

async function query() {
  const client = await pool.connect();
  await client.query('SELECT 1');
  // Missing client.release() - connection leak!
}

✅ Correct

async function query() {
  const client = await pool.connect();
  try {
    await client.query('SELECT 1');
  } finally {
    client.release();
  }
}

// Released right after the try: success falls through to it, and so does
// the error a catch swallows. A catch that rethrows releases first.
async function bestEffort() {
  const client = await pool.connect();
  try {
    await client.query('SELECT 1');
  } catch (err) {
    console.error(err);
  }
  client.release();
}

// An early return that gives the client back first is not a leak: every
// path out of the try releases exactly once.
async function find(id: string) {
  const client = await pool.connect();
  try {
    const r = await client.query('SELECT * FROM t WHERE id = $1', [id]);
    if (!r.rows.length) {
      client.release();
      return null;
    }
  } catch (err) {
    client.release();
    throw err;
  }
  client.release();
}

// Using pool.query() directly (auto-releases)
async function simpleQuery() {
  await pool.query('SELECT 1');
}

Error Message Format

⚠️ CWE-772 | Pool client acquired but never released | HIGH
   Fix: Add client.release() in a finally block

Known False Negatives

The following patterns are not detected due to static analysis limitations:

Destructured Client

Why: The rule tracks variable references by identifier, not destructured properties.

// ❌ NOT DETECTED
async function query() {
  const { query, release } = await pool.connect();
  await query('SELECT 1');
  // Missing release() call - not detected!
}

Callback Pattern

Why: The callback done parameter requires different tracking.

// ❌ NOT DETECTED
pool.connect((err, client, done) => {
  if (err) return callback(err);
  client.query('SELECT 1', (err, res) => {
    // Missing done() call - not detected!
    callback(err, res);
  });
});

Client Passed to Functions

Why: When the client is handed to another function and this function runs no query on it itself, the helper owns the lifetime (the withClient(client, work) wrapper) and the rule abstains. A function that runs client.query(...) itself and also lends the client to a helper is its owner, and IS checked.

// ❌ NOT DETECTED — ownership handed off
async function query() {
  const client = await pool.connect();
  await executeQueries(client); // Does this release? Rule can't tell
}

// ✅ DETECTED (since 2026-10) — this function owns the transaction
async function signup(name) {
  const client = await pool.connect();
  await client.query('BEGIN');
  await insertUser(client, name);
  await client.query('COMMIT'); // never released
}

Which receivers count as a pool

A new Pool() in the same file, a binding or this.x declared with pg's Pool type (an injected pool), or any .connect() whose result then runs .query(...) — the pool checkout shape, whatever the pool is called. A route file that imports its pool from a relative module (import { pool } from '../db') is linted when that module imports a PostgreSQL driver.

Thrown Exceptions Before Release

Why: The rule checks for presence of .release() call, not control flow paths.

// ❌ NOT DETECTED - release exists but may not execute
async function query() {
  const client = await pool.connect();
  await client.query('SELECT 1');
  throw new Error('Oops'); // Release never reached!
  client.release();
}

Workaround: Always use try/finally pattern or pool.query() for simple queries.

When Not To Use It

  • When using connection wrappers that handle release

Use this rule

npm install -D oxlint-plugin-postgresql-security
.oxlintrc.json
{  "jsPlugins": ["oxlint-plugin-postgresql-security"],  "rules": {    "postgresql-security/no-missing-client-release": "error"  }}
vite.config.ts
import { defineConfig } from 'vite-plus';export default defineConfig({  lint: {    jsPlugins: ['oxlint-plugin-postgresql-security'],    rules: {      'postgresql-security/no-missing-client-release': 'error',    },  },});

oxlint-plugin-postgresql-security re-exports eslint-plugin-postgresql-security/oxlint: the same rules under the same IDs, and no ESLint install. Why oxlint.

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.