Back to Blog
critical SEVERITY8 min read

How Unrestricted File Upload happens in Node.js/Express and how to fix it

A critical unrestricted file upload vulnerability was discovered in `mainsystem/routes/admin/profile.js`, where the avatar upload endpoint accepted any file type without validation. An authenticated attacker could upload a malicious server-side script to a web-accessible directory and execute arbitrary code on the server. The fix adds MIME type filtering, an allowlist of safe image formats, and a 2 MB file size limit to the multer middleware.

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is an Unrestricted File Upload vulnerability (CWE-434) in a Node.js/Express application using the multer middleware. The `/admin/profile/upload-avatar` endpoint in `mainsystem/routes/admin/profile.js` accepted any file without validating MIME type, extension, or size, allowing an attacker to upload a malicious `.php` or `.js` file to a web-accessible directory. The fix adds a `fileFilter` function to multer that allows only `image/jpeg`, `image/png`, `image/gif`, and `image/webp` MIME types, and enforces a 2 MB file size cap via the `limits` option.

Vulnerability at a Glance

cweCWE-434
fixAdded an allowlist `fileFilter` for image MIME types and a 2 MB `limits.fileSize` cap to the multer configuration
riskRemote code execution via malicious file upload to a web-accessible directory
languageJavaScript (Node.js/Express)
root causemulter was configured with `storage` only — no `fileFilter` or `limits` — allowing any file type and size
vulnerabilityUnrestricted File Upload

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:

  1. The exploit path is short — three steps: authenticate, upload, request the file.
  2. The uploaded files are web-accessible — stored in /user_images/, not behind an access-controlled path.
  3. 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.
  4. 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 file
  • cb(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 a fileFilter and limits configuration.
  • The /admin/profile/upload-avatar endpoint 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-Type header 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 file object 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/ in mainsystem/middlewares/uploadAvatar.js, with no intervening type or size check.
  • Missing control: No fileFilter function to validate file.mimetype against an allowlist, and no limits.fileSize cap — meaning any file type and any file size was accepted unconditionally.
  • CWE: CWE-434 — Unrestricted Upload of File with Dangerous Type.
  • Fix: Added a fileFilter allowlist for four safe image MIME types and a 2 MB fileSize limit to the multer() constructor call in uploadAvatar.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.


References

Frequently Asked Questions

What is an unrestricted file upload vulnerability?

An unrestricted file upload vulnerability occurs when a server accepts uploaded files without validating their type, size, or content, potentially allowing attackers to upload malicious scripts that can be executed server-side.

How do you prevent unrestricted file upload in Node.js?

Use multer's `fileFilter` option to allowlist safe MIME types (e.g., `image/jpeg`, `image/png`), enforce file size limits with `limits.fileSize`, and store uploaded files outside the web root or use a CDN/object storage service.

What CWE is unrestricted file upload?

Unrestricted file upload is classified as CWE-434: Unrestricted Upload of File with Dangerous Type.

Is checking the file extension enough to prevent unrestricted file upload?

No. File extensions can be spoofed by renaming a malicious file. You should validate the MIME type reported by the client, and ideally also inspect the file's magic bytes (actual binary content) for a defense-in-depth approach.

Can static analysis detect unrestricted file upload vulnerabilities?

Yes. Tools like Semgrep, ESLint security plugins, and AI-powered scanners like Orbis AppSec can detect multer configurations that lack `fileFilter` or `limits`, flagging them as potentially dangerous upload endpoints.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #3

Related Articles

high

How Sensitive Data Exposure happens in Zotero plugins and how to fix it

A high-severity data exposure vulnerability in `Zotero.ts` automatically transmitted complete document metadata—including private notes, attachment paths, and tags—to external LLM services without user consent. The fix replaces broad `item.toJSON()` serialization with explicit field selection, sending only essential bibliographic data.

high

How missing dependency update cooldowns happen in GitHub Dependabot configurations and how to fix it

A semgrep scan flagged `.github/dependabot.yml` for lacking a cooldown period, meaning Dependabot would immediately propose updates to brand-new package versions across npm, Bundler, and Docker ecosystems. The fix adds a `cooldown: default-days: 7` block to every `package-ecosystem` entry, forcing a one-week waiting period before newly published releases are considered — reducing exposure to malicious or unstable package drops.

high

How dependabot-missing-cooldown happens in GitHub Actions/Node.js and how to fix it

The repository's `.github/dependabot.yml` had no cooldown period configured, meaning Dependabot could immediately propose updates to newly published package versions with zero time for the community to flag malware or instability. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` ecosystems, forcing a 7-day waiting period before new releases are surfaced as update PRs.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

critical

How Unbounded WebSocket Message Handling Causes Resource Exhaustion in Node.js and How to Fix It

The WebSocketCrossServerAdapter class in a popular Node.js WebSocket library lacked any rate limiting on inbound messages, allowing attackers to flood Redis nodes and WebSocket servers with high-volume traffic. The fix introduces a configurable `rateLimit` option that caps messages per connection per second, preventing resource exhaustion while preserving legitimate functionality.

critical

How Remote Code Execution Happens in Handlebars Template Compilation and How to Fix It

CVE-2026-33937 is a critical remote code execution vulnerability in Handlebars.js that allows attackers to execute arbitrary code by passing maliciously crafted Abstract Syntax Tree (AST) objects to the compile() function. The vulnerability was patched in version 4.7.9, and we've upgraded to protect against this threat vector.