How to fix Insecure File Upload
A user uploads `shell.php` to your `/avatars/` folder. You serve `/avatars/shell.php` over HTTP. Apache/PHP executes it. RCE. The fix isn't 'check the extension' — it's a layered defense: type validation, separate origin, no execution, AV scanning. Here's the complete playbook.
What is Insecure File Upload?
Insecure file upload happens when an app accepts files without sufficient validation, AND those files end up in a location where they can be misused. The classic chain: user uploads `shell.php` (or `shell.jsp`, `shell.aspx`), file is saved to webroot under its original name, attacker visits `https://app.com/uploads/shell.php` — the web server executes it. RCE in three steps.
Variants beyond direct RCE: SVG XSS (uploaded SVG executes JavaScript when viewed inline), HTML smuggling (uploaded .html serves attacker-controlled content under your origin), zip-slip (extracted zip writes outside intended directory), polyglot files (file is valid PNG AND PHP simultaneously, bypasses MIME checks). Plus indirect: stored malware infecting other users, ImageMagick exploits parsing crafted images, embedded EXIF metadata with XSS payload.
The fix is layered because no single check is sufficient. Extension whitelist? Polyglot bypass. MIME type from request? Attacker-controlled. Magic bytes? Polyglot bypass. The defense is: validate type AND extension AND content AND save under a server-generated name AND serve from a separate origin AND with Content-Disposition: attachment AND scan with ClamAV. Each layer catches what others miss.
What an attacker can do
The concrete impact of leaving Insecure File Upload unpatched.
Remote code execution via uploaded shell
Upload .php / .jsp / .aspx, browse to it, RCE on the web server.
Stored XSS via SVG / HTML uploads
Uploaded SVG with embedded `<script>` runs in the victim's browser when viewed.
Server-side request forgery via image parsing
ImageMagick-style exploits (CVE-2016-3714 'ImageTragick') parsed image triggers HTTP requests.
Storage exhaustion / denial of service
No size limits → attacker uploads 100GB of garbage, fills disk, app crashes.
How do I know if I'm vulnerable?
Manual: find every file-upload endpoint. Check: (1) extension validation present? (2) magic-byte/content-type validation present? (3) saved with user-supplied filename or generated? (4) saved to webroot or separate location? (5) served with X-Content-Type-Options: nosniff and Content-Disposition: attachment for non-images? Each missing layer = increased risk.
Automated: AuditCore's Sensitive Files Scanner finds upload directories and tries to fetch known dangerous file extensions. The Smart API Fuzzer discovers upload endpoints from OpenAPI specs. The AI Context Scanner reasons about upload flows when given source code access.
How to fix Insecure File Upload
5 ordered steps. Apply them in order — each builds on the previous.
- 1
Validate extension AND content type AND magic bytes
Three checks because each can be bypassed alone. Whitelist allowed extensions; verify MIME from server-side detection (not request header); check magic bytes match.
For images, use library-level validation (Pillow/sharp/ImageMagick) that fails on malformed input — not just header sniffing.
javascriptStep 1import { fileTypeFromBuffer } from "file-type"; const ALLOWED_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".pdf"]); const ALLOWED_MIMES = new Set([ "image/jpeg", "image/png", "image/webp", "application/pdf", ]); async function validateUpload(buffer, originalName) { // 1. Extension whitelist const ext = path.extname(originalName).toLowerCase(); if (!ALLOWED_EXTS.has(ext)) throw new Error("Extension not allowed"); // 2. Magic-byte detection (ignores Content-Type from request) const detected = await fileTypeFromBuffer(buffer); if (!detected || !ALLOWED_MIMES.has(detected.mime)) { throw new Error("File content does not match allowed types"); } // 3. Extension matches detected type const expectedExts = { jpg: ".jpg", jpeg: ".jpg", png: ".png", webp: ".webp", pdf: ".pdf" }; if (expectedExts[detected.ext] !== ext.replace("jpeg", "jpg")) { throw new Error("Extension does not match content"); } } - 2
Save under a server-generated random filename
Never save with the user's filename. Use UUID or hash. Prevents path traversal, predictable naming attacks, and keeps the original extension confusion contained.
Store original filename in DB metadata if you need to display it. Use random name on disk.
pythonStep 2import uuid from pathlib import Path UPLOAD_DIR = Path("/var/uploads") # NOT in web root def save_upload(file, allowed_exts, max_bytes=10_000_000): if file.size > max_bytes: raise ValueError("File too large") # Magic byte check via Pillow / similar file.stream.seek(0) detected_ext = sniff_magic(file.stream.read(16)) file.stream.seek(0) if detected_ext not in allowed_exts: raise ValueError("Type not allowed") # Generate random name; store metadata in DB new_name = f"{uuid.uuid4().hex}.{detected_ext}" path = UPLOAD_DIR / new_name file.save(path) return new_name # return ID to store in DB - 3
Serve uploads from a separate origin (cookieless, no execution)
User uploads at `app.example.com` should be SERVED from `uploads.example.com` or an S3 CDN. Separates the upload origin from your app's session cookies and prevents server-side execution.
S3 + CloudFront is the easy path. Configure CloudFront to set Content-Disposition: attachment for non-images and X-Content-Type-Options: nosniff.
yamlStep 3# Separate subdomain for uploads — no app cookies, no PHP/Node executor # nginx config snippet for uploads.example.com server { listen 443 ssl http2; server_name uploads.example.com; root /var/uploads; # Block any execution of script files location ~ \.(php|jsp|jspx|asp|aspx|cgi|pl|py|sh)$ { deny all; } # Force download for non-images location ~* \.(pdf|doc|docx|xls|xlsx|zip|tar)$ { add_header Content-Disposition "attachment"; add_header X-Content-Type-Options "nosniff"; } # Images served inline but with strict headers location ~* \.(jpg|jpeg|png|webp|gif)$ { add_header X-Content-Type-Options "nosniff"; add_header Content-Security-Policy "default-src 'none'; img-src 'self'"; } } - 4
Sanitize SVGs (or convert to raster)
SVG can contain `<script>` and event handlers. Sanitize via DOMPurify-equivalent for SVG, or convert to PNG server-side and discard the original.
If users don't strictly need SVG (most apps don't), reject them. If you need them: use `svg-sanitizer` (PHP), `dompurify` (Node) with SVG profile.
javascriptStep 4import DOMPurify from "isomorphic-dompurify"; function sanitizeSvg(svgString) { return DOMPurify.sanitize(svgString, { USE_PROFILES: { svg: true, svgFilters: true }, FORBID_TAGS: ["script", "foreignObject"], FORBID_ATTR: ["onload", "onerror", "onclick"], }); } // Better: convert to PNG server-side import sharp from "sharp"; const png = await sharp(svgBuffer).png().toBuffer(); // Discard original SVG, store the PNG - 5
Scan uploads with ClamAV before serving
Defense-in-depth: even if a malicious file passes all your checks, AV catches known malware patterns. Scan async after upload, mark file as 'pending' until clean.
ClamAV via clamd daemon is the standard. For SaaS, use VirusTotal API or similar. Cost: ~10-100ms per file.
pythonStep 5import clamd cd = clamd.ClamdNetworkSocket("clamav-daemon", 3310) def scan_file(path: str) -> bool: """Returns True if clean, False if infected.""" result = cd.scan(path) if result is None: raise RuntimeError("ClamAV unreachable") status = result.get(path, ("ERROR",))[0] return status == "OK" # In upload flow: # 1. Save to /tmp/staging/<id> # 2. Mark file as pending in DB # 3. Async worker: scan_file → if clean, move to /var/uploads, mark ready # 4. App serves only files marked ready
How to verify the fix
AuditCore tries to upload `.php`, `.jsp`, `.html`, `.svg` with embedded payloads against your endpoints, then attempts to fetch them. Findings: which extensions accepted, whether served with execution.
Manual: try uploading `shell.php` containing `<?php system($_GET['cmd']); ?>`. If it accepts, browse `uploads/shell.php?cmd=id`. RCE = critical, immediate fix. Try `shell.php.jpg` (extension confusion). Try `polyglot.png` (valid PNG with PHP appended). Try uploading 1GB of garbage to test size limits.
Frequently asked questions
Is checking the file extension enough?+
No. Attackers control the extension. They also control the Content-Type header. Magic-byte detection from server-side is the minimum reliable check, combined with extension whitelist.
Can I rely on Content-Type from the upload form?+
No. The browser sends what the user's machine reports — easy to spoof. Always detect server-side.
What about uploads to S3 directly with presigned URLs?+
Even better — bytes never touch your server. But still apply: extension/MIME whitelist via S3 bucket policy, size limits via presigned URL conditions, post-upload Lambda for AV scan, separate domain for serving.
Should I store uploads in the database (BLOB) instead of filesystem?+
Database storage avoids path traversal but adds memory pressure and slow serving. For files <1MB, DB BLOB is OK. For larger: file system or S3 with strict separation. Either way, validate before storing.
Do I need both ClamAV and content validation?+
Yes — defense in depth. Content validation catches 'wrong type'; ClamAV catches 'right type but malicious payload' (e.g., PDF with embedded exploit, Word doc with macro malware).
Related fix guides
How to fix Path Traversal
Upload paths must be canonicalized to prevent zip-slip and similar.
Read guideHow to fix XSS
SVG uploads serve XSS — covered as a specific case in the XSS guide.
Read guideHow to fix command injection
Image processing tools (ImageMagick, ffmpeg) have RCE histories — handle uploads in sandboxed workers.
Read guide