checklistfile-uploadsecuritytesting

File upload testing checklist: 30+ cases people forget

An upload field looks harmless: pick an image — hit “Upload”. But behind that button hides arguably more edge cases than behind any form: content vs extension, sizes, names with ../, a connection dropping mid-upload, and a whole layer of vulnerabilities that turn an upload into code execution on the server.

The good news — it’s testable by a checklist, and that checklist is reusable everywhere: avatar, document, listing photo, CSV import, ticket attachment. Below is a grouped breakdown plus a flat checklist at the end.

1. Content matters more than the extension

The number one mistake is trusting the extension and the Content-Type header. Both are controlled by the client — which means, by the attacker. A shell.php file renamed to photo.jpg will sail through a “check by the dot”.

  • The server detects the type by content (magic bytes / signature), not by extension and not by the request’s Content-Type.
  • A real image with a .txt extension and text with a .png extension are handled correctly (accepted/rejected deliberately, no crash).
  • Whitelist the allowed types, don’t blacklist forbidden ones (a blacklist is always leaky).
  • A polyglot file (valid both as a GIF and as HTML/JS at once) does not pass as an “innocent image”.
  • A file with no extension, a double extension (invoice.pdf.exe), or uppercase (.JPG, .PnG) — handled predictably.

2. Sizes: zero, limit, limit+1, giant

  • An empty file (0 bytes) is rejected with a clear error, not “uploaded successfully”.
  • Exactly at the limit, limit−1, limit+1 bytes — the boundaries work as advertised.
  • A very large file (several GB) does not take down the server or eat memory (streaming, not loading entirely into RAM); the limit is enforced on the client, the server, and at the web-server level (nginx client_max_body_size).
  • Decompression bomb: a tiny archive/image that expands to gigabytes. The classic 42 KB → 4.5 PB zip bomb; for images — a “pixel flood” (a 100000×100000 picture). The size after decompression/decoding is under the limit too.

3. File names — a source of pain on their own

The file name comes from the user, and you cannot use it “as is” — neither to save to disk nor to serve back.

  • Path traversal: ../../etc/passwd, ..\..\windows — does not escape the upload directory. The best defense is to generate your own name (UUID) and keep the original separately as metadata.
  • A very long name (255+ chars), a name of only spaces/dots (....), Windows-reserved names (CON, NUL, PRN).
  • Unicode and emoji in the name (invoice_2026_😀.pdf), an RTL override (U+202E) that masks the extension: photo‮gpj.exe looks reversed; displays and saves without a crash and without the trick.
  • Special characters and injections in the name: quotes, <script>, ;, $() — escaped both in the UI (the file name in a list) and in the Content-Disposition header on download.
  • A duplicate name: two photo.jpg files don’t silently overwrite each other and don’t serve someone else’s file.

4. The upload process itself

  • Selection methods: button, drag & drop, paste from clipboard (Ctrl+V of a screenshot), mobile camera — all lead to the same result.
  • Multiple upload: pick N files; one broken file in the batch doesn’t kill the rest; a counter/progress per file.
  • Progress, cancel and pause work; a cancel mid-way leaves no half-file on the server and frees the space.
  • Connection drop mid-upload: a clear error, retry without duplicates (resumable upload / idempotency key), the already-selected file isn’t lost.
  • Slow network (2G/3G), a large file over mobile data: the UI doesn’t freeze, there’s a timeout with retry.
  • Mobile: backgrounding/a call/screen lock during upload — it continues or resumes correctly, rather than dying silently.
  • A double click on “Upload” = one request (idempotency); the button is disabled while sending.

5. Server-side processing and storage

  • Images: resize/re-encode, thumbnail generation; a rotated photo with EXIF orientation displays correctly; metadata (GPS coordinates!) is stripped for privacy.
  • Where the file lives: outside the webroot (can’t be hit directly by URL and executed); served through a controller with a permission check.
  • Privacy and access: someone else’s file can’t be fetched by substituting an id/name (IDOR); private files go through signed, expiring URLs, not “a guessable path”.
  • CDN/cache doesn’t cache a private file publicly; after deletion the file is genuinely unavailable (in the cache too).
  • User quotas: hitting the storage limit is handled, not a 500.

6. Upload security — the main part

This is where the most expensive bugs live. The baselines are the OWASP File Upload Cheat Sheet and Unrestricted File Upload.

  • Content validation + an extension whitelist + a size limit — three checks together, not instead of one another.
  • An SVG with an embedded <script> → XSS when viewed; SVG is either forbidden, sanitized, or served as an attachment rather than rendered inline.
  • No execution in the upload folder: no .php/.jsp interpretation; storage outside the webroot; a separate domain/subdomain for user content.
  • Download response: a correct Content-Type, X-Content-Type-Options: nosniff (so the browser doesn’t “guess” HTML inside an image), Content-Disposition: attachment for non-viewable types.
  • RCE via processing: vulnerable libraries (ImageMagick “ImageTragick”, Ghostscript, ffmpeg) are patched; processing runs sandboxed/constrained.
  • SSRF if “upload by URL” exists: the server doesn’t reach 169.254.169.254/internal addresses; file://, gopher:// schemes are blocked.
  • Antivirus scan (e.g. ClamAV) for documents/attachments; the EICAR test file is actually caught.
  • Null byte and double extension (file.php%00.jpg, file.php.jpg) don’t bypass the check.

7. UX states and accessibility

  • A clear error: why the file was rejected (type? size? name?), not just “Error”.
  • All states are visible: empty → selected → uploading(%) → success → error → retry.
  • A custom dropzone is keyboard- and screen-reader-accessible (a native <input type=file> under the hood, aria-label, progress announced via aria-live).
  • The preview doesn’t break the layout (a vertical/panoramic photo, a transparent PNG, a broken file → placeholder).
  • A hint about allowed formats and the limit — before the attempt, not only in the error text.

Flat checklist (save it)

  • Type detected by content (magic bytes), not by extension/Content-Type
  • Whitelist of types and extensions, size limit — on the client and the server
  • 0 bytes, limit, limit±1, giant file, decompression/zip/pixel bomb
  • Path traversal in the name (../), own name (UUID), original in metadata
  • Long name 255+, dots/spaces, reserved names, RTL override
  • Unicode/emoji in the name; special chars escaped in UI and Content-Disposition
  • Duplicate names don’t overwrite and don’t serve someone else’s file
  • Button / drag&drop / paste / camera — a single result
  • Multiple: a broken file in the batch doesn’t kill the rest, progress per file
  • Progress/cancel/pause; cancel leaves no junk on the server
  • Connection drop → clear error, retry without duplicates, input not lost
  • Slow network/mobile, backgrounding during upload
  • Double click = one request; the button is disabled
  • EXIF: orientation honored, GPS/metadata stripped
  • Files outside the webroot, served via a controller with a permission check
  • No IDOR: someone else’s file isn’t fetchable by id/name; private — signed URLs
  • SVG not rendered inline (sanitize/attachment) — no XSS
  • No .php/.jsp execution in the upload folder
  • nosniff + correct Content-Type + Content-Disposition: attachment
  • Processors (ImageMagick/Ghostscript/ffmpeg) patched, sandboxed
  • Upload-by-URL doesn’t do SSRF (internal addresses/metadata/file://)
  • Antivirus scan; EICAR is caught
  • Null byte and double extension don’t bypass the check
  • Quota/storage limit handled without a 500
  • Clear errors and all states (empty/uploading/success/error)
  • Dropzone keyboard- and screen-reader-accessible; preview doesn’t break layout

Bottom line. A file upload is a mini-form with its own physics: content beats extension, the name can’t be trusted, the network drops, and “check by the dot” validation opens RCE. Run this checklist on any upload field — and you’ll close most of the “boring” bugs and a couple of very expensive ones at once.

Sources: OWASP File Upload Cheat Sheet, OWASP Unrestricted File Upload, David Fifield — A better zip bomb.