File Upload Security
Accepting files means accepting attacker-controlled bytes into your storage, your processing pipeline, and possibly your origin.
File upload touches almost every category in this course at once: path traversal, command injection, XXE, XSS, resource exhaustion and access control. It deserves its own checklist.
Never trust anything the client tells you
- The filename — path traversal, header injection, and a lie about the extension.
- The Content-Type — set by the client, so
image/pngmeans nothing. - The extension —
photo.png.php,photo.php%00.png, or a double extension the web server resolves differently. - The size header — enforce the real limit at the stream, not from a header.
Verify the content
Read the magic bytes. A PNG starts with 89 50 4E 47; a file claiming to be a PNG that does not is rejected. Better still, re-encode: decode the image and write a fresh one, which destroys any embedded payload and any polyglot structure.
Store outside the web root, with generated names
The original filename is metadata you display; the storage key is a UUID you generate. That single decision eliminates path traversal, overwrite attacks and extension confusion together.
Never serve user content from your origin
A stored HTML or SVG file served from abc.com is stored XSS with access to your cookies. Serve user content from a separate domain, with Content-Disposition: attachment, X-Content-Type-Options: nosniff and a restrictive CSP.
Bound everything
Maximum file size, maximum count per request, maximum total per user, image dimension limits (a 50,000×50,000 PNG is a few hundred kilobytes and gigabytes of RAM when decoded), and a timeout on any processing step.
Scan and sandbox
Run antivirus if you accept documents. Run any media processing in a locked-down container — ImageMagick, ffmpeg and PDF libraries all have long CVE histories, and they are being fed hostile input by definition.
Example
// The four lies a client tells about an upload
// 1. filename: "../../../var/www/html/shell.php"
// 2. Content-Type: "image/png" (it is a PHP script)
// 3. extension: "avatar.png.php" (or avatar.php%00.png)
// 4. size: Content-Length says 1KB, the stream sends 5GB
// The four answers
// 1. generate the storage key yourself — never use their name for a path
// 2. read the magic bytes
// 3. derive the extension from the DETECTED type
// 4. enforce the limit on the stream, and abort when exceededWhen to use it
- A web shell is prevented because uploads are stored outside the web root under generated names with no executable extension.
- A decompression-bomb image is rejected by a pixel limit before the decoder can exhaust the container's memory.
- An uploaded HTML file cannot steal sessions because user content is served from a separate domain with Content-Disposition: attachment.
More examples
A complete, defensible upload handler
Re-encoding is the single highest-value step: it neutralises polyglot files, strips EXIF GPS data, and guarantees the stored bytes are a real image.
import multer from 'multer';
import { fileTypeFromBuffer } from 'file-type';
import sharp from 'sharp';
import crypto from 'node:crypto';
// 1. Memory storage with a hard limit — nothing touches disk unverified.
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 5 * 1024 * 1024, // 5MB, enforced on the STREAM
files: 1,
fields: 10,
parts: 15,
},
});
// 2. Allowlist of DETECTED types, mapped to the extension we will use.
const ALLOWED = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
// Deliberately absent: image/svg+xml (it is XML), application/pdf,
// text/html — each is active content.
};
app.post('/api/avatar', auth, upload.single('file'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' });
// 3. Detect the REAL type from magic bytes. Ignore the client entirely.
const detected = await fileTypeFromBuffer(req.file.buffer);
if (!detected || !ALLOWED[detected.mime]) {
return res.status(415).json({
error: 'unsupported_file_type',
allowed: Object.keys(ALLOWED),
});
}
// 4. Bound the DECODED size, not just the byte size.
let processed;
try {
const image = sharp(req.file.buffer, {
limitInputPixels: 50_000_000, // ~7000x7000; blocks decompression bombs
sequentialRead: true,
});
const meta = await image.metadata();
if (meta.width > 10_000 || meta.height > 10_000) {
return res.status(400).json({ error: 'image_dimensions_too_large' });
}
if ((meta.pages ?? 1) > 100) { // animated formats
return res.status(400).json({ error: 'too_many_frames' });
}
// 5. RE-ENCODE. This destroys embedded payloads, polyglots and EXIF —
// and EXIF routinely contains GPS coordinates users did not intend
// to publish.
processed = await image
.rotate() // apply EXIF orientation, then drop it
.resize(512, 512, { fit: 'cover' })
.jpeg({ quality: 85, mozjpeg: true })
.toBuffer();
} catch (err) {
logger.warn({ err, userId: req.user.id }, 'image processing failed');
return res.status(400).json({ error: 'invalid_image' });
}
// 6. WE choose the storage key. The user's filename is display metadata only.
const key = `avatars/${req.user.id}/${crypto.randomUUID()}.jpg`;
await s3.putObject({
Bucket: USER_CONTENT_BUCKET, // a bucket with NO public read policy
Key: key,
Body: processed,
ContentType: 'image/jpeg',
ContentDisposition: 'attachment', // never render inline
CacheControl: 'private, max-age=86400',
ServerSideEncryption: 'AES256',
});
await db('users').where({ id: req.user.id }).update({ avatar_key: key });
res.status(201).json({
url: await signedUrl(key, { expiresIn: 3600 }),
// The original name, sanitised, purely for display
originalName: sanitiseFilename(req.file.originalname),
});
});
const sanitiseFilename = (n) =>
String(n).replace(/[^\w.\- ]/g, '_').replace(/\.{2,}/g, '.').slice(0, 100);Serving user content without giving away your origin
ResponseContentDisposition on the signed URL is a useful belt-and-braces: it forces download behaviour even if something was stored with the wrong metadata.
// The rule: user-uploaded content NEVER shares an origin with your application.
// ── Best: a separate domain, with signed URLs ─────────────────────────
// app: https://abc.com
// user content: https://usercontent-abc.com ← a DIFFERENT SITE
//
// Even if an HTML or SVG file executes, it is on an origin with no cookies,
// no localStorage, and no access to your DOM.
export async function signedUrl(key, { expiresIn = 300 } = {}) {
return s3.getSignedUrlPromise('getObject', {
Bucket: USER_CONTENT_BUCKET,
Key: key,
Expires: expiresIn,
// Force these on the RESPONSE, whatever is stored:
ResponseContentDisposition: 'attachment',
ResponseContentType: 'application/octet-stream',
});
}
// ── If you must serve it yourself ─────────────────────────────────────
app.get('/files/:id', auth, async (req, res) => {
const file = await db('files')
.where({ id: req.params.id, user_id: req.user.id }) // authorization
.first();
if (!file) return res.status(404).json({ error: 'not_found' });
res.set({
// Serve the type WE detected, never the one the client claimed
'Content-Type': file.detected_mime,
'X-Content-Type-Options': 'nosniff', // no MIME sniffing
'Content-Disposition':
`attachment; filename="${sanitiseFilename(file.original_name)}"`,
'Content-Security-Policy': "default-src 'none'; sandbox",
'X-Frame-Options': 'DENY',
'Cache-Control': 'private, no-store',
});
createReadStream(absolutePathFor(file.storage_key)).pipe(res);
});
// ── The bucket policy is part of the control ─────────────────────────
// {
// "Effect": "Deny",
// "Principal": "*",
// "Action": "s3:GetObject",
// "Resource": "arn:aws:s3:::usercontent/*",
// "Condition": { "Null": { "aws:UserAgent": "false" } }
// }
// Simply: no public read. Every access goes through a signed URL your
// application issued after checking authorization.
// ── And never do this ────────────────────────────────────────────────
// app.use('/uploads', express.static('./uploads'));
// Directory listing, no authorization, your origin, and whatever the web
// server decides to execute.Quotas, scanning and the processing sandbox
The internal network on the processing container is the key line: a compromised decoder cannot exfiltrate what it reads because it has no route out.
// ── Per-user quotas, or storage becomes a denial-of-wallet attack ─────
async function checkQuota(userId, incomingBytes) {
const { total, count } = await db('files')
.where({ user_id: userId })
.select(db.raw('COALESCE(SUM(size_bytes),0) as total, COUNT(*) as count'))
.first();
const plan = await planFor(userId);
if (Number(total) + incomingBytes > plan.storageBytes) {
throw new PayloadTooLargeError('storage_quota_exceeded');
}
if (Number(count) >= plan.maxFiles) {
throw new PayloadTooLargeError('file_count_quota_exceeded');
}
}
// Rate-limit uploads separately — they are far more expensive than a read.
const uploadLimiter = rateLimit({
windowMs: 3600_000, limit: 100,
keyGenerator: (req) => `upload:${req.user.id}`,
});
// ── Malware scanning for document uploads ────────────────────────────
import NodeClam from 'clamscan';
const clam = await new NodeClam().init({ clamdscan: { socket: '/var/run/clamav/clamd.sock' } });
async function scan(buffer) {
const { isInfected, viruses } = await clam.scanBuffer(buffer, 30_000);
if (isInfected) {
logger.error({ viruses }, 'infected upload rejected');
throw new BadRequestError('file_rejected'); // do not name the signature
}
}
// ── Process media in a sandbox ───────────────────────────────────────
// ImageMagick, ffmpeg, LibreOffice and PDF libraries all have long CVE
// histories, and you are feeding them hostile input by definition.
//
// docker-compose.yml
// media-processor:
// image: media-tools:latest
// read_only: true # nothing writable but /tmp
// tmpfs: [/tmp:size=256m]
// cap_drop: [ALL]
// security_opt: [no-new-privileges:true]
// networks: [processing-net] # internal: true → NO egress
// mem_limit: 512m
// pids_limit: 64
// cpus: 0.5
//
// The container is the boundary. A CVE in the decoder then reaches a
// throwaway process with no network, no secrets and no filesystem.
// ── And expire what nobody claimed ───────────────────────────────────
// Orphaned uploads accumulate forever. A nightly job deleting unreferenced
// objects older than 24 hours is both a cost and a privacy control.
Discussion