Unrestricted file uploads (CWE-434) allow attackers to upload executable scripts disguised as images, exhaust server storage with oversized files, or store malware for later retrieval. OWASP A04 (Insecure Design) specifically flags accepting files based solely on client-supplied Content-Type as a design flaw. NIST 800-53 SI-10 requires input validation including file content. A PHP file uploaded to a web-accessible directory and served by Apache becomes a remote code execution vector. Relying on the Content-Type header is trivially bypassed: any HTTP client can send Content-Type: image/png with an executable payload.
Medium because exploiting unrestricted upload requires a specific upload feature and storage configuration, but successful exploitation can achieve remote code execution or service disruption.
Validate file type by inspecting magic bytes — not the client-provided Content-Type — and store uploads in external blob storage, never in the web root:
import { fileTypeFromBuffer } from 'file-type'
const ALLOWED = new Set(['image/jpeg', 'image/png', 'image/webp'])
const MAX_BYTES = 5 * 1024 * 1024 // 5 MB
const buffer = Buffer.from(await file.arrayBuffer())
if (buffer.byteLength > MAX_BYTES) return Response.json({ error: 'File too large' }, { status: 413 })
const detected = await fileTypeFromBuffer(buffer)
if (!detected || !ALLOWED.has(detected.mime)) {
return Response.json({ error: 'File type not permitted' }, { status: 415 })
}
// Store in Vercel Blob / S3 — never in public/
await put(`uploads/${crypto.randomUUID()}.${detected.ext}`, buffer, { contentType: detected.mime })
ID: security-hardening.input-validation.file-upload-validation
Severity: medium
What to look for: Count all file upload endpoints and handlers. For each, check file upload handlers for server-side MIME type validation (not relying on the Content-Type header from the client, but inspecting file magic bytes), file size limits, and whether uploaded files are stored outside the web root (in blob storage or a directory not publicly served). Also check whether uploaded files can be executed (e.g., PHP files uploaded to a web-accessible directory).
Pass criteria: File uploads validate MIME type server-side by checking file magic bytes (not client-provided Content-Type). File size limits are enforced. Files are stored in blob storage (S3, Vercel Blob, Supabase Storage) or a non-web-accessible path. File extensions are validated against an allowlist with maximum file size under 10 MB and an allowlist of permitted MIME types. Report: "X upload endpoints found, all Y validate MIME type, size, and extension."
Fail criteria: File type is determined only by the client-provided Content-Type header. No file size limits. Files stored in a publicly accessible web directory. Executable file types (PHP, Python, shell scripts) are not blocked.
Skip (N/A) when: The application has no file upload functionality.
Detail on fail: "File uploads rely on client Content-Type header for type validation — attackers can bypass by changing the header" or "No file size limit on upload endpoint — large file uploads could exhaust server resources" or "Uploaded files stored in public/ directory — executable files could be served and run"
Remediation: Use magic number validation and store files in external blob storage:
import { fileTypeFromBuffer } from 'file-type' // npm i file-type
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp'])
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB
export async function POST(req: Request) {
const formData = await req.formData()
const file = formData.get('file') as File
if (!file) return Response.json({ error: 'No file uploaded' }, { status: 400 })
if (file.size > MAX_SIZE) return Response.json({ error: 'File too large' }, { status: 413 })
const buffer = Buffer.from(await file.arrayBuffer())
const detected = await fileTypeFromBuffer(buffer)
if (!detected || !ALLOWED_TYPES.has(detected.mime)) {
return Response.json({ error: 'File type not allowed' }, { status: 415 })
}
// Store in blob storage, not the filesystem
const blob = await put(`uploads/${crypto.randomUUID()}.${detected.ext}`, buffer, {
access: 'public',
contentType: detected.mime,
})
return Response.json({ url: blob.url })
}