Why a PDF upload can carry script, why the real cause is trusting a file's label, and the fixes: server-decided types, forced downloads, polyglot checks.
A file called report.pdf doesn’t have to be a PDF. At BHyve, people upload documents so our AI can answer questions from them. One of our weak spots was that a PDF upload could carry script. Here’s how that’s possible, the cause underneath it, and the fixes.
A file name and a content type are just labels
When a browser uploads a file, it sends three things: a name, a content type (a label like application/pdf that says what kind of file it is), and the bytes.
The name and the content type are chosen by whoever sends the request. A browser fills them in honestly. A script can write anything. OWASP’s file upload guidance says the content type can’t be trusted because it is trivial to spoof.
So an attacker can upload a file named invoice.pdf, labelled application/pdf, whose bytes are a web page with a script inside.
The danger is what happens when someone opens it
Storing a hostile file does nothing on its own. The trouble starts when another user clicks it.
If your server sends the file back with the label the uploader chose, say text/html, the browser renders it as a page. If it sends a vague label, the browser may look at the bytes and guess. That guessing is called MIME sniffing, and it can also end in “this looks like HTML”.
Either way, the page loads from your domain. To the browser, its script is your script. It runs as the signed-in person who opened it, with their access. This is stored cross-site scripting (XSS): script saved on your site that runs in other people’s browsers.
A “PDF” can carry script in three broad ways:
- A web page with a PDF name. The easiest to catch.
- A polyglot. A file that really is a PDF and also really is a web page. More on these below.
- A real PDF with its own JavaScript. The PDF format allows embedded JavaScript, and some PDF readers run it. Adobe lets people restrict or switch it off for that reason.
The cause: trusting the label
The cause wasn’t one missing check. It was trust, in two places. The upload path believed what a file said about itself, its name and its type. And the browser was allowed to open the file as part of our site.
Either one alone is a weakness. Together they turn a file that lies about being a PDF into script running as your app. Patching one route or one file type doesn’t settle that. The trust has to go.
So the principle behind the fix has two halves: decide what a file is from its bytes, and never let an upload run as your site. The first half decides what you keep, and magic bytes and polyglot checks serve it. The second limits the damage when a bad file gets through anyway, and headers serve it.
Magic bytes are what a file says about itself
Most file formats begin with a fixed signature, called magic bytes or a magic number. Programs read it to recognise the format, whatever the file is called.
| Format | First bytes (hex) | As text |
|---|---|---|
| 25 50 44 46 | ||
| PNG | 89 50 4E 47 0D 0A 1A 0A | PNG, after one byte |
| JPEG | FF D8 FF | not readable |
| ZIP, DOCX, XLSX | 50 4B 03 04 | PK |
Word and Excel files are ZIP archives underneath, which is why they share ZIP’s signature.
Checking magic bytes means reading the start of the file and asking whether it matches the type you expect. It catches the lazy attack: a web page renamed to .pdf doesn’t start with %PDF, so it fails.
It’s also easy to beat. Type %PDF at the top of the web page and it passes. OWASP says signature checks shouldn’t be used on their own, because bypassing them is common and easy.
A polyglot is one file in two formats
A polyglot is a file that is valid in two formats at once. It works because many formats are forgiving about what sits around the part they care about.
- Browsers render HTML even when there’s junk before the first tag.
- Adobe’s own notes say its PDF readers only need the
%PDFmarker somewhere in the first 1,024 bytes, not right at the start. - ZIP readers find the archive’s index at the end of the file, so a ZIP can be stuck on the end of almost anything.
Put those together and one file can open as a perfectly good PDF in a PDF reader and run as a web page in a browser. It passes the magic-byte check because it really does start like a PDF.
A polyglot check asks a second question: besides being what it claims to be, is this file also something else? In general that means parsing the whole file as the claimed type, not just its first bytes, and rejecting files that carry signs of a second format, such as HTML tags or another file’s signature, where they don’t belong. For some types you can go further and rebuild a clean copy. OWASP calls this content disarm and reconstruction (CDR) and suggests it for formats like PDF and Word.
Fix 1: the server decides the content type
Never store or send back the type the uploader gave you. Work out the type from the bytes, check it against a short list of types you accept, and save that. When you serve the file, send the type your server decided.
This is the core of “checked by content, not by name”. The uploader’s labels become hints at most.
A related header helps. X-Content-Type-Options: nosniff tells the browser to use the content type as given and not guess.
Fix 2: force a download
Content-Disposition is a response header that tells the browser what to do with a file. inline means show it in the page. attachment means download it.
A minimal set of headers for a stored upload looks like this, as a general example:
Content-Type: application/pdf
Content-Disposition: attachment
X-Content-Type-Options: nosniff
Even if a hostile file slips past every other check, a download doesn’t open as a page on your site. It lands in the user’s downloads, away from your domain and the user’s signed-in session.
The trade-off is convenience: no one-click view in a browser tab. If you need previews, build them on purpose, for specific types, rather than serving every upload inline.
Fix 3: check every file, every time
The checks only work if nothing goes around them. An upload form is one way in. An API, an import, a bulk upload and an integration are others. Attackers look for the path you forgot, so one unchecked route undoes the rest. Put the checks in one shared place that every route calls, so a new route can’t skip them by accident.
The upload checklist
Use this for any app that accepts files from users:
- Treat the file name and the uploader’s content type as untrusted labels.
- Accept a short list of types, and decide the type on the server from the bytes.
- Check magic bytes, then parse the whole file as the type it claims to be.
- Reject polyglots: files that also look like HTML, script or another format.
- Serve files with the type you decided and
X-Content-Type-Options: nosniff. - Serve uploads with
Content-Disposition: attachmentunless you’ve built a safe preview for that type. - Give stored files names your server generates. OWASP recommends this so an uploader can’t choose or guess the stored name.
- If you can, serve uploads from a separate domain, so a file that slips through can’t act as your app.
- Run the same checks on every way a file can arrive.
What it changed
Our uploads are now checked by content, not by name. The server decides each file’s type from its bytes, every file goes through magic-byte and polyglot checks, and attachments are served as downloads. A file whose bytes don’t match what it claims to be is rejected. And if one ever slipped through, it would land in someone’s downloads instead of opening as a page on our site.
The lesson
Magic bytes stop the lazy file. The polyglot check stops the clever one. The headers make sure that anything that gets past both still can’t run as your site.
The rest of the security work is in the security case study. The session side has its own post: a session security checklist. And once a file is safely stored, getting useful text out of it is a different problem, covered in PDF extraction with Docling, MinerU and MarkItDown.
Quick answers
- What are magic bytes?
- Magic bytes are the first few bytes of a file that identify its format. A PDF starts with the characters %PDF, and a PNG starts with the bytes 89 50 4E 47 0D 0A 1A 0A.
- What is a polyglot file?
- A polyglot is one file that is valid in two formats at once, such as a file that opens as a PDF and also renders as a web page. It can pass a check that only reads the first few bytes.
- What does Content-Disposition: attachment do?
- It tells the browser to download the file instead of showing it. HTML or script inside the file is then never rendered as part of your site.
- Can you trust the Content-Type of an uploaded file?
- No. Whoever sends the upload chooses it, so it can say anything. OWASP calls it trivial to spoof. Decide the type on the server from the file's bytes.
- Is checking magic bytes enough to secure file uploads?
- No. Magic bytes are easy to fake, and OWASP says not to rely on them alone. Combine them with full parsing, polyglot checks, server-decided content types and forced downloads.