# File uploads enforce MIME type and size limits server-side

- **Pattern:** `ab-001081` (`directory-submissions-moderation.moderation.file-upload-security`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-001081
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-001081)

## Why it matters

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.

## Severity rationale

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.

## Remediation

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`:

```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.

## Detection

- **ID:** `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:

  ```ts
  // 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}` })
  }
  ```

## External references

- cwe CWE-434
- cwe CWE-20
- owasp A03

Taxons: injection-and-input-trust, cost-efficiency

HTML version: https://auditbuffet.com/patterns/ab-001081
