PostgreSQL's COPY FROM command can read any file the database process can access — including /etc/passwd. Here's the 1-line query that does it and the ESLint rule that blocks it.
await client.query(`COPY users FROM '${req.body.filepath}'`);
That line passed code review. It was a one-line bulk-import helper — the kind of code a reviewer skims and approves. Then someone sent /etc/passwd as the filename.
COPY FROM bulk-loads data from a file path. If that path is user-controlled, an attacker chooses the file — and PostgreSQL reads it into a table you can SELECT from. /etc/passwd, your .env, your SSH keys: whatever the postgres OS user can read. I took the 11 most common ways a node-postgres import gets written and ran one ESLint rule over them: 7 came back CRITICAL — including the "safe" allowlist pattern I'd have shipped myself. File I/O is also the one security domain where AI models are worst at self-correcting: in a 700-function, 5-model benchmark, the best model only fixed 73% of file-handling vulnerabilities it had written even after seeing the lint error. Here's the attack, why it survives review, why every AI assistant reintroduces it, and the rule that tiers a COPY path — CRITICAL on a dynamic one, MEDIUM on a hardcoded one — before it ships.
// ❌ User controls file path
const filepath = req.body.filepath;
await client.query(`COPY users FROM '${filepath}'`);
Attacker input:
filepath: /etc/passwd
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:
- The connecting role has
pg_read_server_filesor is a superuser (PostgreSQL 11+) - A user-controlled string reaches
COPY ... FROMvia SQL string concatenation in Node.js - The
postgresOS 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.
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 vector is: a user-controlled string reaches COPY ... FROM via SQL string concatenation in Node.js. Every code review saw a database query. None saw a filesystem read. 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 FROMis a filesystem read with SQL syntax. If user input reaches it, your database process is your attacker's shell.
PostgreSQL COPY FROM path injection maps to standards bodies that got here first:
| 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.
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.
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.
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");
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'll get the same 7 / 2 / 2 split. That's the gap a build-time gate
closes — and the reason I stopped trusting my own eyeball on a COPY line.
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
// ✅ 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)
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
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:
2:20 error 🔒 CWE-73 OWASP:A03-Injection | Dynamic file path in COPY FROM detected. | CRITICAL [SOC2,PCI-DSS]
This is the honest seam of any AST-based rule: it reasons about syntax, not provenance. So the allowlist is a real runtime mitigation, but you still have to tell the rule it's intentional. 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/"] }],
That's the whole fix for a legitimate import dir; allowHardcodedPaths
does the same job scoped to admin/migration globs (both expanded below). When in
doubt, prefer STDIN — it's the one shape you never have to argue with the linter
about.
// ❌ Attacker can write to filesystem
await client.query(`COPY users TO '/var/www/html/shell.php'`);
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 — open an issue.)
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 |
npm install --save-dev eslint-plugin-pg
// eslint.config.mjs — `configs` is a NAMED export (default export is the plugin)
import { configs } from "eslint-plugin-pg";
export default [configs.recommended];
import pg from "eslint-plugin-pg";
export default [
{
plugins: { pg },
rules: {
"pg/no-unsafe-copy-from": "error",
},
},
];
If you have legitimate admin/migration scripts that use hardcoded file paths:
export default [
{
files: ["**/migrations/**", "**/scripts/**"],
rules: {
"pg/no-unsafe-copy-from": ["error", { allowHardcodedPaths: true }],
},
},
];
export default [
{
rules: {
"pg/no-unsafe-copy-from": [
"error",
{ allowedPaths: ["^/var/imports/", "\\.csv$"] },
],
},
},
];
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.
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.
// This code triggers pg/no-unsafe-copy-from
const filepath = req.body.filepath;
await client.query(`COPY users FROM '${filepath}'`);
// 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();
}
}
Key changes:
- Replaced
COPY FROM '/path/to/file'withCOPY FROM STDIN - Data now flows through your application, not the filesystem
- You control validation before it reaches the database
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun |
| Node | >= 18.0.0 |
| ESLint | ^8.0.0 || ^9.0.0, flat config (peer range also declares ^10.0.0 ahead of its stable release) |
pg driver | peer ^6 || ^7 || ^8; AST-based, lints regardless of installed version |
| Module system | Plugin ships CommonJS; your config can be eslint.config.js or .mjs |
| Oxlint | Loads under Oxlint's JS-plugin runner via the interlace-pg port, parity-gated in CI |
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_pathhijacking →
The rest of the series covers the remaining data-layer attack surface:
- SQL injection + identifier hijacking + connection exhaustion + insecure transport — the four canonical
node-postgresfailure modes - search_path hijacking — how a
SET search_path TO ${tenant}re-routes unqualified SELECTs (Dev.to) - The connection leak that exhausted our pool — a 3 AM post-mortem on CWE-404
- The three SQL-injection patterns that survive code review (Dev.to)
- Every rule in
eslint-plugin-pg
New to the plugin ecosystem? Start at the
Interlace ESLint docs — getting started,
then drop into the pg/no-unsafe-copy-from rule doc.
- 📦 npm: eslint-plugin-pg
- 🚀 Interlace ESLint docs: getting started
- 📖 Rule docs: no-unsafe-copy-from
- 💻 Source on GitHub
Run the rule against your own data layer and tell me what it finds. Have you audited your PostgreSQL-facing Node.js code for COPY FROM patterns — and do you know what files your DB process has read access to? I'll read every reply.
eslint-plugin-pg is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz