File Upload Security Testing Beyond File Extensions

File Upload Security Testing Beyond File Extensions
File upload security testing beyond extension checks means testing the whole lifecycle: who may request an upload, which resource it attaches to, what happens between receipt and release, and what your application serves back as the original or a derived preview. Extension, MIME type and magic-byte checks are useful partial signals at one moment in that lifecycle. They do not tell you whether the right person attached the file to the right record, whether it was released before a scan finished, or whether a thumbnail is served to someone who should never see it.
This guide is for teams that already validate extensions and content types and want to know what else to test. Everything below is a recommended test design, not a measurement, and every fixture is benign: owned synthetic files, no exploit payloads, no decompression bombs.
Imagine a harmless image that passes every check
Imagine a perfectly valid PNG. It has the right extension, the right Content-Type, the right magic bytes and a clean scan result. An authorised user uploads it. Nothing is wrong with the file.
Now imagine the request that attached it named a record belonging to another tenant, and the application only checked that the caller was logged in. Or the file was readable at its final URL for thirty seconds before the scan finished. Or the thumbnail service wrote a derived copy to a path that any signed-in user can fetch.
In each case the file passes every content check and the upload is still a vulnerability. Content validation answers one question about the bytes. Most real upload bugs sit in the questions around it: who, which resource, when, and served back how.
Treat the scenario above as an illustration, not an incident. A test plan built only around hostile filenames will miss the findings that matter.
Why extension, MIME type and magic-byte checks are partial signals
Each of these checks inspects a single attribute at a single moment. None of them establishes that a file is safe or that the request was legitimate.
The OWASP File Upload Cheat Sheet is explicit that there is no silver bullet. Its guidance is layered: allowlist extensions after decoding the filename, treat the client-supplied Content-Type as untrusted because it is trivial to spoof, check the file signature alongside it, generate filenames server-side, restrict who may upload, store files outside the web root with least-privilege permissions, and set size limits. It treats antivirus and content disarm as conditional extras, not guarantees.
Read that list as a set of controls, each with a limited claim:
- An extension allowlist says what the filename claims. It says nothing about the content.
- A
Content-Typecheck says what the client claims. It is attacker-controlled. - A magic-byte check says the first bytes look like the expected format. A file can start as a valid image and still carry other structure, which is why a polyglot (a file valid as two formats at once) is a standard test idea. Test it with a benign fixture: an image that is also a valid text or markup document, with no active payload.
- A malware scan says a particular engine did not flag the file at a particular time.
None of these answer "was this caller allowed to do this" or "what does the system do with the file next." Test them, but don't stop there. A bypass test for extension and MIME checks is a regression test for one control, not an assessment of the feature.
Model the upload lifecycle as explicit states
Model the upload as a small state machine. Most upload findings are really a state being readable or writable when it should not be.
Here is an illustrative design. Your names will differ and you don't need exactly these states:
- Issued: the server has agreed to accept an upload and returned an upload target or token.
- Received (quarantined): bytes have arrived but are not yet visible to anyone except the system.
- Checked: validation and scanning have completed against a specific version of the content.
- Released: the object is attached to its resource and servable to authorised users.
- Rejected or deleted: the object is removed or permanently unreachable.
For each state, write down three things: who can read the object, who can change it, and which transitions are allowed. Then test the gaps between them. Can you reach the object in state 2 through a guessable path? Can you move straight from 1 to 4 by calling the finalise endpoint without any scan having run? Can you re-enter an earlier state by uploading again to the same target?
This framing also makes findings easier to write up. "Object readable in quarantine state through the direct storage path" is a precise, retestable statement. "File upload is insecure" is not.
If your system has no explicit states and simply writes the file where it will be served, that is the first finding to record.
Controls, what they establish, and the extra test: a comparison table
Each upload control establishes a narrow fact, and each needs a test that checks the thing the control does not cover. Use this table as a checklist when scoping the work.
| Control | What it establishes | What it does not establish | Extra test and evidence |
|---|---|---|---|
| Extension allowlist | The filename ends in a permitted type after decoding | What the content is | Upload benign files with mismatched, doubled and mixed-case extensions; record accept or reject and the stored name |
Content-Type check | The client declared a permitted type | The real type | Send a permitted type header with a different body and the reverse; record which value the server trusts |
| Magic-byte check | The leading bytes match the expected format | That the rest of the file is only that format | Use a benign polyglot fixture; record how the file is stored, processed and served |
| Malware scan (for example an EICAR test string) | The engine flagged or did not flag this content now | That unflagged files are harmless, or what happens if the scan fails | Upload the EICAR test file, then force scan timeout and failure; record the file's visibility in each case |
| Size and count limits | Resource use is bounded per request | Bounds on downstream processing | Upload at, just under and over the limit; record rejection point and any partial objects left behind |
| Server-generated filenames | Client input does not control the storage path | Who may read or replace the object | Submit traversal-style names such as ../ sequences in a filename; record the stored key and that nothing lands outside the intended location |
| Upload permission | The caller may upload something | Which resource it attaches to, or who may read it | Attach to another tenant's record ID; record the response and any visible side effect |
| Presigned or tokenised upload | The holder of the URL may perform one specific operation | That the holder is still authorised | Reuse the URL after expiry and after a permission change; record what happens |
Content-Disposition on download | The browser is told to download or render inline | Authorisation, or that the content is safe | Fetch originals and previews; record the header and the content type served |
| Checksum | Bytes were not altered in transit | That the content is allowed or harmless | Confirm the checksum is bound to the object that was later approved |
The EICAR file is a harmless, industry-standard string that antivirus engines are designed to flag. It is the right fixture for proving that your scanning step runs and that a flagged file ends up in a rejected state. It is not evidence that real malware would be caught.
For file-format edge cases (an SVG containing script, an archive with traversal-style entry names, an image with extreme dimensions), use small benign examples and confirm the application's behaviour. Don't exploit anything. If you want to test decompression or image-processing limits, do it against an owned environment with agreed resource ceilings, and treat a resource-exhaustion finding as a limits question to resolve with the owning team.
Test authorisation at every step: create, attach, finalise, retrieve, replace, delete
Test authorisation separately at each step. A check that exists on one endpoint is often missing on the next one that touches the same object.
Work through the verbs with two synthetic users in different tenants and one low-privilege user inside the first tenant:
- Create. Who can request an upload at all? Does the request name a target resource, and is the caller's right to that resource checked at issue time or only later?
- Attach. If the file is uploaded first and linked afterwards, can you link your upload to a record you don't own? Can you link another tenant's upload to yours? Upload permission is not attachment permission.
- Finalise. Can you call the completion step without the upload having happened, with a different object, or twice? Does finalisation re-check authorisation or trust the earlier token?
- Retrieve. Can another tenant fetch the original by ID, by guessing a path, or through a list endpoint? Is retrieval checked against the attached resource or only against being signed in?
- Replace. Can you upload a new file over an existing one you don't own? AWS documents that uploading to an existing object key replaces that object, so scope each issued upload to a specific operation and a specific key, and test overwrite using only objects you created.
- Delete. Who can remove a file, and does deletion also remove derived copies and previews?
Presigned URLs deserve a short, specific check. Per the same AWS documentation, a presigned URL grants access to whoever holds it for the period it is valid, and its capabilities are limited by the permissions of the identity that created it. Whether an application-level permission change cuts off an already issued URL depends on how you built it. Test that directly: issue a URL, remove the user's access, then try the URL. If you intended single use, that is an application requirement to implement and verify, not a property of the URL.
For the broader cross-tenant method, including how to build the matrix across all object types, see Tenant Isolation Testing: How to Prove Tenant Boundaries Hold. For the object-reference side specifically, IDOR Testing for APIs: How to Find Broken Object Access covers the technique.
Test the asynchronous gap: scan timeouts, failures and version-pinned release
Test the time between receipt and release. That gap is where files get served before anyone has decided they are allowed.
Most pipelines are asynchronous: accept the bytes, queue a scan, process, then release. Each hand-off is a place for the state model to break. Recommended tests:
- Pre-release visibility. Immediately after upload, try every read path you can find: the final URL, the storage path, the list endpoint, the preview endpoint. The object should be unreachable until released.
- Scan timeout. In a test environment, make the scanner slow or unavailable. Confirm the file stays unpublished, or that your documented policy for limited access applies. Decide the policy in advance: fail closed, or release to the uploader only. Either can be defensible. An undocumented default is not.
- Scan failure. Force an error result, not just a clean or flagged result. Check that an error is not treated as a pass.
- Flagged file. Upload the EICAR string and confirm the object ends in a rejected state, is removed from every reachable location, and any derived copies go with it.
- Retry and duplicate delivery. If the queue redelivers a message, does the pipeline release twice, or release an object that has since been replaced?
- Version pinning. This is the subtle one. If approval is recorded against a key or a filename, the content under that key can change after approval. Tie the release decision to the exact immutable version or content digest that was scanned and processed. Then test it: upload, wait for approval, replace the object at the same key, and see whether the new bytes inherit the old approval. A storage key alone does not pin content.
Keep the claim modest. A scanner reduces risk and does not find everything. Your test goal is that the system behaves safely when the scanner says nothing useful, not that the scanner is perfect.
Test what you serve back: downloads, previews, thumbnails and derived files
Test what you serve back as carefully as what you accept. The same file can be harmless at rest and dangerous once the browser renders it from your origin.
Start with delivery headers. MDN's Content-Disposition reference explains that inline lets the browser display the content and attachment prompts a download. Treat that header as one delivery control. It does not authorise the request and it does not make content safe. For each served path, record the Content-Type, Content-Disposition, any X-Content-Type-Options and CSP headers, and the origin the file is served from. User-controlled content served from your main application origin deserves extra scrutiny, because anything that runs there runs with that origin's privileges.
SVG is the standard example. It is an image format that can contain script. Upload a benign SVG fixture, open the served URL directly, and note whether it renders as a document from your origin or downloads as an attachment. Borg wrote up a real instance of this class in The Part of Crypto Security Nobody Talks About, where an image proxy served an attacker-controlled SVG as image/svg+xml without an attachment disposition or a script-blocking CSP.
Then test derived files:
- Thumbnails and previews. Are they authorised the same way as the original, or served from a separate public path? Does deleting the original delete them?
- Converted formats. If you convert documents to images or PDFs, does the output keep the access rules of the source?
- Metadata. Do derived files or served headers leak the original filename, a storage path or an internal ID?
- Processing behaviour. Does the image or document processor tolerate unusual but valid inputs (very large dimensions, many pages, odd metadata) within agreed limits? Run these only in an owned test environment, and record the resource impact rather than pushing to failure.
The finding to look for is a derived artefact with weaker access control than its source. It is common because the preview path is built later, by a different team, from a different template.
A small test matrix and the evidence to keep
A small matrix keeps the work reviewable and gives you evidence you can rerun after a fix. Use one row per scenario and keep the same columns every time.
| Scenario | Actor | Expected result | Evidence to keep |
|---|---|---|---|
| Request upload for own resource | Tenant A user | Issued | Request and response, target resource ID |
| Request upload for other tenant's resource | Tenant A user | Denied | Response code and body, confirmation nothing was created |
| Attach own upload to other tenant's record | Tenant A user | Denied | Request, response, record state before and after |
| Finalise without upload or twice | Tenant A user | Denied or idempotent | Request sequence, resulting object state |
| Read object while quarantined | Tenant A user, Tenant B user | Not reachable | Every path tried and its response |
| EICAR string uploaded | Tenant A user | Rejected, removed everywhere | Scan result, object and derived-copy state |
| Scanner timeout and error | System | Unpublished or documented limited access | Fixture description, observed visibility |
| Replace object after approval | Tenant A user | New content not auto-approved | Digest or version before and after, release state |
| Reuse issued URL after expiry and access removal | Tenant A user | Behaviour matches stated design | Timestamps, requests, outcome |
| Fetch original and preview as other tenant | Tenant B user | Denied for both | Responses for each path |
| Open served SVG and HTML-like fixtures | Browser | Attachment or safe rendering | Headers, origin, rendered behaviour |
| Delete original | Tenant A user | Derived copies also gone | Paths checked before and after |
Keep, for every row: the exact request, the response, the object's state before and after, the fixture used and its digest, and the date and build under test. That lets you retest after a fix and say the same sequence now behaves differently, instead of arguing about it.
Two habits help. Use only synthetic accounts and objects you created. And write expected results before you run the tests, so a surprise is obvious rather than rationalised.
This is also a good regression suite. Upload paths change often and each change can quietly reopen an old gap, which is the case made in Security Regression Testing for Authorisation Fixes.
Test the lifecycle, not just the filename
Extension, MIME and magic-byte checks are worth keeping, and they are the cheapest part of file upload security testing. The findings that cost you are usually elsewhere: an upload attached to the wrong tenant, a file readable before it was checked, an approval that followed a key instead of a version, a thumbnail with weaker access than its original. Write the lifecycle down as states, test authorisation at every verb, force the scanner to fail, and test every path that serves the file back. Borg runs whitebox pentests that validate findings against the running application with evidence and reproduction steps, and retests after a fix ships. If you want your upload paths tested that way, book a walkthrough at borghq.io.
Frequently asked questions
- How do you do file upload security testing beyond extension checks?
- Test the lifecycle, not only the file. Check who can request an upload, which resource it attaches to, what happens between receipt and release, and how the original and derived previews are served. Use benign fixtures, two synthetic tenants, and record the request, response and object state for each step so a fix can be retested.
- How do you test magic-byte and Content-Type validation bypasses safely?
- Use benign fixtures only. Send a permitted Content-Type header with a different body and the reverse, try mismatched and doubled extensions, and upload a harmless polyglot that is valid as two formats. Record which value the server trusts and how the file is stored and served. These checks confirm one control, not overall safety.
- How do you test file upload malware scanning with EICAR?
- Upload the EICAR test string, a harmless file that antivirus engines are built to flag. Confirm the object ends rejected and is removed from every reachable path, including derived copies. Then force scanner timeout and error in a test environment and verify the file stays unpublished. EICAR proves the step runs, not that real malware would be caught.
- Is an OWASP file upload checklist enough?
- It is a strong starting point. The OWASP File Upload Cheat Sheet recommends layered controls such as extension allowlists, server-generated filenames, authorised uploaders, isolated storage and size limits, and it states there is no silver bullet. Add tests it cannot cover for your product: tenant attachment, pre-release visibility, version-pinned approval and preview authorisation.
- Can Borg test file upload paths?
- Borg's whitebox pentests test live applications and APIs with source-code context, including authorisation and tenant-isolation paths, and return verified findings with evidence and reproduction steps. After a fix ships, Borg retests it. Teams can book a walkthrough at borghq.io to scope upload flows alongside the rest of the application.



