File uploads validated only by extension are trivially bypassed: rename shell.php to shell.jpg and upload. Without MIME type inspection via magic bytes and enforced server-side size limits, attackers can store and potentially execute malicious payloads on your server, or exhaust storage with multi-gigabyte uploads that bypass client-only size caps. CWE-434 (Unrestricted Upload of File with Dangerous Type) and OWASP A03 cover this directly.
High because successful malicious file upload can lead to remote code execution or storage exhaustion, though exploitation requires the server to execute the file or lack adequate sandboxing.
Validate MIME type via file magic bytes, not just Content-Type header or file extension. Enforce size before reading the full buffer. In app/api/listings/upload/route.ts:
const ALLOWED_TYPES: Record<string, number[]> = {
'image/jpeg': [0xFF, 0xD8, 0xFF],
'image/png': [0x89, 0x50, 0x4E, 0x47],
'image/webp': [0x52, 0x49, 0x46, 0x46]
}
const MAX_BYTES = 5 * 1024 * 1024 // 5 MB
if (file.size > MAX_BYTES) {
return Response.json({ error: 'File exceeds 5 MB limit' }, { status: 400 })
}
const buf = new Uint8Array(await file.arrayBuffer())
const sig = ALLOWED_TYPES[file.type]
if (!sig || !sig.every((b, i) => buf[i] === b)) {
return Response.json({ error: 'Unsupported or mismatched file type' }, { status: 400 })
}
Store uploaded files outside the web root (or in a dedicated object storage bucket) and never serve them with execute permissions.
ID: directory-submissions-moderation.moderation.file-upload-security
Severity: high
What to look for: If the form allows file uploads (images, documents), examine the validation. Check for: (1) MIME type validation (file extension is not enough), (2) file size limits enforced server-side, (3) file storage isolated from source code. Test uploading a file with a fake extension (e.g., .jpg containing executable code) — the server should reject it.
Pass criteria: Enumerate all relevant code paths. File uploads are validated server-side for MIME type using the file's magic bytes/content-type, not just extension. File size limits are enforced (e.g., max 5MB). Uploaded files are stored outside the source directory and served with appropriate headers (no execute permissions).
Fail criteria: No file upload validation, or validation only checks extension, or no size limits, or files are stored in the web root.
Skip (N/A) when: The form does not allow file uploads.
Detail on fail: "Users can upload files with any extension. An attacker uploads an .exe file disguised as .jpg." or "File size limit only exists on client — server accepts multi-GB uploads."
Remediation: Validate file uploads server-side:
// app/api/listings/upload/route.ts
import { writeFile } from 'fs/promises'
import path from 'path'
import { v4 as uuidv4 } from 'uuid'
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
export async function POST(req: Request) {
const formData = await req.formData()
const file = formData.get('file') as File
// Check MIME type
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
return Response.json(
{ error: 'Invalid file type. Only JPEG, PNG, and WebP allowed.' },
{ status: 400 }
)
}
// Check file size
if (file.size > MAX_FILE_SIZE) {
return Response.json(
{ error: 'File too large. Maximum 5MB.' },
{ status: 400 }
)
}
// Read buffer and verify magic bytes
const buffer = await file.arrayBuffer()
const view = new Uint8Array(buffer)
// Check PNG signature
if (file.type === 'image/png') {
const pngSignature = [137, 80, 78, 71]
if (!pngSignature.every((byte, i) => view[i] === byte)) {
return Response.json(
{ error: 'File is not a valid PNG' },
{ status: 400 }
)
}
}
// Store with random name in secure location
const filename = `${uuidv4()}-${Date.now()}.${file.type.split('/')[1]}`
const filepath = path.join(process.cwd(), 'public', 'uploads', filename)
await writeFile(filepath, Buffer.from(buffer))
return Response.json({ url: `/uploads/${filename}` })
}