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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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.


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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #3

Related Articles

critical

CVE-2026-59873: node-tar 7.5.11 DoS via Crafted Gzip Bomb

node-tar versions 7.5.11 through 7.5.18 are vulnerable to a denial-of-service attack through maliciously crafted gzip archives that decompress to disproportionately large sizes. An attacker can exploit this to exhaust memory and CPU resources by submitting a small, highly compressed archive that expands beyond configured limits during extraction.

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.

critical

No Rate Limit on /api/uploads/presign Enables DoS

The `/api/uploads/presign` endpoint accepted unlimited concurrent requests to generate storage presigned URLs, giving an attacker a free lever to exhaust storage-provider quotas and server resources. The fix adds an `express-rate-limit` middleware capping each client to 30 requests per minute on that route.