| name | file-upload-security |
|---|---|
| description | Accept user file uploads without introducing remote code execution, stored XSS, or polyglot attacks. Covers magic-byte validation, strict type allowlists, image re-encoding to defang embedded payloads, EXIF stripping, virus scanning, path-safe storage keys, and serving via a separate origin with Content-Disposition. Invoke when adding upload to a new endpoint or migrating from local-disk storage to object storage. |
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.
- 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)
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.
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.
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.
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.
The bytes are the problem. After validation, transform the file so any embedded payload becomes inert.
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.
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.pdfActive 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
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
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
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 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
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.
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.
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).
- 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.
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
- 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