How to check file type
Read the first bytes instead of the name. file --mime-type photo.jpg prints image/png when a PNG has been renamed, and xxd -l 16 photo.jpg shows the signature 8950 4e47 0d0a 1a0a. The extension and the uploaded Content-Type are both claims made by the client, not facts about the bytes.
Why check this
Run this on every feature that accepts a file, before release and again after the validation library changes. The check separates three things a bug report usually merges: what the file is called, what the request says it is, and what its bytes say it is.
One concrete failure it prevents: a PHP script uploaded as logo.png and stored in a directory the web server can execute. A second: a PNG renamed to .jpg that passes the extension allow list, then breaks the thumbnailer in production because the decoder was picked from the name. Both cases look identical in a test that reads only the file name.
Prerequisites
- Node 22 for the fixtures and the endpoint. No packages are installed.
file5.44 andxxd, both present in Git Bash. See the file manual for what--mime-typechanges.- curl 7.52 or later for
-F. Its;type=parameter sets the part'sContent-Typeto any string you write. - Nothing on this page sends a file to a third-party host.
Create the fixtures. The script writes a valid one-pixel PNG, a byte copy of it named photo.jpg, a PHP script named logo.png, the same script behind a GIF87a header, and the first 8 bytes of the PNG alone.
import { writeFileSync } from 'node:fs';
import { deflateSync } from 'node:zlib';
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
const table = [];
for (let n = 0; n < 256; n += 1) {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
let crc = 0xffffffff;
for (const b of body) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
return Buffer.concat([len, body, crcBuf]);
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(1, 0);
ihdr.writeUInt32BE(1, 4);
ihdr[8] = 8; ihdr[9] = 2;
const png = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', deflateSync(Buffer.from([0x00, 0xff, 0x00, 0x00]))),
chunk('IEND', Buffer.alloc(0)),
]);
const script = Buffer.from('\n', 'latin1');
writeFileSync('sample.png', png);
writeFileSync('photo.jpg', png);
writeFileSync('logo.png', script);
writeFileSync('banner.gif', Buffer.concat([Buffer.from('GIF87a', 'latin1'), script]));
writeFileSync('trunc.png', png.subarray(0, 8));
Steps 3 and 4 post to a local endpoint that reports the declared type and the sniffed type side by side. Save it as upload-8931.mjs, start it with node upload-8931.mjs, and stop it when you finish.
import { createServer } from 'node:http';
const SIGNATURES = [
['89504e470d0a1a0a', 'image/png'],
['ffd8ff', 'image/jpeg'],
['474946383761', 'image/gif'],
['474946383961', 'image/gif'],
['25504446', 'application/pdf'],
['504b0304', 'application/zip'],
];
createServer((req, res) => {
const parts = [];
req.on('data', (d) => parts.push(d));
req.on('end', () => {
const body = Buffer.concat(parts);
const sep = body.indexOf('\r\n\r\n');
const head = body.subarray(0, sep).toString('latin1');
const hex = body.subarray(sep + 4, sep + 12).toString('hex');
const sniffed = SIGNATURES.find(([sig]) => hex.startsWith(sig));
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({
filename: /filename="([^"]*)"/.exec(head)?.[1] ?? null,
declaredType: /Content-Type:\s*(\S+)/i.exec(head)?.[1] ?? null,
firstBytesHex: hex,
sniffedType: sniffed ? sniffed[1] : null,
}));
});
}).listen(8931, '127.0.0.1', () => console.log('listening on 8931'));
Steps
- Step 1.
Print the opening bytes of a file you trust, in hex and in ASCII.
xxd -l 16 sample.png00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452 .PNG........IHDRThe first eight bytes are the PNG signature.
IHDRat offset 12 is the first chunk name, so the header is structurally intact, not only the magic number. - Step 2.
Ask for the type of every fixture at once and compare it with the name on disk.
file --mime-type sample.png photo.jpg logo.png banner.gif trunc.pngsample.png: image/png photo.jpg: image/png logo.png: text/x-php banner.gif: image/gif trunc.png: application/octet-streamTwo lines disagree with the extension.
photo.jpgis a PNG, andlogo.pngis source code.fileread the bytes and ignored the name in both directions. - Step 3.
Send the PNG to the endpoint while telling it the part is a JPEG.
curl -s -F 'file=@photo.jpg;type=image/jpeg' http://127.0.0.1:8931/upload{"filename":"photo.jpg","declaredType":"image/jpeg","firstBytesHex":"89504e470d0a1a0a","sniffedType":"image/png"}declaredTypeis the string curl was told to write.sniffedTypecomes from the bytes that arrived. A validator that reads the first field accepts anything a client cares to claim. - Step 4.
Post the file that carries a GIF header in front of a script.
curl -s -F 'file=@banner.gif;type=image/gif' http://127.0.0.1:8931/upload{"filename":"banner.gif","declaredType":"image/gif","firstBytesHex":"4749463837613c3f","sniffedType":"image/gif"}Name, declared type and signature agree, and the payload after byte 6 is still
<?php. A signature check answers what the file starts with, never what the rest of it holds. - Step 5.
Run the same signature table over the files directly, the way an assertion in a test suite would.
node -e "const fs=require('node:fs');const SIG={'89504e470d0a1a0a':'image/png','ffd8ff':'image/jpeg','474946383761':'image/gif','25504446':'application/pdf','504b0304':'application/zip'};for(const f of process.argv.slice(1)){const h=fs.readFileSync(f).subarray(0,8).toString('hex');const t=Object.keys(SIG).find(s=>h.startsWith(s));console.log(f,h,t?SIG[t]:'unknown');}" sample.png photo.jpg logo.png banner.gif trunc.pngsample.png 89504e470d0a1a0a image/png photo.jpg 89504e470d0a1a0a image/png logo.png 3c3f706870206563 unknown banner.gif 4749463837613c3f image/gif trunc.png 89504e470d0a1a0a image/pngCompare the last line with step 2. An eight-byte file is
image/pngto the signature table andapplication/octet-streamtofile, becausefilelooks for a structure and the table looks for a prefix.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| The type matches the extension | Name and bytes agree on this file | Nothing. Keep the check in the suite. |
| photo.jpg: image/png | The name is wrong or the file was renamed | Decide by bytes everywhere downstream, and store the type you detected. |
| logo.png: text/x-php | An executable text file wearing an image name | Treat as an incident on a live upload directory. Check How to check if file type is validated on the server. |
| declaredType and sniffedType differ | The client lied, or the browser guessed from the extension | Reject on the server. The request header is not evidence. |
| application/octet-stream | No rule in the magic database matched | Read the bytes yourself before deciding. It can be a new format or a broken file. |
Common mistakes
Thresholds
What to check next
- How to check if file type is validated on the server: this page identifies the file, that one exercises the endpoint's rules.
- How to test uploading a file with a double extension: the case where the name has two answers and the server picks the wrong one.
- How to check x-content-type-options: what stops a browser sniffing the file you serve back.
- How to check which file types a file input accepts: the client-side filter, which every request in step 3 bypassed.
- How to check the encoding of a file: once the type is text, the next question is which encoding.
FAQ
How to check the MIME type of a file?
file --mime-type name.ext prints it from the bytes. Without --mime-type the same command prints a longer description, including image dimensions and colour depth. Both read the magic database, never the extension, so a renamed file gives the same answer as the original.
How to check a file type without an extension?
The same commands work. Nothing in step 1, 2 or 5 uses the name for anything except opening the file.
How to check a PNG file header?
xxd -l 16 file.png. The first eight bytes are 8950 4e47 0d0a 1a0a. Bytes 12 to 15 spell IHDR, the first chunk. If those four are missing, the signature was copied onto something that is not a PNG.
Is a file signature check enough on its own?
No. Step 4 built a file that passes it and holds a script. Use the signature to reject fast, then decode or re-encode the image, store it under a name you generate, and serve it from a location that cannot execute.
Does the Content-Type in the upload request tell you anything?
It tells you what the client claimed. Step 3 sent PNG bytes labelled image/jpeg and the server recorded both. Treat the header as an input to validate, in the same class as the file name.
Verified
Verified by Maks Vernyfile 5.44curl 8.21.0node 22.23.2
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
basic8 minpublished updated Maks Verny