How to check if file type is validated on the server
Send one upload whose three type claims disagree: a .png file name, a Content-Type: image/png part header, and PHP text in the body. curl -F "file=@shell.png;type=image/png" sends it. A server that answers 201 trusted the name or the header. Only a rejection proves it read the bytes.
Why check this
A multipart upload carries three separate claims about what the file is, and a client writes all three. The file name is typed by whoever picked the file. The Content-Type line inside the part is written by the browser or by curl. The bytes are the only one the server can verify. This check tells you which of the three the server believed.
Run it before sign-off on any endpoint that accepts files, and again after a change to the upload middleware or a switch of storage backend. The failure it prevents is concrete: a file called avatar.png holding <?php ... ?>, stored under the document root, and executed on the next GET of its URL. The same gap lets an SVG with a script tag through an "images only" filter.
What the check does not settle: where the file was stored, under what name, and with which Content-Type it is served back. Those are separate reads, and a server that rejects the payloads below can still serve an accepted image as How to check content-type of API response describes.
Prerequisites
- curl 8.0 or later. The
-Foption takes;type=and;filename=suffixes, which set the part header and the sent name independently of the file on disk. See the curl manual on -F. - Node 22 for the target below. Point the commands at your own route instead, and expect its own status codes.
- A target that reports its verdict. The one here accepts
?mode=ext,?mode=ctypeand?mode=magic, so one request reads against all three strategies. Save it asupload-server.mjsand runnode upload-server.mjs.
import { createServer } from 'node:http';
const PNG = Buffer.from('89504e470d0a1a0a', 'hex');
const rules = {
ext: (p) => /\.(png|jpe?g|gif)$/i.test(p.filename),
ctype: (p) => /^image\/(png|jpeg|gif)$/.test(p.type),
magic: (p) => p.body.subarray(0, 8).equals(PNG),
};
createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1:8932');
const mode = url.searchParams.get('mode') || 'ext';
const buf = [];
req.on('data', (c) => buf.push(c));
req.on('end', () => {
const raw = Buffer.concat(buf);
const b = /boundary=(.+)$/.exec(req.headers['content-type'] || '');
const part = raw.toString('binary').split('--' + b[1]).find((s) => s.includes('name="file"')) || '';
const cut = part.indexOf('\r\n\r\n');
const head = part.slice(0, cut);
const p = {
filename: (/filename="([^"]*)"/.exec(head) || [, ''])[1],
type: (/Content-Type:\s*(\S+)/i.exec(head) || [, ''])[1],
body: Buffer.from(part.slice(cut + 4, part.length - 2), 'binary'),
};
const ok = (rules[mode] || rules.ext)(p);
res.writeHead(ok ? 201 : 415, { 'content-type': 'application/json' });
res.end(JSON.stringify({
mode, accepted: ok, filename: p.filename, declaredType: p.type,
firstBytes: p.body.subarray(0, 8).toString('hex'), size: p.body.length,
}) + '\n');
});
}).listen(8932, '127.0.0.1', () => console.log('upload target on 8932'));
- Three payloads in the same directory.
real.pngis any PNG you already have; the run below used a 1 by 1 truecolour PNG of 69 bytes.
printf '<?php echo "pwned"; ?>\n' > shell.png && printf '\x89PNG\r\n\x1a\n<?php echo "pwned"; ?>\n' > poly.png
Steps
- Step 1.
Ask a type identifier what the three payloads are, before any of them is sent.
file real.png shell.png poly.pngreal.png: PNG image data, 1 x 1, 8-bit/color RGB, non-interlaced shell.png: PHP script, ASCII text poly.png: datashell.pnghas a PNG name and PHP content.poly.pngstarts with the eight PNG signature bytes and continues as PHP, andfilecalls it neither. - Step 2.
Send
shell.pngwith no extra options and read what curl put on the wire.curl -s --trace-ascii - -o /dev/null -F "file=@shell.png" "http://127.0.0.1:8932/upload?mode=ext"=> Send data, 221 bytes (0xdd) 0000: --------------------------9YF2yzk6mqA7g2mMtydobq 0032: Content-Disposition: form-data; name="file"; filename="shell.png 0072: " 0075: Content-Type: image/png 008e: 0090: <?php echo "pwned"; ?>. 00a9: --------------------------9YF2yzk6mqA7g2mMtydobq--Line
0075is the claim the server may read. Nothing in the file produced it: curl derivedimage/pngfrom the.pngin the name. - Step 3.
Send the same payload to the endpoint that validates by extension.
curl -s -F "file=@shell.png" "http://127.0.0.1:8932/upload?mode=ext" -w '\nHTTP %{http_code}\n'{"mode":"ext","accepted":true,"filename":"shell.png","declaredType":"image/png","firstBytes":"3c3f706870206563","size":23} HTTP 2013c3f706870is<?php. The server stored a script and reported success. - Step 4.
Send it to the endpoint that validates by the declared
Content-Type.curl -s -F "file=@shell.png" "http://127.0.0.1:8932/upload?mode=ctype" -w '\nHTTP %{http_code}\n'{"mode":"ctype","accepted":true,"filename":"shell.png","declaredType":"image/png","firstBytes":"3c3f706870206563","size":23} HTTP 201 - Step 5.
Repeat the request with an honest part header and compare the verdict.
curl -s -F "file=@shell.png;type=text/plain" "http://127.0.0.1:8932/upload?mode=ctype" -w '\nHTTP %{http_code}\n'{"mode":"ctype","accepted":false,"filename":"shell.png","declaredType":"text/plain","firstBytes":"3c3f706870206563","size":23} HTTP 415Same bytes, same name, opposite answer. The verdict tracks a string the client chose, which is the finding.
- Step 6.
Send it to the endpoint that reads the first bytes.
curl -s -F "file=@shell.png" "http://127.0.0.1:8932/upload?mode=magic" -w '\nHTTP %{http_code}\n'{"mode":"magic","accepted":false,"filename":"shell.png","declaredType":"image/png","firstBytes":"3c3f706870206563","size":23} HTTP 415 - Step 7.
Send the signature payload to the same byte check.
curl -s -F "file=@poly.png" "http://127.0.0.1:8932/upload?mode=magic" -w '\nHTTP %{http_code}\n'{"mode":"magic","accepted":true,"filename":"poly.png","declaredType":"image/png","firstBytes":"89504e470d0a1a0a","size":31} HTTP 201Thirty-one bytes, eight of them a PNG signature and the rest PHP.
filerefused to call this an image in step 1, and the eight-byte check took it.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| Step 3 accepted, step 6 rejected | The route validates bytes, not the name | Move on to file names: double extensions and path separators. |
| Steps 3 and 4 both accepted | Nothing reads the file | Raise it as a defect. The name and the header are attacker input. |
| Step 4 accepted, step 5 rejected | The verdict follows the part header | Same defect. A browser sends what its own table says, and curl sends what you tell it. |
| Step 7 accepted | The check reads a fixed prefix only | Ask for a decode, not a signature match. Eight bytes prove nothing about the rest. |
| Every step rejected, including real.png | The route rejects the request for another reason | Check the field name, the size limit and the auth before reading these results. |
Common mistakes
What to check next
- How to check file type: how a real type is derived from bytes, which is the check the server should be running.
- How to test file upload size limit: the other rejection path that has to be separated from this one.
- How to check X-Content-Type-Options: what happens when the accepted file is served back and the browser sniffs it.
- How to check which file types a file input accepts: the client side filter that this check goes around.
FAQ
How do I check the MIME type of an uploaded file?
Read the bytes on the server, not the part header, which is a client string as step 5 shows. In a test, run file or a libmagic binding on the stored file and compare it with what the API reported.
Does an accept attribute on the input make this unnecessary?
No. accept filters the file picker dialogue. It is not sent with the request, so no server sees it. Any client that builds the multipart body by hand ignores it.
Which file upload vulnerability test cases belong in a regression suite?
Four fit in one run: a script body under an image name, an honest declared type that should fail, a signature prefix followed by other content, and a real image under a script name. The first three are steps 3, 5 and 7 above.
Is a 200 response enough to call the upload accepted?
No. Check what the response says was stored. An endpoint can answer 200 with a body naming a validation error, and an endpoint can answer 201 after dropping the file. The status and the stored object are separate facts.
Why test with curl rather than the browser form?
The browser writes the part header from its own type table and sends the name of the file you picked. curl sets name, header and bytes independently, which is the only way to make the three disagree.
Verified
Verified by Maks Vernycurl 8.21.0node 22.23.2file 5.44
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny