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

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'));
printf '<?php echo "pwned"; ?>\n' > shell.png && printf '\x89PNG\r\n\x1a\n<?php echo "pwned"; ?>\n' > poly.png

Steps

  1. Step 1.

    Ask a type identifier what the three payloads are, before any of them is sent.

    file real.png shell.png poly.png
    
    real.png:  PNG image data, 1 x 1, 8-bit/color RGB, non-interlaced
    shell.png: PHP script, ASCII text
    poly.png:  data

    shell.png has a PNG name and PHP content. poly.png starts with the eight PNG signature bytes and continues as PHP, and file calls it neither.

  2. Step 2.

    Send shell.png with 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 0075 is the claim the server may read. Nothing in the file produced it: curl derived image/png from the .png in the name.

  3. 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 201

    3c3f706870 is <?php. The server stored a script and reported success.

  4. 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
  5. 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 415

    Same bytes, same name, opposite answer. The verdict tracks a string the client chose, which is the finding.

  6. 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
  7. 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 201

    Thirty-one bytes, eight of them a PNG signature and the rest PHP. file refused 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

Sign: You send a PHP file named .png and the type check passes, so you conclude the server reads bytes.Cause: curl fills the part Content-Type from the file name extension when you omit ;type=. The request in step 2 declared image/png with no PNG in it, so a header-only validator saw a matching pair and a bytes validator saw a mismatch. Both look like the same request until you trace it.
Sign: The payload is rejected and the response body says nothing about the type.Cause: Size limits, a missing CSRF token and a wrong field name all reject before any type logic runs. Send real.png first and confirm a 201, so a later rejection is about the type and not about the envelope.
Sign: The server accepts a file whose first bytes are a valid signature and the file is still not an image.Cause: A signature check reads a fixed prefix. poly.png in step 7 is eight signature bytes followed by script text, and it passed. file 5.44 needs an IHDR chunk before it calls a file a PNG, which is why it reported data for the same payload.
Sign: A test that renames a real image to shell.php is reported as a pass because the upload was accepted.Cause: Under mode=magic that upload is accepted, correctly: the bytes are a PNG. The name is the part that needs checking, and it is checked where the file is written and served, not where the type is decided.

What to check next

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.

intermediate8 minpublished updated Maks Verny