How File Type Validation Bypass Happens in Node.js Image Processing and How to Fix It
Introduction
In the image processing library, we discovered a critical file type validation bypass in src/js/insert.js at line 50. The insertImagesFromPaths function accepted user-supplied files for image processing, but it validated them based solely on file extension—a trivially spoofable property. An attacker could rename a malicious executable as malware.exe.png, and the application would process it as a valid image file without ever verifying the actual content. This vulnerability affects any downstream consumer of this Node.js library who processes user-uploaded or user-provided files.
The vulnerability was particularly dangerous because:
- File extensions are user-controlled: A simple rename bypasses the check
- No content verification: The code never examined the actual bytes of the file
- Production code path: This wasn't isolated to tests—it was in the active codebase
- Drag-and-drop vulnerability: The exploitation vector was trivial (user just drags a renamed file)
The Vulnerability Explained
The Problematic Code
The original insertImagesFromPaths function relied on a simple extension check:
const mimeFor = (ext) => ({
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp',
svg: 'image/svg+xml'
}[ext] || 'image/png');
This function extracted the file extension and mapped it to a MIME type. If the extension was png, it assumed the file was a PNG image. There was no verification that the file's actual content matched this assumption.
Why This Is Dangerous
File extensions are metadata stored in the filesystem—they're not part of the file's actual content. An attacker can rename any file with any extension they want:
# Attacker renames a Windows executable to look like a PNG
mv malware.exe malware.png
# Or more cleverly:
mv backdoor.exe innocent-image.png.exe # Double extension
When the application processes this file, it checks the extension, sees .png, and treats it as an image. The actual binary content—which contains executable code—is never examined.
Attack Scenario
- Attacker creates a malicious payload: Perhaps a Node.js script, a binary executable, or a crafted payload designed to exploit the image processing pipeline
- Attacker renames the file: Changes
payload.exetovacation-photo.png - User is tricked into importing: Via drag-and-drop or file upload dialog, the user imports the file thinking it's an innocent image
- Application processes the malicious file: Because the extension is
.png, theinsertImagesFromPathsfunction accepts it - Exploitation occurs: Depending on how the file is processed downstream, the malicious content could be executed, parsed, or used for further attacks
The vulnerability is likely exploitable because the file buffer is passed directly to image processing logic without any verification of actual file type.
Real-World Impact
For a library like this, the impact cascades to all downstream consumers:
- Any application using this library that accepts user-provided image files
- Any Electron/Tauri desktop application with drag-and-drop image import
- Any web application that processes user uploads through this library
An attacker could potentially achieve arbitrary code execution or inject malicious content into the application's processing pipeline.
The Fix
The fix implements magic byte verification—a technique that examines the binary signature at the start of every file to determine its true type, independent of the filename.
What Changed
A new function looksLikeImage() was added to verify file content:
/** Sniff the real file type from its magic bytes so a renamed executable can't pass as an image. */
function looksLikeImage(buf, ext) {
const b = new Uint8Array(buf);
if (ext === 'png') return b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47;
if (ext === 'jpg' || ext === 'jpeg') return b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF;
if (ext === 'gif') return b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46;
if (ext === 'bmp') return b[0] === 0x42 && b[1] === 0x4D;
if (ext === 'webp') return b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46;
if (ext === 'svg') return /^\s*(<\?xml|<svg)/i.test(new TextDecoder().decode(b.slice(0, 256)));
return false;
}
How Magic Bytes Work
Every file format has a unique binary signature—the "magic bytes" at the beginning of the file:
| Format | Magic Bytes (Hex) | Hex Representation |
|---|---|---|
| PNG | 89 50 4E 47 | \x89PNG |
| JPEG | FF D8 FF | ÿØÿ |
| GIF | 47 49 46 | GIF |
| BMP | 42 4D | BM |
| WebP | 52 49 46 46 | RIFF (followed by WEBP) |
| SVG | <?xml or <svg |
Text-based XML |
These signatures are part of the file format specification and cannot be faked without creating a valid file of that type.
The Security Improvement
Now when a file is processed:
- Extension is checked: Is it
.png? - Magic bytes are verified: Do the first 4 bytes equal
0x89 0x50 0x4E 0x47(PNG signature)? - Both must match: Only if both the extension AND the magic bytes confirm it's a PNG will the file be processed
This means:
- ✅ A genuine PNG file renamed to .jpg will be rejected (extension doesn't match magic bytes)
- ✅ A malicious executable renamed to .png will be rejected (magic bytes don't match PNG signature)
- ✅ A genuine PNG file named photo.png will be accepted (both extension and magic bytes match)
Before and After
Before (Vulnerable):
// Only checks extension
const ext = filename.split('.').pop().toLowerCase();
const mime = mimeFor(ext); // Trust the extension blindly
processImage(buffer, mime); // Process whatever file this is
After (Secure):
// Checks both extension AND content
const ext = filename.split('.').pop().toLowerCase();
if (!looksLikeImage(buffer, ext)) {
throw new Error(`File extension .${ext} does not match actual file type`);
}
const mime = mimeFor(ext);
processImage(buffer, mime); // Now we know this is really an image
Prevention & Best Practices
1. Always Verify File Content, Never Trust Extensions
Extensions are user-controlled metadata. Always verify the actual file content:
// ❌ BAD: Only checks extension
const isImage = filename.endsWith('.png');
// ✅ GOOD: Checks magic bytes
const isImage = looksLikeImage(fileBuffer, extension);
2. Use Dedicated File Type Libraries
For production code, consider using battle-tested libraries:
// Using file-type library
import { fileTypeFromBuffer } from 'file-type';
const type = await fileTypeFromBuffer(buffer);
if (type?.mime !== 'image/png') {
throw new Error('File is not a valid PNG');
}
3. Validate at Multiple Layers
- Client-side: Quick UX feedback (but not security)
- Server-side: Definitive validation using magic bytes
- Content verification: Ensure the file can actually be processed as claimed
// Multi-layer validation
if (!filename.endsWith('.png')) return false; // Quick check
if (!looksLikeImage(buffer, 'png')) return false; // Magic bytes
try {
const image = await sharp(buffer).metadata(); // Can we actually process it?
return image.format === 'png';
} catch (e) {
return false;
}
4. Use Static Analysis to Catch This Pattern
Configure your security tools to flag:
- File operations that check only extension
- Buffer processing without type validation
- User-controlled filenames used directly for type determination
Semgrep rule pattern:
- id: nodejs-extension-only-validation
pattern: |
filename.split('.').pop()
...
process$FUNC(buffer, ...)
message: File type validated by extension only; use magic bytes instead
5. Reference Security Standards
- CWE-434: Unrestricted Upload of File with Dangerous Type
- CWE-345: Insufficient Verification of Data Authenticity
- OWASP A4:2021: Insecure Deserialization (related: trusting untrusted data formats)
Key Takeaways
-
Extension-only validation is security theater: File extensions are trivially spoofed by renaming. Always verify actual file content using magic bytes.
-
The
looksLikeImage()function implements a defense-in-depth check: It verifies that both the file extension AND the binary signature match known image formats, preventing renamed executables from passing validation. -
Magic bytes are format-specific and cannot be faked: PNG files must start with
0x89 0x50 0x4E 0x47; JPEG files must start with0xFF 0xD8 0xFF. These are part of the file format spec, not user-controlled. -
This vulnerability affects downstream library consumers: Any application using this library for image processing inherited this risk until the fix was applied.
-
Drag-and-drop interfaces are attack vectors: User-friendly file import features are prime targets for this type of attack, making robust validation essential.
How Orbis AppSec Detected This
- Source: User-controlled filename and file buffer from drag-and-drop or file upload in
insertImagesFromPaths() - Sink: The
mimeFor()function call at line 50 insrc/js/insert.jsthat maps extension to MIME type without verifying file content - Missing control: No validation that the actual file bytes match the declared image format
- CWE: CWE-434 (Unrestricted Upload of File with Dangerous Type)
- Fix: Added
looksLikeImage()function that verifies magic bytes before processing, ensuring the file's actual binary content matches the claimed image type
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
File type validation is a critical security control for any application that processes user-provided files. Relying solely on file extensions creates a false sense of security and opens the door to attacks where malicious files are disguised as harmless ones.
The fix in src/js/insert.js demonstrates the correct approach: verify file content using magic bytes, not just filename extensions. This simple but powerful technique ensures that only genuine image files are processed, regardless of what an attacker names the file.
As developers, we must remember that user input is untrusted—including filenames. By implementing magic byte verification and multi-layer validation, we can prevent attackers from bypassing our security controls through simple file renaming tricks.
For any library or application that processes user-supplied files, implement this fix today and audit your codebase for similar extension-only validation patterns.
References
- CWE-434: Unrestricted Upload of File with Dangerous Type
- CWE-345: Insufficient Verification of Data Authenticity
- OWASP: File Upload Cheat Sheet
- File Signatures (Magic Bytes) Reference
- file-type NPM Library Documentation
- Semgrep Rule: File Type Validation
- fix: the insertimagesfrompaths function validates fi... in insert.js