Securing File Uploads
Stage 3 · Auth, Identity & Security · B.U.I.L.D. letter: L
You figured out authentication. You locked down your routes. You feel like the app is basically a fortress — and then a user uploads a file. Suddenly there's a door in your fortress wall that you forgot to build a lock for. File uploads are one of the most underestimated attack surfaces in web apps. Let's change that.
⚠️ The vibe trap
You built a beautiful upload UI — drag, drop, done. The file goes straight into public/uploads/ with the original filename, you save the path to the database, and you serve it back. Your vibe was immaculate. The security was not.
An attacker uploads shell.php, your server happily stores it as public/uploads/shell.php, and now they have a Remote Code Execution (RCE) backdoor into your infrastructure. Or they upload a file named ../../../../etc/passwd and start reading your system files. Or they loop-upload 5 GB files until your disk is full and your app crashes. None of this requires any hacking skill — it just requires knowing that most devs forget to harden uploads.
🔬 Validate by Content, Not by Name
The file extension and the Content-Type header are user-controlled. They are lies. An attacker can rename malware.php to cute-cat.png and set the MIME type to image/png. The only thing you can trust is the magic bytes — the first few bytes of the actual file content, which identify the format at the binary level.
// server/lib/validateFileType.js
import { fileTypeFromBuffer } from 'file-type'; // npm i file-type
const ALLOWED_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'application/pdf',
]);
/**
* Returns true only if the buffer's magic bytes match an allowed MIME type.
* Ignores the filename extension and the Content-Type header entirely.
*/
export async function validateFileType(buffer) {
const result = await fileTypeFromBuffer(buffer);
if (!result) return false; // undetectable format → reject
return ALLOWED_TYPES.has(result.mime);
}
Mental model: Think of magic bytes as a passport stamped at the border, not a name tag the traveler wrote themselves. The file-type library reads those bytes and tells you what the file actually is.
Why this matters: Extensions and MIME headers are trivially spoofed. If you only check file.mimetype === 'image/png' from the multipart parser, you have no real validation at all.
Common mistake: Using path.extname(file.originalname) or trusting req.file.mimetype from Multer as your only type check. Multer echoes back whatever the browser sent — it does not inspect the bytes.
📏 Enforce Size Limits — at the Framework Layer
Never let the full file reach your business logic before you check the size. By then you've already consumed bandwidth, memory, and disk I/O. Set the limit at the parser level so the connection is killed early.
// server/middleware/upload.js (Express + Multer)
import multer from 'multer';
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
// Store in memory just long enough to validate; we'll move to object storage ourselves
export const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_FILE_SIZE_BYTES,
files: 1, // one file per request
fields: 10, // reasonable cap on form fields
},
fileFilter(_req, file, cb) {
// Quick extension pre-check (defense in depth — magic bytes check happens next)
const safeExtensions = /\.(jpg|jpeg|png|webp|gif|pdf)$/i;
if (!safeExtensions.test(file.originalname)) {
return cb(new Error('File type not permitted'), false);
}
cb(null, true);
},
});
// Usage in your route
import express from 'express';
import { upload } from '../middleware/upload.js';
import { validateFileType } from '../lib/validateFileType.js';
import { storeFile } from '../lib/storage.js';
const router = express.Router();
router.post('/upload', upload.single('file'), async (req, res) => {
const file = req.file;
if (!file) return res.status(400).json({ error: 'No file provided' });
// Magic-bytes check on the actual buffer
const isValid = await validateFileType(file.buffer);
if (!isValid) return res.status(400).json({ error: 'Invalid file type' });
// Delegate to safe storage (see next section)
const result = await storeFile(file.buffer, file.originalname);
res.json({ url: result.signedUrl });
});
export default router;
Mental model: A bouncer who only checks your face after letting you inside the club isn't really a bouncer. The size limit is the rope line — you enforce it before the person (file) even enters.
Why this matters: Without server-side size limits, a single malicious upload of a multi-gigabyte file can exhaust your server's RAM (memoryStorage) or fill your disk, taking down the entire app.
Common mistake: Setting a limit only in the frontend (accept="image/*" or a JS size check). Frontend validation is UX. Backend validation is security. You need both, but only the backend one counts.
🗂️ Never Trust the Filename — Generate Your Own
The original filename is attacker-controlled text. ../../../../etc/cron.d/backdoor, index.php, file%00.jpg — all of these have been used in real exploits. Throw the original filename away for storage purposes. Generate a random, opaque key with no path separators and a safe extension derived from the validated MIME type.
// server/lib/storage.js
import { randomUUID } from 'crypto';
import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { fileTypeFromBuffer } from 'file-type';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.S3_UPLOAD_BUCKET; // a private bucket — no public-read ACL
// Maps validated MIME to a safe extension for the storage key
const MIME_TO_EXT = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'application/pdf': 'pdf',
};
/**
* Stores a validated file buffer in S3 under a random UUID key.
* Returns a short-lived signed URL for the caller to return to the client.
*
* @param {Buffer} buffer - The validated file content
* @param {string} _originalName - Accepted but NOT used for storage (logged only)
*/
export async function storeFile(buffer, _originalName) {
const detected = await fileTypeFromBuffer(buffer);
const ext = MIME_TO_EXT[detected?.mime] ?? 'bin';
// Key: uploads/<uuid>.<safe-ext> — no user input, no path separators from outside
const storageKey = `uploads/${randomUUID()}.${ext}`;
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: storageKey,
Body: buffer,
ContentType: detected?.mime ?? 'application/octet-stream',
// Prevent the bucket from serving this as executable content
ContentDisposition: 'attachment',
// Tag for lifecycle rules / virus-scan triggers
Tagging: 'status=pending-scan',
}));
// Generate a signed URL valid for 15 minutes — client fetches the file directly
const getCommand = new GetObjectCommand({ Bucket: BUCKET, Key: storageKey });
const signedUrl = await getSignedUrl(s3, getCommand, { expiresIn: 900 });
// Save storageKey (not the signed URL) to your database — URLs expire
return { storageKey, signedUrl };
}
Mental model: You are the hotel front desk. Guests don't get to choose their room number — you assign it. The key is yours, not theirs.
Why this matters: Path traversal attacks (../../) work by embedding directory separators in the filename so the server resolves a path outside the intended directory. A UUID has no slashes, no dots-dots, no cleverness — it's just a random string.
Common mistake: Sanitizing the original filename with a regex instead of replacing it entirely. Sanitization can be bypassed with URL encoding, null bytes, or Unicode tricks. Replacement cannot be bypassed because the original name never touches the filesystem.
🔒 Signed URLs and the Same-Origin Cookie Threat
Serving user uploads from your own domain (e.g., https://yourapp.com/uploads/xyz.jpg) means your cookies are sent with every request to that file. If an attacker uploads crafted HTML, SVG, or an HTML-disguised file that your server serves with Content-Type: text/html, the browser will execute it in your app's origin — and their malicious script has full access to every cookie your app has set, including session tokens.
The defense has three parts:
- Store uploads in a separate origin (S3 bucket or CDN subdomain, not your app's origin).
- Serve via signed URLs that expire.
- Always set
Content-Disposition: attachmentand the correctContent-Typeso browsers never auto-render uploads as HTML.
// server/routes/file.js — regenerate a signed URL on demand (never store them)
import express from 'express';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { db } from '../db.js'; // your database client
import { requireAuth } from '../middleware/auth.js';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const router = express.Router();
router.get('/file/:fileId/url', requireAuth, async (req, res) => {
// Look up the storage key for this file; verify the requesting user owns it
const file = await db.query(
'SELECT storage_key FROM user_files WHERE id = $1 AND user_id = $2',
[req.params.fileId, req.user.id]
);
if (!file.rows.length) return res.status(404).json({ error: 'Not found' });
const command = new GetObjectCommand({
Bucket: process.env.S3_UPLOAD_BUCKET,
Key: file.rows[0].storage_key,
// Force download; never render in browser
ResponseContentDisposition: 'attachment',
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 }); // 5 minutes
res.json({ url });
});
export default router;
Mental model: The signed URL is like a one-time ticket stub. You generate it fresh when someone who has permission asks for it. It has an expiry stamped on it, and if it leaks it stops working soon anyway.
Why this matters: Permanent public URLs in S3 mean any file, once uploaded, is accessible forever with no auth check — even if you delete the record from your database. Signed URLs enforce authorization at access time, every time.
Common mistake: Saving the signed URL to the database and serving it permanently. S3 signed URLs expire; your stored URL will break. Save the storage key; generate the signed URL on the fly.
🛠️ Your Mission
Open the app you've been building throughout this track (or spin up a fresh Express or Next.js API route). Find — or create — an upload feature. It might be a profile photo, a document, a course attachment, anything.
Your job is to harden it end to end:
- Replace any
public/uploads/storage withmulter.memoryStorage()+ magic-bytes validation. - Generate a UUID-based storage key — strip the original filename completely.
- Move storage to S3 (or a local mock like
localstack/MinIO) with a private bucket. - Add a
/file/:id/urlroute that regenerates a signed URL only for authenticated owners. - Add
Content-Disposition: attachmentto every file you serve. - Write one test that tries to upload a renamed
.phpfile asimage/pngand asserts it is rejected.
If you don't have S3 access yet, implement everything except the actual S3 call — use fs to write to a directory outside your web root and simulate the signed URL with a short-lived JWT. The mental model is what matters.
✅ You're done when…
- Every item in the File Uploads section of the Security Audit Checklist is checked off — unrestricted file upload, path traversal, storage exhaustion, content-type spoofing, and RCE via exec dir (see also the OWASP File Upload Cheat Sheet for reference detail on each category)
- Your upload handler rejects a file that has the
.pngextension but real PHP magic bytes - No uploaded file is stored under a user-controlled path or filename anywhere in your system
- Your S3 bucket (or equivalent) has no public-read ACL — all access goes through signed URLs
- Every served file has
Content-Disposition: attachmentand the correctContent-Typefrom your own lookup table, not from the upload request - Size limits are enforced at the multipart-parser level (Multer
limits.fileSize), not only in JavaScript after the buffer is already in memory - You have at least one automated test that attempts a malicious upload and asserts a 400 response
➡️ Next: HTTPS, Encryption & Data Protection.
Build It Right, Or Don't Build It At All. 🏛️