File Upload Security
User-uploaded files are one of the highest-leverage attack surfaces. A single endpoint that accepts * MIME types and writes to a server-served path is a path to RCE, XSS, SSRF, stored-XSS in PDFs, and a half-dozen other failure modes. The defaults of every web framework do less than they should — this skill is what to add on top.
Generic, not CMS-specific. For Payload-specific tuning see payload-cms-security. For WordPress see wordpress-hardening. For storage choice / backend architecture see backend-architecture.
When to invoke
- Adding file upload to an endpoint for the first time
- A user-uploaded file caused or contributed to an incident
- Migrating from local-disk uploads to object storage (R2 / S3 / Spaces)
- Reviewing an existing upload feature you inherited
- Adding new file types (e.g. previously images only, now PDFs too)
The threat model
A user can upload anything labeled as anything. From your perspective:
- The filename is attacker-controlled —
../../etc/passwd,🔥.jpg.exe,index.html - The
Content-Typeheader is attacker-controlled — JPG bytes claiming to beapplication/json, executable bytes claiming to beimage/png - The file extension is attacker-controlled
- The file contents may be a polyglot — bytes that are valid as image and valid as HTML / JavaScript / PHP
Defense: trust nothing the client says about the file. Inspect the bytes, re-derive the type, generate your own key for storage, serve from an origin that cannot execute anything.
Step 1 — Validate at the boundary
Size limits, server-side
import multer from 'multer';
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024, // 10 MB hard cap
files: 5,
fields: 20,
fieldSize: 1024,
fieldNameSize: 100,
},
});
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'no file' });
// ... continue with content validation
});
Defaults from middleware libraries are too generous (50MB+). Set what you actually need. Different endpoints get different caps — avatar upload doesn't need 10MB.
Magic-byte detection (not MIME)
Content-Type is a hint. Read the first bytes to determine the actual type.
import { fileTypeFromBuffer } from 'file-type';
const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected) {
return res.status(400).json({ error: 'unrecognized file type' });
}
const ALLOWED_MIMES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
]);
if (!ALLOWED_MIMES.has(detected.mime)) {
return res.status(400).json({ error: `type ${detected.mime} not allowed` });
}
For Python: python-magic (libmagic bindings). For Go: mimetype. For Ruby: marcel.
Allowlist, not denylist. "Anything except .exe and .php" misses .phtml, .phar, .shtml, .cgi, .pl, .jsp, .aspx, and tomorrow's bypass.
Filename — never trust the client's
Never store with the client-supplied name. Generate one server-side.
import { randomUUID } from 'node:crypto';
const ext = detected.ext; // from file-type, not from filename
const storageKey = `uploads/${userId}/${randomUUID()}.${ext}`;
Patterns to enforce in the storage key:
- Generated, not user-supplied — UUID, ULID, or content-hash (SHA-256 of bytes — also gives you free dedup)
- No path traversal possible — no
.., no leading/, no special characters. With a UUID this is automatic. - Scoped by owner —
uploads/<userId>/<random>makes per-user access policies trivial - No executable extensions — extension comes from server-detected type, not client filename
If you must keep the original filename for display, store it as a separate database field, never as part of the storage key.
Step 2 — Defang content
The bytes are the problem. After validation, transform the file so any embedded payload becomes inert.
Images: re-encode
Run every image through sharp (or equivalent) to a known format. This:
- Strips polyglot bytes — the re-encoded image is just the visual content
- Removes EXIF metadata — including geotags (PII leak)
- Normalizes the format — your storage layer holds known-good images
import sharp from 'sharp';
const processed = await sharp(req.file.buffer)
.rotate() // honor EXIF orientation, then drop EXIF
.resize({ width: 4096, height: 4096, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85, progressive: true }) // or .webp() for modern
.withMetadata({ exif: {} }) // strip EXIF
.toBuffer();
// Upload `processed`, not `req.file.buffer`
A PHP webshell embedded in a JPEG file does not survive re-encoding. So does most ImageTragick-style abuse. The cost is a few hundred ms of CPU per upload.
PDFs and documents
Re-encoding a PDF is harder than re-encoding an image, but you can:
- Strip JavaScript with
qpdf/pdfcpu— removes embedded JS that some PDFs use for "interactive" forms (or for malware) - Convert to PDF/A — a more restricted PDF subtype, no JS allowed
- Render to image + new PDF — destroys text but eliminates everything else; only when you really need it (legal redaction, etc.)
# Strip JavaScript and re-write the PDF
qpdf --decrypt --remove-restrictions input.pdf cleaned.pdf
# Or convert to PDF/A
qpdf --object-streams=disable --linearize input.pdf cleaned.pdf
Office documents
Active content is the issue (VBA macros, OOXML scripting). Three options, increasingly strict:
- Scan for macros — reject if present (LibreOffice headless can introspect)
- Convert to PDF server-side and store the PDF — destroys macros and most other active content
- Don't accept Office docs — accept PDF only, instruct users to convert client-side
Generic / unknown types
If you really must accept "any file", treat the upload like a quarantine zone:
- Never serve from a domain that can execute
- Always with
Content-Disposition: attachment(forces download, not inline render) - Pass through a virus scanner (ClamAV is free, decent for known malware) — see Step 4
Step 3 — Storage and serving
Storage: object storage, not local disk
See backend-architecture for the "images disappear on redeploy" trap. For uploads specifically:
- S3-compatible storage (S3, R2, B2, Spaces, MinIO if self-hosted)
- Private bucket with signed URLs for access — never make the bucket public unless the content really is public
- Path scoped by owner —
uploads/<userId>/<key>so IAM policies can restrict access by prefix
Serving: separate origin, no script execution
The cardinal sin: serving user uploads from the same origin as your app, with Content-Type derived from extension and no Content-Disposition. An attacker uploads xss.html, your server serves it as text/html from your app's origin, the attacker links to it, and now they have stored XSS with same-origin access to your auth cookies.
Patterns:
- Serve from a separate domain —
cdn.example.comoruploads.example.com. Even if XSS lands here, your auth cookies are not in scope. Content-Disposition: attachmentfor non-image uploads — forces download instead of inline rendering for browsers that would otherwise sniff into something dangerous- Strict
Content-Type— match the server-detected type from Step 1, not the original X-Content-Type-Options: nosniff— disables browser MIME-sniffingContent-Security-Policy: default-src 'none'for the uploads origin — even if HTML lands there, the CSP refuses to render scripts/images/anything- No directory listing on the bucket
# Example serving config for the upload origin (nginx fronting R2/S3)
server {
listen 443 ssl;
server_name uploads.example.com;
add_header X-Content-Type-Options nosniff always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'unsafe-inline';" always;
add_header Strict-Transport-Security "max-age=31536000" always;
# Force download for anything not in the image allowlist
location ~* \.(jpg|jpeg|png|webp|gif|svg)$ {
# served inline as images
}
location / {
add_header Content-Disposition "attachment" always;
}
}
SVG is special
SVG is XML with <script> support. If a user uploads evil.svg and you serve it as image/svg+xml, browsers render the SVG and execute its scripts. SVG is effectively HTML in disguise.
Options:
- Don't accept SVG if you can avoid it (most "image upload" features don't need vector)
- Sanitize SVG with
DOMPurify's SVG mode orsvg-sanitizer(PHP) before storing - Force download with
Content-Disposition: attachmenteven if served asimage/svg+xml
Signed URLs for private content
For per-user / per-tenant uploads (not public), generate short-lived signed URLs at access time:
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
app.get('/api/files/:id', requireAuth, async (req, res) => {
const file = await db.files.findFirst({
where: { id: req.params.id, userId: req.user.id } // BOLA check — see api-security
});
if (!file) return res.status(404).end();
const url = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: 'uploads', Key: file.storageKey }),
{ expiresIn: 300 } // 5 minutes
);
res.json({ url });
});
5-minute TTL is plenty for direct downloads; longer only if needed.
Step 4 — Virus scanning (for non-image uploads)
For PDFs, Office docs, archives, and "any file" features, run a virus scan. Not perfect, but catches known-malicious files.
- ClamAV is free, self-hostable, decent for known malware. Run as a daemon, scan via socket.
- VirusTotal API for higher fidelity (paid) — also gives you reputation signals
- Hosted scanners — Cloudflare (with paid tier features), bundled with some CDN providers
// Sketch — clamav-client style
import { Clam } from 'clamav.js';
const clam = new Clam({ host: '127.0.0.1', port: 3310 });
const scanResult = await clam.scan(req.file.buffer);
if (scanResult.isInfected) {
await db.uploadAttempts.create({
data: { userId: req.user.id, reason: 'av-positive', signature: scanResult.viruses.join(',') }
});
return res.status(400).json({ error: 'file rejected' });
}
False positives happen — log them, allow operator review, don't punish the user with a confusing error.
Step 5 — Pre-signed PUT for large uploads
For files > a few MB, having the user upload through your app server is wasteful. Pre-signed PUT URLs let the browser upload directly to S3/R2 while your app stays in control:
import { PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
app.post('/api/uploads/request', requireAuth, async (req, res) => {
const { filename, size, mime } = req.body;
// Server-side validation BEFORE issuing the URL
if (size > 100 * 1024 * 1024) return res.status(400).json({ error: 'too large' });
if (!ALLOWED_MIMES.has(mime)) return res.status(400).json({ error: 'type not allowed' });
const storageKey = `uploads/${req.user.id}/${randomUUID()}`;
const url = await getSignedUrl(s3, new PutObjectCommand({
Bucket: 'uploads',
Key: storageKey,
ContentType: mime,
ContentLength: size,
}), { expiresIn: 300 });
// Record the pending upload so we know what to validate after
await db.pendingUploads.create({ data: { storageKey, userId: req.user.id, expectedSize: size, expectedMime: mime }});
res.json({ url, storageKey });
});
// Browser PUTs the file directly to `url`. After upload completes,
// the client calls /api/uploads/confirm with the storageKey.
app.post('/api/uploads/confirm', requireAuth, async (req, res) => {
const { storageKey } = req.body;
const pending = await db.pendingUploads.findFirst({
where: { storageKey, userId: req.user.id }
});
if (!pending) return res.status(404).json({ error: 'no pending upload' });
// Fetch the uploaded object, validate magic bytes server-side, then re-encode/scan
const obj = await s3.send(new GetObjectCommand({ Bucket: 'uploads', Key: storageKey }));
// ... validation, re-encoding, scanning as usual
// If validation fails, delete the object
});
The browser uploads big files efficiently; your app stays the gatekeeper for validation, scanning, and re-encoding (via a worker that picks up confirmed uploads).
Step 6 — Operational hygiene
- Orphan cleanup — pending uploads that never confirmed, abandoned files when records are deleted. Daily cron.
- Per-user quota — soft cap with notification, hard cap with rejection. Prevents one user filling your bucket.
- Per-IP / per-account rate limit on upload endpoints — stops bulk abuse
- Backup the bucket — see
backup-disaster-recovery. Object storage is durable but not deletion-proof. - Audit log of uploads — who uploaded what, when, what was the validation result. See
log-strategy. - Periodic re-scan — virus signatures update; what passed yesterday may be flagged today. Re-scan high-value buckets monthly.
Checklist
For a file-upload feature going to production:
- Server-side size cap matches actual need (not framework default)
- Magic-byte detection used; MIME header from client ignored
- Strict type allowlist (not denylist)
- Storage key generated server-side (UUID / hash); no client filename in the path
- Images re-encoded through
sharp/ equivalent; EXIF stripped - PDFs run through
qpdfto strip JS, or converted to PDF/A - SVG not accepted, or sanitized before storage
- Office docs converted to PDF server-side, or rejected
- Stored in object storage with a private bucket; signed URLs for access
- Served from a separate origin (
cdn.example.com, notapp.example.com) -
X-Content-Type-Options: nosniffset on the upload origin - Strict CSP on the upload origin (
default-src 'none'baseline) -
Content-Disposition: attachmentfor non-image types - Virus scan in place for non-image uploads
- Per-user quota and per-IP rate limit
- BOLA-safe download: owner check on every fetch (see
api-security) - Orphan / abandoned upload cleanup runs daily
- Bucket itself is backed up
What this skill will not do
- Help build upload features for systems you do not own
- Endorse serving user uploads from the application's own origin without
Content-Dispositionand a strict CSP - Recommend "trust the client's MIME header" for any production system