A while back I renamed a Windows executable to invoice.pdf, uploaded it through a form that "only accepts PDFs," and watched it sail straight into an S3 bucket. The upload code was fine. The validation was fine. Everything did exactly what it was written to do — which was the problem.
That afternoon reset how I think about file uploads. So this post is the thing I wish someone had shown me earlier: the three ways file validation quietly fails, the question nobody asks about where those files actually go, and why a rejected upload is worth more than you think. There's real code you can copy, and the ideas hold no matter what you build with.
The uncomfortable truth: an upload is untrusted input
We're careful with untrusted input everywhere else. We parameterize SQL. We escape HTML. We validate JSON against a schema. And then a user hands us a file — an opaque blob of bytes from the outside world — and we check the part of it that's easiest to fake and call it a day.
Here's the check I see most often, and probably wrote myself more than once:
// The validation that isn't
const allowed = [".pdf", ".png", ".jpg"];
const ext = path.extname(file.originalname).toLowerCase();
if (!allowed.includes(ext)) {
return res.status(400).json({ error: "Invalid file type" });
}
// ...store it
Two things this trusts, both attacker-controlled: the filename, which the client sends and can be anything, and the Content-Type header if you check that instead — also client-supplied. Neither says a single true thing about what's inside the file. invoice.pdf was a PE binary. The extension check waved it through because the extension is just a string the uploader typed.
Failure #1: the format is a lie
A file's real type lives in its bytes, not its name. Most formats start with a magic number — a signature in the first few bytes. A real PDF begins with %PDF-. A PNG begins with the bytes 89 50 4E 47. So the honest first check is: does the content match the claimed format?
// Read the leading bytes and compare against known signatures
const fd = await fs.open(file.path, "r");
const buf = Buffer.alloc(8);
await fd.read(buf, 0, 8, 0);
await fd.close();
const isPDF = buf.slice(0, 5).toString("ascii") === "%PDF-";
const isPNG = buf.slice(0, 4).toString("hex") === "89504e47";
This alone would have stopped my fake invoice. But it's still not enough, because of the nastier cousin: the polyglot file. A polyglot is a single file that is validly two formats at once — a legitimate PDF that is also a working ZIP or an HTML page, depending on who opens it. The magic bytes look perfect. Your PDF viewer renders a real document. Another parser downstream sees something else entirely. Checking the header isn't enough; the internal structure has to be coherent all the way through.
Failure #2: the file is "valid" and completely useless
Not every bad upload is an attack. A huge share is just junk that passes every type check and then quietly breaks something later:
- A PDF with real structure but zero rendered content — a blank scan.
- A spreadsheet with headers and no rows.
- An image that is a single flat color because the camera was covered.
- A "text file" that is nothing but whitespace.
Every one of these is a structurally valid file. Type checking says yes. But if a user uploaded a blank page where a signed form was supposed to go, you don't have a valid upload — you have a silent data-quality bug that surfaces three weeks later in a report nobody can explain. Catching this means looking at the content: page count, extracted text length, pixel variance, row count.
Failure #3: the threat is inside the bytes
And then there's the actual malware — files carrying known signatures, embedded scripts, or exploit patterns aimed at whatever parses them next. You cannot eyeball this. It needs a real scanner with a maintained signature database (ClamAV is the standard open-source one) running over the raw bytes before the file is ever handed to another service.
Put the three together and a file you can trust is one that has cleared all of them: structural validity (bytes match the declared format, polyglots included), content sanity (not blank or garbage for its type), threat-free (scanned against known malware), and policy fit (obeys your rules for this path — allowed extensions, size, rate limits). That last one matters more than it looks: a profile-avatar endpoint and a medical-records endpoint have completely different definitions of "acceptable," and the check should know which one it's running.
The question nobody asks: where does the file actually go?
Here's the trap I walked into next. The obvious way to add validation is to POST every upload to some checking service. But think about what that means: you've just created a second copy of your users' data on infrastructure you don't control. For anything regulated — patient records, financial documents, anything with a data-residency requirement — that's not a convenience, it's a compliance problem you invented for yourself.
The cleaner model is to separate inspection from storage. A validator should look at the bytes as they pass through and then write the clean file into a bucket you own — your account, your region, your encryption keys. It shouldn't matter whether that bucket lives in AWS S3, Azure Blob Storage, or Google Cloud Storage; your data stays in your cloud, and the validator only ever holds metadata and the audit trail. Bring your own bucket. The thing checking your files should never become the thing that has your files.
The other half: a rejected upload is threat intelligence
We treat a 400 as the end of the story. Someone uploaded a bad file, we blocked it, done. But look at rejections in aggregate and a different picture appears: the same IP retrying with a dozen spoofed extensions, a burst of virus hits against one specific context, a caller probing your size limits at 3am. That isn't noise — that's reconnaissance, and most stacks log it to a file nobody reads and throw the signal away.
Give those rejections a home and uploads become a security surface you can actually watch: a score for how exposed you are, a breakdown of what's being thrown at you (malware vs. policy violations vs. rate-limit abuse), which IPs and callers are behind it, and how attack patterns shift over time. The block is the easy part. Knowing who keeps knocking is the part that lets you respond before it matters.
Where this left me
I kept rebuilding slices of all this on every project — magic-byte checks here, a ClamAV container there, a half-working blank-PDF heuristic, and then re-solving "but where does the file go" every single time. It was always the same pipeline, always slightly wrong, always first on the chopping block when a deadline got close.
So I built it properly as a service I could just call, and I've been running it as Uplint. One request runs the full trust pipeline:
const result = await uplint.validate(file, {
context: "patient-records",
scan: true,
detectBlanks: true,
});
if (!result.trusted) {
// result.reasons: "content_mismatch" | "blank_document" | "threat_detected" | ...
}
Clean files land in your own S3, Azure, or GCS bucket — Uplint holds the metadata and audit log, not your data — and every rejection rolls into a Security Center that turns blocked uploads into a live threat view. I'm sharing it because the problem is common enough that I doubt I'm the only one who kept reinventing it badly. It's free right now (500 uploads/month), and to be upfront: it's my project, so I have skin in the game — I'd honestly rather have your feedback than your signup.
The part that's true no matter what you use
If you take nothing else from this: stop validating uploads by their name. Check the bytes. Check that there's real content inside them. Scan them before anything downstream touches them. Keep them in storage you control. And don't throw away what your rejections are telling you. Do it with your own code, a library, or a service — doesn't matter. Just don't let a string the uploader typed decide what gets into your system.
What's the worst file that ever made it past your upload validation? I'll go first — mine was a .pdf that fought back. 🙂
Top comments (0)