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 Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A high-severity misconfiguration in `.github/dependabot.yml` left this Node.js library without a cooldown period, meaning Dependabot would immediately propose updates to newly published packages — including potentially malicious or unstable ones. The fix adds a `cooldown` block with `default-days: 7` to both the `npm` and `github-actions` package ecosystem entries, introducing a mandatory 7-day waiting period before any new package version is surfaced as an update candidate.

critical

How CSRF Protection Failures Happen in FastAPI and How to Fix Them

A critical CORS misconfiguration in `backend/main.py` allowed cookies to be sent alongside wildcard-origin requests, violating the CORS specification and opening the door to cross-site request forgery attacks. The fix conditionally disables `allow_credentials` when the allowed origins list contains a wildcard, bringing the configuration into compliance with browser security rules. This change closes a subtle but dangerous gap that could have let attackers on sibling subdomains forge authenticate

critical

How Missing Rate Limiting Happens in Node.js SSE Handlers and How to Fix It

A critical missing rate-limiting control in `src/sse/handlers/chat.js` allowed any caller to flood the SSE chat endpoint with unlimited requests, risking server resource exhaustion, denial of service, and runaway AI provider API costs. The fix introduces a per-IP sliding-window rate limiter that caps requests at 60 per minute and returns HTTP 429 on violations. Because the endpoint was publicly reachable and only validated API keys — not request frequency — exploitation required nothing more tha

medium

How Denial of Service via Catastrophic Backtracking happens in Node.js and how to fix it

CVE-2026-4867 is a Denial of Service vulnerability in path-to-regexp 0.1.12 where malformed URL parameters can trigger catastrophic backtracking in the library's regular expression engine, allowing an attacker to hang or crash a Node.js application with a single crafted request. The fix upgrades path-to-regexp to version 0.1.13, which patches the vulnerable regex patterns. This change was applied via a package-level override to ensure the patched version is used throughout the entire dependency

high

How Denial of Service via Exponential-Time Complexity happens in Node.js and how to fix it

CVE-2026-13149 is a high-severity Denial of Service vulnerability in the `brace-expansion` npm package, where crafted input strings trigger exponential-time processing that can freeze or crash a Node.js application. The fix upgrades `brace-expansion` from `2.0.2` to `2.1.4` and `minimatch` from `5.1.6` to `5.1.9`, along with npm `overrides` to ensure the patched versions are used throughout the entire dependency tree.

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript