How to test uploading a file with a double extension

Send one payload under two names and read what each layer decides. curl -F 'file=@payload.php;filename=avatar.php.jpg' returns allowByLast: true, because the allowlist reads the last extension and never sees the php in the middle. Fetching the stored file back returns Content-Type: image/jpeg over PHP source.

Why check this

Run this on any endpoint that accepts a file and derives something from the name: the stored path, the type sent back to the browser, the decoder a thumbnailer picks. Run it before release and again after anyone edits the validation pattern.

The failure it prevents is a chain in which no single layer is wrong. An allowlist reading the last extension accepts avatar.php.jpg. Storage keeps the name it was handed. The handler that serves the file later reports image/jpeg for a body that is source code. Each layer answered a different question about the same name.

Prerequisites

Create the payload. It stays 38 bytes throughout, so every difference below comes from the name.

printf '\n' > payload.php

Save the endpoint as upload-8931.mjs and start it with node upload-8931.mjs. It applies three extension rules to the name, stores the file under it, and serves it back with a type read from the last extension.

import { createServer } from 'node:http';
import { extname, basename } from 'node:path';
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';

const ALLOW = ['png', 'jpg', 'jpeg', 'pdf'];
const TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
  pdf: 'application/pdf', php: 'application/x-httpd-php' };
mkdirSync('store', { recursive: true });

createServer((req, res) => {
  if (req.method === 'GET') {
    const name = decodeURIComponent(req.url.slice('/files/'.length));
    if (!existsSync(`store/${name}`)) { res.writeHead(404).end('not stored'); return; }
    res.setHeader('content-type', TYPES[name.split('.').pop().toLowerCase()] ?? 'application/octet-stream');
    res.end(readFileSync(`store/${name}`));
    return;
  }
  const parts = [];
  req.on('data', (d) => parts.push(d));
  req.on('end', () => {
    const body = Buffer.concat(parts);
    const head = body.subarray(0, body.indexOf('\r\n\r\n')).toString('latin1');
    const bytes = body.subarray(body.indexOf('\r\n\r\n') + 4, body.lastIndexOf('\r\n--'));
    const name = /filename="([^"]*)"/.exec(head)?.[1] ?? '';
    const seg = name.split('.');
    writeFileSync(`store/${basename(name)}`, bytes);
    res.setHeader('content-type', 'application/json');
    res.end(JSON.stringify({
      filename: name,
      lastExt: seg.at(-1).toLowerCase(),
      firstExt: seg.length > 2 ? seg[1].toLowerCase() : null,
      pathExtname: extname(name),
      allowByLast: ALLOW.includes(seg.at(-1).toLowerCase()),
      allowByFirst: ALLOW.includes((seg[1] ?? '').toLowerCase()),
      allowByRegexAnywhere: /\.(png|jpe?g|pdf)/i.test(name),
      storedBytes: bytes.length,
    }));
  });
}).listen(8931, '127.0.0.1', () => console.log('listening on 8931'));

