Validating File Uploads With Magic Bytes
A filename is a label the caller chose and a content type is a header the caller sent. Neither says anything about the bytes, which is why extension checks have never stopped anyone determined. This guide implements content-based validation properly: bounding the request before it is read, detecting the type from the leading bytes, allowlisting narrowly per feature, and re-encoding accepted files so that anything hiding alongside the real content does not survive.
It is part of the File Upload & Deserialization Security guide within Vulnerability Patterns & Web Mitigation Strategies. Validation is only the first of the four links in the upload chain — the storage and serving controls in serving user uploads from a sandboxed origin matter just as much.
Prerequisites
- A signature detection library —
libmagicbindings,file-type, or an equivalent that reads content - An image processing library you are prepared to keep patched
- The ability to set a maximum body size at the reverse proxy or edge, not only in application code
- A per-feature list of the types the product genuinely needs to accept
Expected Outcomes
- Requests over the size limit rejected before the application allocates memory for them
- Type decided from content and compared with a narrow per-feature allowlist
- Accepted images re-encoded, stripping appended data and metadata
- The detected type recorded alongside the stored object for use when serving it back
Step 1: Bound the Request Before You Read It
Validation that runs after the whole body is in memory has already lost the resource argument. Set the ceiling at the edge, then stream.
# nginx: refuse oversized bodies before any application process is involved.
client_max_body_size 10m;
client_body_buffer_size 128k;
// Node: stream to a temporary sink, counting bytes; abort as soon as the cap is passed.
const MAX = 10 * 1024 * 1024;
async function readBounded(req: Request): Promise<Buffer> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > MAX) {
req.destroy(); // stop reading; do not finish the upload
throw new HttpError(413, 'file too large');
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
Two independent limits are deliberate. The edge limit protects the process from ever seeing the bytes; the application limit protects against a misconfigured edge and documents the intent in code where reviewers will see it.
Step 2: Detect the Type From the Leading Bytes
Read a header window, ask the detector what it is, and compare against the allowlist for this feature — not a global list, because a profile photo endpoint has no reason to accept the same types as a document import.
import { fileTypeFromBuffer } from 'file-type';
const ALLOWED: Record<string, Record<string, string>> = {
avatar: { 'image/png': '.png', 'image/jpeg': '.jpg', 'image/webp': '.webp' },
attachment: { 'image/png': '.png', 'image/jpeg': '.jpg', 'application/pdf': '.pdf' },
};
export async function detect(feature: keyof typeof ALLOWED, bytes: Buffer) {
const head = bytes.subarray(0, 8192);
const found = await fileTypeFromBuffer(head); // reads the signature
if (!found) throw new HttpError(400, 'unrecognised file type');
const allowed = ALLOWED[feature];
if (!(found.mime in allowed)) {
throw new HttpError(400, `${found.mime} is not accepted for ${feature}`);
}
return { mime: found.mime, ext: allowed[found.mime] };
}
Step 3: Re-Encode Accepted Images
Detection identifies the format at the front of the file. It says nothing about what has been appended to the back, embedded in a comment field, or tucked into metadata. Decoding and rewriting removes all three, because the output contains only what the decoder understood.
from PIL import Image, ImageFile
import io
ImageFile.LOAD_TRUNCATED_IMAGES = False # a truncated file is a rejection, not a repair job
MAX_PIXELS = 50_000_000 # decompression bound, before allocation
Image.MAX_IMAGE_PIXELS = MAX_PIXELS
def reencode(data: bytes, mime: str) -> bytes:
with Image.open(io.BytesIO(data)) as img:
img.verify() # structural check, cheap
with Image.open(io.BytesIO(data)) as img: # verify() consumes the file object
img = img.convert("RGB") if mime == "image/jpeg" else img.convert("RGBA")
out = io.BytesIO()
img.save(out, format="JPEG" if mime == "image/jpeg" else "PNG", optimize=True)
return out.getvalue() # no appended archive, no metadata, no comments
The pixel ceiling matters as much as the byte ceiling. A one-megabyte image can declare dimensions that expand to gigabytes of pixel buffer, and the resulting allocation is a denial of service that never needed a large upload at all.
Verification
Prove the defences with files that are deliberately dishonest.
# 1. A script wearing an image name and content type.
printf '<?php system($_GET["c"]); ?>' > /tmp/shell.php.png
curl -s -o /dev/null -w '%{http_code}\n' -F 'file=@/tmp/shell.php.png;type=image/png' \
-H "Cookie: $SESSION" http://localhost:3000/api/uploads
# Expected: 400 — the signature is not an accepted image type
# 2. A real image with an archive appended: accepted, but the archive must not survive.
cat real.png payload.zip > /tmp/polyglot.png
URL=$(curl -s -F 'file=@/tmp/polyglot.png' -H "Cookie: $SESSION" \
http://localhost:3000/api/uploads | jq -r .url)
curl -s "$URL" | tail -c 200 | grep -c 'PK' || echo "archive stripped as expected"
# 3. A pixel bomb: small on the wire, enormous when decoded.
curl -s -o /dev/null -w '%{http_code}\n' -F 'file=@/tmp/50000x50000.png' \
-H "Cookie: $SESSION" http://localhost:3000/api/uploads
# Expected: 400, and no memory spike on the server
A passing run means: dishonest types are refused, honest-but-loaded files are normalised, and decode limits hold before allocation rather than after.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Legitimate uploads rejected as unrecognised | Detector needs more context than the window provides, e.g. container formats | Increase the header window to 8 kilobytes and detect container subtypes explicitly |
| Re-encoding rotates or flips photographs | Orientation metadata is dropped by the rewrite | Read the orientation, apply the transform, then write without the metadata |
| Memory spikes on large images | Byte limit enforced but pixel limit missing | Set a maximum pixel count before decoding and reject beyond it |
| Files accepted by one instance and rejected by another | Different library versions across the fleet | Pin the detector and image library versions and verify them in the health endpoint |
| PDFs pass validation but rendering hangs | Parser fetching remote resources during processing | Disable network access in the conversion sandbox and set a hard timeout |
Common Implementation Mistakes
Frequently Asked Questions
How many bytes do I need to detect a type?
Most signatures sit within the first 16 bytes, but container formats need more: an office document is a zip archive whose real type depends on entries inside it, and some media containers place identifying boxes several kilobytes in. An 8-kilobyte window covers the practical cases without buffering whole files, and is small enough to hold in memory safely while the remainder of the body streams to storage.
Should I reject or re-encode a file that fails a strict check?
Reject anything whose detected type falls outside the allowlist, and re-encode everything inside it. Re-encoding is not a rescue operation for suspicious files — it is normalisation of accepted ones. A valid image that also carries an appended archive stops carrying it once it has been decoded to pixels and written back out, which is why re-encoding is the highest-value step in the pipeline.
What about formats that cannot be re-encoded, like PDF?
Accept them only where the feature genuinely needs them, parse them with the network disabled and a hard timeout, and serve them from the isolated content origin with an attachment disposition so the browser downloads rather than renders them in your security context. Where the product only needs a preview, render one server-side into an image, serve that, and keep the original strictly as a download.
Related
- File Upload & Deserialization Security — the parent guide covering the whole upload chain
- Serving User Uploads From a Sandboxed Origin — the delivery controls that make a stored file harmless
- Safe Deserialization in Python and Node.js — the same “untrusted bytes decide the code path” problem one layer down
- Cross-Site Scripting (XSS) Mitigation — what an accepted file becomes when it is served from the application origin