Stop trusting file extensions: validating uploads by content (with ffmpeg)
A user of my free tool uploaded a recording named voice.m4a and got "This doesn't look like a real .m4a file." The file was fine. It was a WebM/Opus recording that had been saved with an .m4a extension. My upload check w
A user of my free tool uploaded a recording named voice.m4a and got "This doesn't look like a real .m4a file." The file was fine. It was a WebM/Opus recording that had been saved with an .m4a extension. My upload check was wrong, not the user.
This is a short write-up of what I changed, and why file extensions are the wrong thing to trust.
The bug
My converter (Node + ffmpeg) checked the first bytes of an upload against the file's extension:
// before: decide by extension, then check one signature
case "m4a":
return head.slice(4, 8).toString() === "ftyp";
Real files are renamed all the time: WebM saved as .m4a, an MP3 named .wav, a screen recording shared as .mp4. ffmpeg itself does not care about the extension and detects the format from the content, so my stricter check only rejected good files.
The fix: sniff the content, accept any known media container
export function isKnownMedia(head: Uint8Array): boolean {
const ascii = (s: string, at = 0) => [...s].every((c, i) => head[at + i] === c.charCodeAt(0));
if (head.length < 8) return false;
// ISO base media (MP4, M4A, MOV, 3GP, HEIC): size + box type
if (["ftyp", "moov", "mdat", "free", "skip", "wide"].some((t) => ascii(t, 4))) return true;
if (ascii("ID3") || ascii("RIFF") || ascii("OggS") || ascii("fLaC")) return true;
if (head[0] === 0x1a && head[1] === 0x45 && head[2] === 0xdf && head[3] === 0xa3) return true; // Matroska/WebM
if (head[0] === 0xff && (head[1] & 0xe0) === 0xe0) return true; // MP3/ADTS frame sync
return false;
}
Only binary signatures match. Plain text never does.
Why not just accept everything?
Because ffmpeg is more powerful than it looks. A text file can be an HLS playlist (#EXTM3U) or a concat script (ffconcat version 1.0) that points at other files or URLs. If you hand a user's text file to ffmpeg, it may read local files or fetch remote ones for them. Two cheap defences:
- Reject text. The signature check above never matches a playlist, because it starts with printable characters.
-
Restrict protocols. Pass
-protocol_whitelist filebefore-i, so even a crafted input cannot reach the network.
await run("ffmpeg", ["-y", "-hide_banner", "-loglevel", "error", "-protocol_whitelist", "file", "-i", input, "-vn", "-map", "0:a:0", out]);
Test with real files, not just unit tests
The unit tests were green and the bug still shipped, because I never fed it a real renamed file. I now generate a matrix of real files with ffmpeg (AAC, Opus, MP3 with an ID3 tag, FLAC, WAV, HEVC MOV, VP9 WebM, and each one with the "wrong" extension), upload them to the running service and check the output. It found two more gaps (a tool that rejected audio files in a video tool) within minutes.
Takeaways
- Decide by content, not by name. Let the real decoder be the final judge.
- Allow-list binary signatures; never let text reach tools that interpret text (ffmpeg, ImageMagick, LibreOffice).
- Restrict what the tool may touch (
-protocol_whitelist, no network, size caps, per-IP rate limits). - Keep original filenames only for display, and keep Unicode letters in them (I used
/[^\p{L}\p{N}._\- ]+/gu), or users with Urdu or Arabic file names get underscores.
I use this in a free converter site with audio, video, PDF and image tools: saveanyreel.com. Bug reports with the file type that failed are very welcome.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.