DEV Community

Cover image for PostgreSQL's COPY FROM Can Read /etc/passwd Into Your Database — One ESLint Rule Blocks It.
Ofri Peretz
Ofri Peretz

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

PostgreSQL's COPY FROM Can Read /etc/passwd Into Your Database — One ESLint Rule Blocks It.

The line below is a bulk-import helper. It is also a file reader: PostgreSQL's COPY FROM will pull any file the database process can access — /etc/passwd, your .env, your SSH keys — straight into a table you can SELECT from.

await client.query(`COPY users FROM '${req.body.filepath}'`);
Enter fullscreen mode Exit fullscreen mode

It passed code review. Of course it did — one line, bulk-loads a CSV, every test against data.csv goes green. Then someone sent /etc/passwd as the filename.

So I took the 11 most common ways a node-postgres import actually gets written and ran one ESLint rule over all of them: 7 came back CRITICAL — including the "safe" allowlist pattern I would have shipped myself.

File I/O is also the security domain where AI models are worst at saving themselves. In a 700-function, 5-model benchmark (snapshot 2026-02-09), the best model fixed only 73% of the file-handling vulnerabilities it had written — after being handed the exact lint error.

The Attack

// ❌ User controls file path
const filepath = req.body.filepath;
await client.query(`COPY users FROM '${filepath}'`);
Enter fullscreen mode Exit fullscreen mode

Attacker input:

filepath: /etc/passwd
Enter fullscreen mode Exit fullscreen mode

PostgreSQL now reads your system files into the users table. Default COPY is tab-delimited, so a single-field-per-line file like /etc/passwd needs a shape-compatible target — one text column, or an explicit WITH (FORMAT text) staging table — not your real multi-column users schema; point it at a one-column staging table and every line lands. Either way, the attacker reads the contents back through the same app — a SELECT away.

The precondition, stated up front so nobody has to "well, actually" me in the comments: server-side COPY FROM '<file>' needs the connecting role to be a superuser or a member of pg_read_server_files (PostgreSQL 11+). A strict least-privilege app role can't read /etc/passwd this way — and on managed Postgres the picture is narrower still, since RDS/Aurora's rds_superuser deliberately withholds pg_read_server_files. (This is also the distinction between server-side COPY and psql's \copy, which runs client-side against the client's filesystem and needs no server privilege at all.) But that grant gets handed out more than people admit — self-managed clusters where the app reuses the migration role, the "admin" connection a hurried platform team wired in, any box where someone ran GRANT pg_read_server_files to unblock an import job — and the point of a build-time rule is that you don't want the vulnerable line sitting in the codebase waiting for the day someone widens the grant. The rule flags the pattern regardless of who you connect as, because the privilege boundary is exactly the thing that drifts.

Three exact conditions that make this exploitable:

  1. The connecting role has pg_read_server_files or is a superuser (PostgreSQL 11+)
  2. A user-controlled string reaches COPY ... FROM via SQL string concatenation in Node.js
  3. The postgres OS user has read access to the target file

The Node.js pattern that enables it is almost always the same: a req.body or req.query value flows into a template literal inside client.query(). Every code review sees a database query. None sees a filesystem read.

Why it survived review

COPY FROM is an admin command — developers don't think of it as user-controlled input. It's the documented, idiomatic way to bulk-load CSV into Postgres, and it shows up in seed scripts, ETL jobs, and admin import endpoints. So when a reviewer sees COPY users FROM '<something>', the pattern reads as "normal database operation," not "file read primitive."

The dangerous part isn't the COPY call — it's where filepath came from, and that context lives several stack frames up, in the route handler. The reviewer looking at the data layer sees a string going into a query. They don't ask "is this a request body?" because their mental model of COPY is "loads our CSV," not "reads any file on the database host."

And unlike SQL injection, parameterizing doesn't save you. COPY users FROM $1 is a syntax error — COPY takes a string literal for the path, not a bind parameter. So the muscle memory that protects developers from SELECT injection ("use placeholders") produces nothing here. The only structural fix is to stop passing a path at all.

There's one more reason it gets waved through, and it's the one I keep coming back to: the PR runs green. The import endpoint works perfectly in the demo — you POST a real CSV path, rows land in the table, the test passes, CI is green. Nothing about a happy-path run surfaces the file-read primitive; you only see it when someone sends /etc/passwd instead of data.csv, and no test suite I've reviewed includes that case. A green pipeline on an import helper feels like proof it's safe, which is exactly why a reviewer approves it.

The same blind spot has a couple of close cousins worth grepping for while you're in there: COPY ... FROM PROGRAM '<cmd>' (that's CVE-2019-9193 — command execution, not just file read) and lo_import('<path>'), the large-object path-read that does the same thing through a different door. If your reviewers' mental model is "COPY loads our CSV," all three slip past for the same reason.

PostgreSQL COPY FROM is a filesystem read with SQL syntax. If user input reaches it, your database process is your attacker's shell.

Security References

PostgreSQL COPY FROM path injection maps to standards bodies that got here first. A CWE number names the shape of the bug; an OWASP Top 10 category tells you which neighborhood it lives in. Neither one tells you how loudly to worry:

Standard Reference Description
CWE-73 External Control of File Name or Path Application allows external input to control file paths
CWE-22 Path Traversal Improper limitation of pathname to restricted directory
CVE-2019-9193 PostgreSQL COPY FROM PROGRAM Arbitrary code execution via COPY FROM PROGRAM (PostgreSQL 9.3-11.2)
OWASP A03:2021 Injection Injection attacks including file path manipulation

⚠️ Note: While PostgreSQL considers CVE-2019-9193 a "feature" for superusers, the underlying pattern of user-controlled file paths in application code remains a critical vulnerability.

What Can Be Read

On a typical self-managed PostgreSQL deployment, the postgres OS user has read access to more than you'd expect:

Target Impact
/etc/passwd User enumeration — always readable by any OS user
/etc/shadow Password hashes — normally root:shadow 0640, not readable by postgres; only exposed on a misconfigured host
Application config files Secrets, database credentials — especially anything in the app's working directory
.env files All environment secrets — if the app process and postgres run on the same host
SSH keys Server access — if the key file is group/world-readable or owned by the postgres user
Application source code Logic, vulnerabilities — readable if the code lives on the same host
PostgreSQL data dir postgresql.conf, pg_hba.conf — readable by the postgres user by definition

The /etc/passwd read is the proof-of-concept. The real target in a typical Node.js deployment is .env — one COPY query away from every secret the application holds.

Why AI assistants reintroduce this

Ask Claude, GPT, or Copilot to "write an endpoint that imports a CSV into Postgres" and the path of least resistance is almost always a file-based COPY with the filename taken straight from the request. It is the shortest correct-looking answer: it compiles, it runs against a local CSV, and the model has seen thousands of COPY ... FROM '<path>' examples in its training data — most of them from trusted-input contexts like migrations and seed scripts, where the path was never adversarial.

The model optimizes for "imports a CSV," not "imports a CSV without becoming a file-read primitive" — exactly the same gap a human reviewer has. The prompt asked what the code should do, not what it should prevent. So the vulnerability isn't a rare hallucination; it's the default generation. This is the same failure class I've documented across other AI-generated data layers — a NestJS service that compiled clean and shipped six security holes, and an 80-function Claude experiment where 65–75% of generated code shipped a vulnerability.

And file I/O — the exact domain COPY FROM lives in — is where the models are weakest at saving themselves. When I widened that experiment to a 5-model, 700-function benchmark broken down by security domain, file I/O was the category every model struggled to remediate: the best model fixed only 73% of the file-I/O vulnerabilities it had written (Opus, 19 of 26), and the runner-up sat at 58% (15 of 26) — even after being handed the exact lint error. The same study's database champion fixed 93% (25 of 27); on file paths, nobody got close. So with COPY FROM you get the worst of both halves: the model writes the dynamic-path version by default, and it's in the one domain where the model is least likely to fix it when asked. That asymmetry is the whole argument for a build-time gate.

