How Unrestricted File Upload Happens in Node.js/Express and How to Fix It
The File Upload Endpoint That Trusted Everything
The /admin/profile/upload-avatar endpoint in mainsystem/routes/admin/profile.js was designed to let administrators update their profile pictures — a routine feature in virtually every web application. But a single missing configuration in mainsystem/middlewares/uploadAvatar.js turned this convenience feature into a critical remote code execution vector.
The root cause was deceptively simple: the multer middleware was initialized with only a storage configuration, and nothing else:
// BEFORE — the vulnerable configuration
const upload = multer({ storage });
No file type check. No size limit. No content inspection. Any file — a JPEG, a PHP webshell, a Node.js script — would be accepted, stored in the /user_images/ directory with its original filename, and made accessible over the web.
This post walks through exactly how that vulnerability works, how it was exploited in theory, and the precise code change that closed the gap.
The Vulnerability Explained
What Was Missing in uploadAvatar.js
The multer library is the de facto standard for handling multipart file uploads in Express applications. It is powerful and flexible, but it ships with a philosophy of "accept everything unless told otherwise." The developer's job is to explicitly restrict what is allowed.
In the vulnerable version of mainsystem/middlewares/uploadAvatar.js, the configuration was:
// BEFORE — no fileFilter, no limits
const storage = multer.diskStorage({
// ... destination and filename logic
});
const upload = multer({ storage });
module.exports = upload;
The multer({ storage }) call tells multer: "Store files using this storage engine." It says nothing about which files are acceptable. The result is that multer will happily write any uploaded file to disk.
The Attack Scenario
An authenticated admin — or an attacker who has compromised an admin account — sends a POST request to /admin/profile/upload-avatar with a multipart body containing a file named shell.php:
POST /admin/profile/upload-avatar HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary
------WebKitFormBoundary
Content-Disposition: form-data; name="avatar"; filename="shell.php"
Content-Type: image/jpeg
<?php system($_GET['cmd']); ?>
------WebKitFormBoundary--
Notice the attacker sets Content-Type: image/jpeg in the part header — but the actual content is PHP code. Because there was no fileFilter to validate the MIME type against the file's real content, multer accepts the upload.
The file lands in /user_images/shell.php. Since /user_images/ is a web-accessible directory and the web server (or a misconfigured PHP-FPM instance) can execute .php files in that path, the attacker now visits:
https://example.com/user_images/shell.php?cmd=id
And receives:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Remote code execution achieved. From here, an attacker can read environment variables, exfiltrate secrets, pivot to internal services, or establish persistence.
Why This Is Rated Critical
This vulnerability is classified as CWE-434: Unrestricted Upload of File with Dangerous Type and carries a critical severity rating because:
- The exploit path is short — three steps: authenticate, upload, request the file.
- The uploaded files are web-accessible — stored in
/user_images/, not behind an access-controlled path. - The original filename is preserved — the attacker controls the file extension, making it trivial to upload a file that a server-side interpreter will execute.
- No server-side content inspection — the application never reads the file's magic bytes to confirm it is actually an image.
The Fix
What Changed in uploadAvatar.js
The fix was applied entirely in mainsystem/middlewares/uploadAvatar.js. Here is the complete before/after comparison:
Before (vulnerable):
const upload = multer({ storage });
After (fixed):
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const fileFilter = (req, file, cb) => {
if (ALLOWED_MIME_TYPES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files (JPEG, PNG, GIF, WEBP) are allowed'), false);
}
};
const upload = multer({ storage, fileFilter, limits: { fileSize: 2 * 1024 * 1024 } });
Three concrete improvements were made:
1. MIME Type Allowlist via fileFilter
The fileFilter function is multer's hook for accepting or rejecting a file before it is written to disk. It receives the Express req object, the file descriptor (which includes file.mimetype as reported by the client), and a callback cb.
cb(null, true)→ accept the filecb(new Error(...), false)→ reject the file and surface an error
The allowlist ALLOWED_MIME_TYPES contains exactly four safe image MIME types: image/jpeg, image/png, image/gif, and image/webp. Any upload whose reported MIME type is not in this list is rejected with a descriptive error before a single byte is written to disk.
2. File Size Limit via limits
limits: { fileSize: 2 * 1024 * 1024 } // 2 MB
The limits.fileSize option caps uploads at 2 MB (2,097,152 bytes). This prevents:
- Denial-of-service attacks via enormous file uploads that exhaust disk space or memory.
- ZIP bomb / image bomb attacks where a small upload expands to gigabytes when processed.
3. Behavior Preservation
Valid image uploads — a JPEG or PNG profile picture under 2 MB — pass through the fileFilter unchanged. The fix only tightens the boundary around untrusted input; it does not alter how accepted files are stored or served.
Prevention & Best Practices
Defense-in-Depth for File Uploads
The MIME type check added in this fix is a strong first layer, but a robust file upload implementation should layer multiple controls:
Validate MIME Type (Client-Reported) — Done ✅
The fileFilter function now checks file.mimetype. This stops casual abuse and misconfigured clients.
Validate Magic Bytes (Server-Side Content Inspection)
The file.mimetype value comes from the Content-Type header in the multipart body — the client controls it. For higher assurance, inspect the file's actual binary signature after upload using a library like file-type:
import { fileTypeFromBuffer } from 'file-type';
const buffer = fs.readFileSync(uploadedFilePath);
const type = await fileTypeFromBuffer(buffer);
if (!type || !['image/jpeg', 'image/png', 'image/gif', 'image/webp'].includes(type.mime)) {
fs.unlinkSync(uploadedFilePath); // delete the suspicious file
throw new Error('File content does not match an allowed image type');
}
Rename Uploaded Files
Never preserve the original filename. Generate a random UUID or hash-based name and assign a safe extension:
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const safeName = `${crypto.randomUUID()}${ext}`;
cb(null, safeName);
}
This prevents path traversal attacks (e.g., ../../etc/cron.d/backdoor) and removes the attacker's control over the file extension.
Store Files Outside the Web Root
Ideally, uploaded files should be stored in a directory that is not directly served by the web server (e.g., /var/uploads/ instead of public/user_images/). Serve them through an Express route that streams the file after validating the requester's authorization:
app.get('/avatars/:id', authenticate, (req, res) => {
const filePath = path.join('/var/uploads/avatars', req.params.id);
res.sendFile(filePath);
});
Use Cloud Object Storage
For production applications, consider storing uploads in AWS S3, Google Cloud Storage, or Azure Blob Storage. These services isolate uploaded content from your application server entirely, eliminating the risk of server-side execution.
Relevant Standards
- OWASP Top 10 A04:2021 — Insecure Design (file upload without validation)
- OWASP File Upload Cheat Sheet — comprehensive guidance on safe upload handling
- CWE-434 — Unrestricted Upload of File with Dangerous Type
- CWE-22 — Path Traversal (related risk when original filenames are preserved)
Key Takeaways
multer({ storage })alone is never safe for user-facing upload endpoints — always pair it with afileFilterandlimitsconfiguration.- The
/admin/profile/upload-avatarendpoint stored files in a web-accessible/user_images/directory — making any accepted file immediately reachable by an HTTP request, which amplifies the impact of missing type validation. - Client-reported MIME types can be spoofed — the
Content-Typeheader in a multipart upload is attacker-controlled; treat it as a hint, not a guarantee, and layer magic byte inspection on top. - File size limits are not optional — the
limits: { fileSize: 2 * 1024 * 1024 }addition prevents both DoS via large uploads and certain image-processing exploits. - Authenticated-only does not mean safe — this endpoint required admin authentication, yet the vulnerability was still rated critical because insider threats, session hijacking, and credential compromise are all realistic attack paths.
How Orbis AppSec Detected This
- Source: The
fileobject in the multer upload pipeline, populated from the attacker-controlled multipart HTTP request body at/admin/profile/upload-avatar. - Sink:
multer.diskStorage()writing the uploaded file to/user_images/inmainsystem/middlewares/uploadAvatar.js, with no intervening type or size check. - Missing control: No
fileFilterfunction to validatefile.mimetypeagainst an allowlist, and nolimits.fileSizecap — meaning any file type and any file size was accepted unconditionally. - CWE: CWE-434 — Unrestricted Upload of File with Dangerous Type.
- Fix: Added a
fileFilterallowlist for four safe image MIME types and a 2 MBfileSizelimit to themulter()constructor call inuploadAvatar.js.
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
The unrestricted file upload in mainsystem/middlewares/uploadAvatar.js is a textbook example of how a single missing configuration option can escalate a routine feature into a critical security vulnerability. The multer library did exactly what it was told — store the file — because nobody told it what files were acceptable.
The fix is elegant in its simplicity: a 10-line fileFilter function and a limits object transform an open door into a well-guarded gate. Valid profile picture uploads continue to work without disruption; everything else is rejected before it touches disk.
For developers building file upload features in Node.js: treat multer({ storage }) as an incomplete configuration. Always define your allowlist, always set size limits, always consider where uploaded files live relative to your web root, and always rename files on the server side. These four habits, applied consistently, eliminate the entire class of unrestricted file upload vulnerabilities.