It 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.
The Attack
// ❌ 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. The attacker reads them 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.
Why this survives code review
COPY FROM is not exotic. It is 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 trap is the same one behind SQL injection: 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." Nobody holds the full data-flow in their head while reviewing an import helper one line at a time.
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.
Security References
This vulnerability is well-documented in industry security standards:
| 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
| Target | Impact |
|---|---|
/etc/passwd |
User enumeration |
/etc/shadow |
Password hashes — but normally root:shadow 0640, not readable by the postgres user; only exposed on a misconfigured host |
| Application config files | Secrets, database credentials |
.env files |
All environment secrets |
| SSH keys | Server access (if the key file is group/world-readable) |
| Application source code | Logic, vulnerabilities |
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 against every way you'd write this
The 73% / 58% / 93% numbers above 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 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:
// ✅ 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 filepath = ALLOWED_IMPORTS[req.body.type];
if (!filepath) throw new Error("Invalid import type");
await client.query(`COPY users 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.
COPY TO is Also Dangerous
// ❌ 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.)
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 |
Let ESLint Catch This
npm install --save-dev eslint-plugin-pg
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];
Enable Only This Rule
import pg from "eslint-plugin-pg";
export default [
{
plugins: { pg },
rules: {
"pg/no-unsafe-copy-from": "error",
},
},
];
Configure for Admin Scripts
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 }],
},
},
];
Allow Specific Paths
export default [
{
rules: {
"pg/no-unsafe-copy-from": [
"error",
{ allowedPaths: ["^/var/imports/", "\\.csv$"] },
],
},
},
];
What You'll See
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.
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.
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}'`);
✅ 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();
}
}
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
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 |
Where this fits
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 - The connection leak that exhausted our pool — a 3 AM post-mortem on CWE-404
- The three SQL-injection patterns that survive code review
- 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.
Links
- 📦 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. What shipped because the PR ran green on a happy-path CSV — what's the riskiest thing your import endpoint trusts from a request body? I'll read every reply.
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.
Top comments (0)