The fix is the same regardless of who wrote the line: a static rule that fails the build before the code reaches review, because neither the human nor the model is reliably going to catch it. Run the rule below on your AI-generated import endpoints and it will flag the dynamic-path COPY on the first pass.

I ran the rule on 11 ways to write COPY FROM — 7 came back CRITICAL

Those AI-remediation numbers (73% Opus, 58% runner-up, 93% database champion) are my own runs, but they're about file I/O in
general
. So I built a corpus specific to this attack: the 11 canonical shapes
a node-postgres bulk-import actually gets written as — the exact line a reviewer
skims and approves, in eleven flavors:

// dynamic / user-controlled — the actual vuln
client.query(`COPY users FROM '${req.body.filepath}'`);
client.query(`COPY users FROM '${filepath}'`);
client.query("COPY users FROM '" + req.query.path + "'");
client.query("COPY users FROM '" + filename + "'");
client.query(`COPY t FROM '${dir}/${name}.csv'`);
client.query(`COPY orders FROM '${path.join(base, userFile)}'`);
// the "safe-looking" allowlist lookup
const fp = ALLOWED[req.body.type];
client.query(`COPY users FROM '${fp}'`);
// hardcoded literal paths
client.query("COPY users FROM '/tmp/data.csv'");
client.query(`COPY users FROM '/var/imports/seed.csv'`);
// the genuinely safe answer
client.query(copyFrom("COPY users FROM STDIN CSV"));
client.query("COPY users FROM STDIN");
Enter fullscreen mode Exit fullscreen mode

Then I ran eslint-plugin-pg@1.4.3 over all eleven. The tiering:

Tier Count What landed here
🔒 CRITICAL 7 Every dynamic-path shape — including the allowlist "safe" pattern
⚠️ MEDIUM 2 The two hardcoded literal paths (operational risk, not injection)
clean 2 Both COPY FROM STDIN forms

7 of 11 flagged CRITICAL on the first pass — and the one that surprised me is
that the allowlist lookup I'd have written myself sits in that 7, not the safe
column. (Why, and how to actually clear it, is the next section.) This isn't a
vibe: those are the exact eleven lines, so drop them in a file, point the rule at
them, and you get the same 7 / 2 / 2 split. I re-ran it while editing this piece —
eslint-plugin-pg@1.4.6 on ESLint 10.4.1, 2026-07-28 — and the split held, which
is all reproducible
is supposed to mean. That's the gap a build-time gate closes, and the reason I
stopped trusting my own eyeball on a COPY line.

The Correct Pattern

The safe answer is COPY FROM STDIN — stop passing a path at all and stream
validated data through your application instead. It's the only fix the rule treats
as clean, because there's no file path left for an attacker to control. This needs
one runtime dependency beyond the lint rule itself:

npm install pg-copy-streams
Enter fullscreen mode Exit fullscreen mode
// ✅ THE safe pattern — no file path, no file-read primitive
import { from as copyFrom } from "pg-copy-streams";
const stream = client.query(copyFrom("COPY users FROM STDIN CSV"));
// Pipe validated CSV data to stream (full example below)
Enter fullscreen mode Exit fullscreen mode

If you genuinely must read from disk (an admin/migration job, not a request
handler), the next-best option is an allowlist — but here's the part most
"safe pattern" write-ups get wrong, and it cost me a CI run to learn:

// ⚠️ Still flagged CRITICAL — the rule can't see this is now vetted
const ALLOWED_IMPORTS = {
  users: "/var/imports/users.csv",
  products: "/var/imports/products.csv",
};
const importType = req.body.type; // "users" | "products", validated against ALLOWED_IMPORTS keys
const filepath = ALLOWED_IMPORTS[importType];
if (!filepath) throw new Error("Invalid import type");

await client.query(`COPY ${importType} FROM '${filepath}'`); // ← dynamicPath: CRITICAL
Enter fullscreen mode Exit fullscreen mode

That last line is still a template literal with an expression in it. Static
analysis sees ${filepath} and reports dynamicPath — it has no way to know
the value is now a key-lookup into a constant, not raw request input. Running
the actual rule on this exact snippet confirms it:

10:20  error  🔒 CWE-73 OWASP:A03-Injection | Dynamic file path in COPY FROM detected - potential arbitrary file read. | CRITICAL [SOC2,PCI-DSS]
Enter fullscreen mode Exit fullscreen mode

This is the honest seam of any AST-based rule: it reasons about syntax, not
provenance. Following a value back to where it came from is taint
analysis
— what a
full SAST engine
does, and what a single-file lint rule trades away in exchange for running on every
keystroke. So this finding is a genuine false
positive
against your
intent, the allowlist is a real runtime mitigation, and you still have to tell the
rule it was deliberate. Inlining the vetted path as a string literal drops it from
CRITICAL to MEDIUM — the rule stops calling it injection, but still flags it as
server-side file access. To clear it entirely, opt the directory in:

// eslint.config.mjs — the one line that clears the vetted-path case
"pg/no-unsafe-copy-from": ["error", { allowedPaths: ["^/var/imports/"] }],
Enter fullscreen mode Exit fullscreen mode

That's the whole fix for a legitimate import dir; allowHardcodedPaths
does the same job scoped to admin/migration globs (both expanded below). The order
matters, though: allowedPaths is matched against a path the rule could actually
extract, so it clears a MEDIUM and never a CRITICAL — inline the literal first,
then allow it. When in doubt, prefer STDIN. It's the one shape you never have to
argue with the linter about.

COPY TO is Also Dangerous

// ❌ Attacker can write to filesystem
await client.query(`COPY users TO '/var/www/html/shell.php'`);
Enter fullscreen mode Exit fullscreen mode

Same privilege boundary, mirror-imaged: server-side COPY TO '<file>' needs
superuser or pg_write_server_files. Where the grant exists, control over the
written rows plus a dynamic destination path enables:

  • Web shell deployment
  • Configuration file overwrite
  • Cron job injection

no-unsafe-copy-from deliberately targets the read side (COPY FROM) first —
it's the highest-frequency vector, the one that shows up in real import endpoints.
The write side here, plus the FROM PROGRAM and lo_import() cousins from the
review section above, are lower-frequency and aren't covered by a dedicated rule
today, so they stay on the manual grep list for now. (If you've hit one of these
in production, that's exactly the kind of signal that moves a detector up the
queue — the issue tracker is where
it lands.)

The Rule: pg/no-unsafe-copy-from

This pattern is detected by the pg/no-unsafe-copy-from rule from eslint-plugin-pg. The rule uses tiered detection:

Detection Type Severity Triggered By
Dynamic Path 🔒 CRITICAL Template literals with ${var}, string concatenation with variables
Hardcoded Path ⚠️ MEDIUM Literal file paths (operational risk, not injection)
STDIN ✅ Valid COPY FROM STDIN patterns

The rule's own metadata carries cwe: 'CWE-73' and a CVSS base score of 9.5 (v1.4.6) — inside the Critical band, which is why a dynamic path prints CRITICAL rather than High. The MEDIUM tier isn't a second score: it's the same weakness with no attacker-controlled input, so the rule reports the operational risk instead of the injection.

Let ESLint Catch This

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

Use Recommended Config

// eslint.config.mjs — `configs` is a NAMED export (default export is the plugin)
import { configs } from "eslint-plugin-pg";
export default [configs.recommended];
Enter fullscreen mode Exit fullscreen mode

Enable Only This Rule

import pg from "eslint-plugin-pg";

