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
- Node 22 for the endpoint. No packages needed.
- curl 7.52 or later. Its
-Ftakes afilename=parameter, so the name in the request is written by the client. See the curl -F manual. - Port 8931 free. Stop the server when you finish.
- Step 5 reads a Windows 11 NTFS volume through PowerShell 5.1 and rests on Win32 path normalisation, which removes a trailing dot. Steps 1 to 4 are portable.
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
- 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 .htaccessavatar.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 ispath.extname. Foravatar.php.jpgcolumns 2 and 4 say JPEG and column 3 says PHP.path.extnamereports one extension at most, so.htaccessreads as nothing. - 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.jpgpasses the allowlist that reads the last extension.invoice.pdf.phpfails that one and still passesallowByRegexAnywhere, an unanchored/\.(png|jpe?g|pdf)/imatching.pdfin the middle.shell.jpg.php.has an empty last extension, so an allowlist and a blocklist keyed on it find nothing to compare. - 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.jpgHTTP/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: 38The serving layer read the same last extension the validator read, so it announces
image/jpeg.Content-Length: 38matchesstoredBytesfrom step 2: nothing rewrote the name or re-encoded the body. - 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"; doneinvoice.pdf.php 200 application/x-httpd-php shell.jpg.php. 200 application/octet-streamThe 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.
- 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. FalseThe 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."answersCannot 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
What to check next
- How to check file type: this page reads the name, that one reads the bytes.
- How to check if file type is validated on the server: run the endpoint's own rules against the names from step 2.
- How to test multipart form data: where the
filename=parameter sits in the request. - How to check content disposition header: what the browser calls the file on the way back out.
- How to check X-Content-Type-Options: what stops a browser overriding the type step 3 sent.
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.
Related on this site
intermediate9 minpublished updated Maks Verny