Steps

  1. Step 1.

    Ask three common ways of reading an extension what each name ends in.

    node -e "const p=require('node:path');for(const n of process.argv.slice(1)){const s=n.split('.');console.log(n.padEnd(16),s.at(-1).padEnd(9),(s.length>2?s[1]:'-').padEnd(5),(p.extname(n)||'(none)').padEnd(8));}" avatar.php.jpg invoice.pdf.php report.pdf archive.tar.gz .htaccess
    
    avatar.php.jpg   jpg       php   .jpg
    invoice.pdf.php  php       pdf   .php
    report.pdf       pdf       -     .pdf
    archive.tar.gz   gz        tar   .gz
    .htaccess        htaccess  -     (none)

    Column 2 is split('.').pop(), column 3 is the first extension after the base name, column 4 is path.extname. For avatar.php.jpg columns 2 and 4 say JPEG and column 3 says PHP. path.extname reports one extension at most, so .htaccess reads as nothing.

  2. Step 2.

    Send the same 38 bytes under three names and read which rule each one satisfies.

    for n in avatar.php.jpg invoice.pdf.php 'shell.jpg.php.'; do curl -s -F "file=@payload.php;filename=$n" http://127.0.0.1:8931/upload; echo; done
    
    {"filename":"avatar.php.jpg","lastExt":"jpg","firstExt":"php","pathExtname":".jpg","allowByLast":true,"allowByFirst":false,"allowByRegexAnywhere":true,"storedBytes":38}
    {"filename":"invoice.pdf.php","lastExt":"php","firstExt":"pdf","pathExtname":".php","allowByLast":false,"allowByFirst":true,"allowByRegexAnywhere":true,"storedBytes":38}
    {"filename":"shell.jpg.php.","lastExt":"","firstExt":"jpg","pathExtname":".","allowByLast":false,"allowByFirst":true,"allowByRegexAnywhere":true,"storedBytes":38}

    avatar.php.jpg passes the allowlist that reads the last extension. invoice.pdf.php fails that one and still passes allowByRegexAnywhere, an unanchored /\.(png|jpe?g|pdf)/i matching .pdf in the middle. shell.jpg.php. has an empty last extension, so an allowlist and a blocklist keyed on it find nothing to compare.

  3. Step 3.

    Ask for the accepted file back the way a browser would.

    curl -s -i http://127.0.0.1:8931/files/avatar.php.jpg
    
    HTTP/1.1 200 OK
    content-type: image/jpeg
    Date: Sat, 12 Sep 2026 17:15:07 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    Content-Length: 38
    
    

    The serving layer read the same last extension the validator read, so it announces image/jpeg. Content-Length: 38 matches storedBytes from step 2: nothing rewrote the name or re-encoded the body.

  4. Step 4.

    Read the type the same handler reports for the other two names.

    for n in invoice.pdf.php 'shell.jpg.php.'; do curl -s -o /dev/null -w "$n %{http_code} %{content_type}\n" "http://127.0.0.1:8931/files/$n"; done
    
    invoice.pdf.php 200 application/x-httpd-php
    shell.jpg.php. 200 application/octet-stream

    The last extension decides the type in all three cases, and the name ending in a dot falls off the lookup table. Every fetch returns 200, so a test asserting the status code alone passes on all three.

  5. Step 5.

    Ask the filesystem whether the names it has listed can be opened.

    Get-ChildItem store | ForEach-Object { "$($_.Name) $(Test-Path "store\$($_.Name)")" }
    
    avatar.php.jpg True
    invoice.pdf.php True
    shell.jpg.php. False

    The listing contains shell.jpg.php. and the lookup for that name fails. Win32 removes the trailing dot before resolving the path, while Node wrote the file through a form that keeps it. Remove-Item "store\shell.jpg.php." answers Cannot find path ... because it does not exist, so a cleanup pass that deletes by name leaves this file behind.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | allowByLast true with firstExt php | The allowlist read the extension the uploader chose to put last | Decide by bytes. See How to check file type. | | allowByRegexAnywhere true, allowByLast false | The pattern has no anchor, so it matches an extension in the middle | Anchor the pattern with $ and test the whole name, not a substring. | | lastExt is the empty string | The name ends in a dot, and both list styles have nothing to compare | Reject a name ending in a dot or a space before anything stores it. | | The served content-type follows the last extension | The serving layer repeats the guess the validator made | Store the type you detected and send it from that record. | | 200 on all three fetches | The status code carries no verdict here | Assert the served type and the stored name, not the status. | | A listed name that Test-Path cannot find | The name survived storage in a form the OS API will not resolve | Generate the stored name yourself and keep the uploaded one as data. |

Common mistakes

Sign: The allowlist test is green for avatar.php.jpg and the ticket is closed as covered.Cause: Both split('.').pop() and path.extname report jpg for that name, and the test asserted the same field the code reads. The name still carries php as its first extension, and step 1 shows the two rules that report it.
Sign: A pattern that looks like an allowlist accepts a file ending in .php.Cause: /\.(png|jpe?g|pdf)/i has no anchor. It searches the whole name, finds .pdf inside invoice.pdf.php and returns true. The verdict field in step 2 is allowByRegexAnywhere, and it is true on every name on this page.
Sign: The name ends in a dot and every extension check passes it without an opinion.Cause: split('.').pop() on shell.jpg.php. returns the empty string and path.extname returns a lone dot. An allowlist rejects it and a blocklist accepts it, so the same name gets opposite verdicts from two teams reading the same field.
Sign: The quarantine script reports zero files removed and the file is still in the directory listing.Cause: Step 5 measured it: the directory lists shell.jpg.php. and Test-Path for that name is False on Windows 11. The tool that stored the file and the tool that cleans up resolved the same string to two different paths.

What to check next

FAQ

What is a double extension upload?

A file whose name carries more than one extension, such as avatar.php.jpg or invoice.pdf.php. Nothing about it is malformed. It is a test case because each layer reads a different part of the name, as step 1 shows.

Which extension should a validator read?

None of them on its own. Read the bytes to decide the type, then generate the stored name from an identifier you control and the extension you detected. If a name check stays in the pipeline, anchor it to the end of the string and reject any name with more than one dot.

Does the request really let me choose the name?

Yes. Step 2 sent payload.php from disk three times and wrote a different filename= into each request. curl copies that parameter into the multipart part unchanged, so the name the server parses is input from the client, in the same class as any form field.

Is a trailing dot worth testing on a Linux server?

The parsing half is, and step 2 covers it: the empty last extension breaks a name check on any platform. The storage half in step 5 is a Windows result. On Linux the dot is kept by both the write and the lookup, so the listing and the delete agree.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2PowerShell 5.1.22621.6133

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.

intermediate9 minpublished updated Maks Verny