export default [
  {
    plugins: { pg },
    rules: {
      "pg/no-unsafe-copy-from": "error",
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

Configure for Admin Scripts

If you have legitimate admin/migration scripts that use hardcoded file paths:

import pg from "eslint-plugin-pg";

export default [
  {
    files: ["**/migrations/**", "**/scripts/**"],
    plugins: { pg },
    rules: {
      "pg/no-unsafe-copy-from": ["error", { allowHardcodedPaths: true }],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

Allow Specific Paths

import pg from "eslint-plugin-pg";

export default [
  {
    plugins: { pg },
    rules: {
      "pg/no-unsafe-copy-from": [
        "error",
        { allowedPaths: ["^/var/imports/", "\\.csv$"] },
      ],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

Both options take the same values the rule's schema declares — allowHardcodedPaths (boolean) and allowedPaths (an array of regex strings, matched against the extracted path).

What the Lint Error Looks Like

Dynamic Path (CRITICAL - Injection Risk)

src/import.ts
  8:15  error  🔒 CWE-73 OWASP:A03-Injection | Dynamic file path in COPY FROM detected - potential arbitrary file read. | CRITICAL [SOC2,PCI-DSS]
                  Fix: Never use user input in COPY FROM paths. Use COPY FROM STDIN for user data.
Enter fullscreen mode Exit fullscreen mode

Hardcoded Path (MEDIUM - Operational Risk)

src/import.ts
  8:15  warning  ⚠️ CWE-73 | Hardcoded file path in COPY FROM - server-side file access. | MEDIUM
                    Fix: Prefer COPY FROM STDIN for application code. Use allowHardcodedPaths option if this is an admin script.
Enter fullscreen mode Exit fullscreen mode

Before/After: Fixing the Lint Error

❌ Before (Triggers Lint Error)

// This code triggers pg/no-unsafe-copy-from
const filepath = req.body.filepath;
await client.query(`COPY users FROM '${filepath}'`);
Enter fullscreen mode Exit fullscreen mode

✅ After (Lint Error Resolved)

// Use COPY FROM STDIN - the recommended safe pattern
import { from as copyFrom } from "pg-copy-streams";
import { Readable } from "stream";

async function importUsers(csvData) {
  const client = await pool.connect();
  try {
    // ✅ COPY FROM STDIN is safe - no file system access
    const stream = client.query(
      copyFrom("COPY users (name, email) FROM STDIN CSV"),
    );

    // Build CSV from validated rows. STDIN removes the file-read primitive;
    // you still escape CSV-special chars (quotes, commas, newlines) so a value
    // can't break out of its field. Use a real CSV encoder in production.
    const toField = (v) => `"${String(v).replace(/"/g, '""')}"`;
    const validatedCsv = csvData
      .map((row) => `${toField(row.name)},${toField(row.email)}`)
      .join("\n");

    Readable.from(validatedCsv).pipe(stream);

    await new Promise((resolve, reject) => {
      stream.on("finish", resolve);
      stream.on("error", reject);
    });
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

Key changes:

  • Replaced COPY FROM '/path/to/file' with COPY FROM STDIN
  • Data now flows through your application, not the filesystem
  • You control validation before it reaches the database

Compatibility

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

How no-unsafe-copy-from Fits eslint-plugin-pg

no-unsafe-copy-from is the filesystem-access member of eslint-plugin-pg — part
of the wider node-postgres threat model. This article is part of the Postgres
Security Protocol
series:

Postgres Security Protocol:
← The 4 ways a node-postgres data layer fails
· COPY FROM filesystem read (you are here) ·
search_path hijacking →

The rest of the series covers the remaining data-layer attack surface:

New to the plugin ecosystem? Start at the
Interlace ESLint docs — getting started,
then drop into the pg/no-unsafe-copy-from rule doc.


Links

The encouraging part is how cheap this one is to close. There is exactly one shape you never have to argue with — COPY FROM STDIN — and one command that tells you whether a single line in your data layer is still handing PostgreSQL a path:

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

Next in this series, the same data layer breaks a different way: search_path hijacking — one SET search_path TO ${tenant} and your unqualified SELECTs quietly start reading someone else's tables.

Run the rule and tell me what it finds — and do you know which files your database process can read? I'll read every reply.

📦 npm install --save-dev eslint-plugin-pg — it flags a user-controlled COPY FROM path on the first pass, before a reviewer has to be the one who catches it.


eslint-plugin-pg is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz

Top comments (0)