Web Security (OWASP Top 10:2025) Module 7 – A06:2025 Insecure Design
Secure file uploads and downloads
Accept only the file types you need by checking their bytes, store them under server-made names, serve them safely, and limit archives and images.
What you will learn
- Validate uploads by size, an allowlist of formats and their first bytes, never by the client's file name or Content-Type
- Store files under server-generated names where nothing runs them, and serve them from a separate origin with safe headers
- Limit archives and images before processing them, and rebuild images from their pixels
- Design quarantine, scanning and cleanup for uploads, and authorise every download
Before you start
What you’ll learn · Before you start
What you will learn
- Validate uploads by size, an allowlist of formats and their first bytes, never by the client's file name or Content-Type
- Store files under server-generated names where nothing runs them, and serve them from a separate origin with safe headers
- Limit archives and images before processing them, and rebuild images from their pixels
- Design quarantine, scanning and cleanup for uploads, and authorise every download
Before you start
On this page
A profile-photo form accepted a file called neha.png, sent as image/png, whose bytes were a web page. Unrestricted
upload of a file with a dangerous type, CWE-434, is number 12 in MITRE’s 2025 CWE Top 25, because an upload is input
that the application stores and later hands to browsers, to other users and to its own tools.
Secure file uploads come down to four decisions: what to accept, where to store it, how to serve it, and what to check inside it. You will make each one for the canteen app, from the photo form to downloads of private files.
An upload is input you will serve back
Every part of an upload comes from the person sending it: the bytes, the file name and the Content-Type that names a
type. OWASP’s File Upload Cheat Sheet says the content type “cannot be trusted, as it is trivial to spoof”. Secure
file uploads therefore decide the type from the bytes, against a short list of the formats the feature needs. The
program below sends six uploads to a weak handler, which believes the name and the header, and to a fixed one, which
reads the first bytes, allows only PNG and JPEG, and names the stored file itself:
// Six uploads to a profile-photo form, accepted by a weak handler that believes the file name and the Content-Type,
// and by a fixed one that reads the file's first bytes, keeps an allowlist, and names the stored file itself.
const bytes = (...parts) => new Uint8Array(parts.flatMap((p) => (typeof p === 'string' ? [...p].map((c) => c.charCodeAt(0)) : p)));
// Signatures from the WHATWG MIME Sniffing standard (the first bytes of each format).
const SIGNATURES = [
{ type: 'image/png', ext: 'png', magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
{ type: 'image/jpeg', ext: 'jpg', magic: [0xff, 0xd8, 0xff] },
{ type: 'image/gif', ext: 'gif', magic: [0x47, 0x49, 0x46, 0x38] },
{ type: 'application/pdf', ext: 'pdf', magic: [0x25, 0x50, 0x44, 0x46, 0x2d] },
{ type: 'application/zip', ext: 'zip', magic: [0x50, 0x4b, 0x03, 0x04] },
];
function sniff(data) {
const hit = SIGNATURES.find((s) => s.magic.every((b, i) => data[i] === b));
return hit ?? { type: 'unknown', ext: null };
}
const UPLOADS = [
{ name: 'asha.png', contentType: 'image/png', data: bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'rest of a PNG') },
{ name: 'ravi.jpg', contentType: 'text/html', data: bytes([0xff, 0xd8, 0xff, 0xe0], 'rest of a JPEG') },
{ name: 'neha.png', contentType: 'image/png', data: bytes('<!DOCTYPE html><p>a web page, not a photo</p>') },
{ name: 'kiran.svg', contentType: 'image/svg+xml', data: bytes('<svg xmlns="http://www.w3.org/2000/svg"><circle r="4"/></svg>') },
{ name: 'Meena Photo (1).PNG', contentType: 'image/png', data: bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 'rest of a PNG') },
{ name: 'arjun.png', contentType: 'image/png', data: bytes('%PDF-1.7 a document, not a photo') },
];
function weakAccept(upload) {
const ext = upload.name.split('.').pop().toLowerCase();
if (!['png', 'jpg', 'jpeg', 'gif', 'svg'].includes(ext)) return { ok: false, reason: `.${ext} not allowed` };
return { ok: true, stored: `uploads/${upload.name}`, servedAs: upload.contentType };
}
const ALLOWED = new Set(['image/png', 'image/jpeg']);
function makeFixedAccept(newId) {
return function fixedAccept(upload) {
if (upload.data.length > 2 * 1024 * 1024) return { ok: false, reason: 'larger than 2 MB' };
const found = sniff(upload.data);
if (!ALLOWED.has(found.type)) return { ok: false, reason: `bytes are ${found.type}, not a PNG or JPEG` };
const ext = upload.name.split('.').pop().toLowerCase();
const named = ext === 'jpeg' ? 'jpg' : ext;
if (named !== found.ext) return { ok: false, reason: `name says .${ext}, bytes say ${found.type}` };
return { ok: true, stored: `avatars/${newId()}.${found.ext}`, servedAs: found.type };
};
}
let n = 0;
const fixedAccept = makeFixedAccept(() => `f${String(++n).padStart(4, '0')}`); // production: crypto.randomUUID()
const show = (r) => (r.ok ? `kept as ${r.stored}, served as ${r.servedAs}` : `refused: ${r.reason}`);
for (const u of UPLOADS) {
console.log(`${u.name} (sent as ${u.contentType}, bytes: ${sniff(u.data).type})`);
console.log(` weak : ${show(weakAccept(u))}`);
console.log(` fixed: ${show(fixedAccept(u))}`);
} Output
asha.png (sent as image/png, bytes: image/png) weak : kept as uploads/asha.png, served as image/png fixed: kept as avatars/f0001.png, served as image/png ravi.jpg (sent as text/html, bytes: image/jpeg) weak : kept as uploads/ravi.jpg, served as text/html fixed: kept as avatars/f0002.jpg, served as image/jpeg neha.png (sent as image/png, bytes: unknown) weak : kept as uploads/neha.png, served as image/png fixed: refused: bytes are unknown, not a PNG or JPEG kiran.svg (sent as image/svg+xml, bytes: unknown) weak : kept as uploads/kiran.svg, served as image/svg+xml fixed: refused: bytes are unknown, not a PNG or JPEG Meena Photo (1).PNG (sent as image/png, bytes: image/png) weak : kept as uploads/Meena Photo (1).PNG, served as image/png fixed: kept as avatars/f0003.png, served as image/png arjun.png (sent as image/png, bytes: application/pdf) weak : kept as uploads/arjun.png, served as image/png fixed: refused: bytes are application/pdf, not a PNG or JPEG
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node upload_validate.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The weak handler kept all six under the names the students chose, a web page and an SVG drawing among them. The fixed
one kept three, each under a name it made, and refused the page, the SVG and a PDF named .png. It also kept the JPEG
that arrived labelled text/html, because the label decides nothing. Each decision belongs to one place in the app:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Accept. The app sends the photo with the name and type the student's phone chose. The API reads the file's first bytes, finds a PNG, and checks the size against 2 MB.
- Store. The API stores the bytes under a name it made, avatars/f0001.png, in storage that runs nothing and is not a folder of the web server. The student's own file name is kept only as text in a record.
- Serve. The photo is served from an origin of its own, with the type found from its bytes, nosniff and a sandbox policy, so a file that is not what it seemed cannot run as part of the app.
- Show. The app's page shows the photo from the other origin in an img element. A private file, such as an ID card, also needs its owner's session on every request.
The four decisions, each in one sentence:
- Accept: check the bytes against an allowlist of formats you need, and a size limit, before anything else.
- Store: keep files under names the server makes, in storage where nothing can run them.
- Serve: send downloads from a separate origin with
nosniff, a sandbox policy and, for documents, as attachments. - Check inside: limit archives and images before unpacking them, rebuild images from their pixels, and scan files in quarantine: Keeping an uploaded file stored but unavailable until its checks, such as an antivirus scan, have finished. Nothing serves a file in quarantine, and a file that fails its checks is deleted from there. first.
A file signature: The fixed bytes at the start of a file that name its format, such as 89 50 4E 47 0D 0A 1A 0A for a PNG or FF D8 FF for a JPEG. An upload check compares them with an allowlist instead of trusting the name or the Content-Type. is what makes the first decision possible: most formats begin with fixed bytes that name them. None of the four decisions relies on the others being perfect. A file that slips past the first check still lands under a name the server made, is still served with headers that keep it from running, and still waits in quarantine until its scan is done.
Checkpoint
Check: Four decisions about every upload
Flashcards · 4 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
Why is an upload input you will serve back, and which parts can you not trust?
The bytes, the file name and the Content-Type all come from the person sending the file, and OWASP's File Upload Cheat Sheet calls the content type trivial to spoof. Decide the type from the bytes against a short list of the formats the feature needs. Unrestricted upload of a dangerous file type is CWE-434, 12th in MITRE's 2025 CWE Top 25.
-
What are the four decisions of secure file uploads?
Accept: check the bytes against an allowlist of formats and a size limit before anything else. Store: keep files under names the server makes, in storage where nothing can run them. Serve: from a separate origin, with nosniff, a sandbox policy and, for documents, as attachments. Check inside: limit archives and images before unpacking them, rebuild images from their pixels, and scan files in quarantine first.
-
What is a file signature, and why does it beat the label?
Most formats begin with fixed bytes that name them, so the handler can read the first bytes and decide the type itself. In the lesson the fixed handler kept a JPEG that arrived labelled text/html, because the label decides nothing, and refused a PDF named .png.
-
Why does none of the four upload decisions rely on the others being perfect?
A file that slips past the first check still lands under a name the server made, is still served with headers that keep it from running, and still waits in quarantine until its scan is done.
Check the bytes against an allowlist, not the name or the header Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Store under a name you made, where nothing runs
- Serve uploads from another origin, as downloads where you can
- Archives and images: limits before any work
- Scan in quarantine, and clean up when anything fails
- Practise: accept a photo and serve a file
- Accept a profile photo by its bytes, and serve files safely
- Interview questions
- Why should an upload handler never trust a file's Content-Type or its name, and what should it check instead?
- Design the profile-photo upload of an app with 2 million users: what is accepted, where files live, how they are served, and how you test each part.
- Vendors want to import their menus as one ZIP of CSV files and photos. What do you check before, during and after unpacking, and what do you refuse?
- Students upload ID cards and fee receipts that only they and the college office may see. Design how these files are stored, served and shared, including links that the office sends by email.
- Key takeaways
1 diagram · 3 runnable examples (JavaScript, Python) · 1 exercise with sample tests · 3 interview answers · about 31 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- file signature
- The fixed bytes at the start of a file that name its format, such as 89 50 4E 47 0D 0A 1A 0A for a PNG or FF D8 FF for a JPEG. An upload check compares them with an allowlist instead of trusting the name or the Content-Type.
- quarantine
- Keeping an uploaded file stored but unavailable until its checks, such as an antivirus scan, have finished. Nothing serves a file in quarantine, and a file that fails its checks is deleted from there.
References
- CWE-434: Unrestricted Upload of File with Dangerous Type (MITRE)
- 2025 CWE Top 25 Most Dangerous Software Weaknesses (MITRE)
- File Upload Cheat Sheet (OWASP Foundation)
- ASVS 5.0.0, V5 File Handling (OWASP Foundation)
- ASVS 5.0.0, V3 Web Frontend Security (requirements 3.2.1 and 3.4.4) (OWASP Foundation)
- ASVS 5.0.0, V14 Data Protection (requirement 14.2.8) (OWASP Foundation)
- MIME Sniffing Standard (WHATWG)
- X-Content-Type-Options header (MDN Web Docs (Mozilla))
- Content-Security-Policy: sandbox directive (MDN Web Docs (Mozilla))
- RFC 6266: Use of the Content-Disposition Header Field in HTTP (IETF (RFC Editor))
- RFC 8187: Indicating Character Encoding and Language for HTTP Header Field Parameters (IETF (RFC Editor))
- zipfile: Work with ZIP archives (Python 3.14 documentation) (Python Software Foundation)
- Portable Network Graphics (PNG) Specification (Third Edition) (W3C)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress