Back to Blog
high SEVERITY7 min read

How unrestricted file upload via extension-only validation happens in Deno/JavaScript and how to fix it

The review image upload handler in this Deno-based app trusted the client-supplied filename extension to decide whether a file was a "safe" image, without ever inspecting the actual file bytes. The fix adds magic-byte signature verification for PNG, JPEG, GIF, and WEBP formats before the file is written to disk, closing the door on disguised executables and malicious payloads.

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

Answer Summary

This is an unrestricted file upload vulnerability (CWE-434) in a Deno/JavaScript image upload endpoint, where `normalizedImageExtension(file.name)` validated only the filename extension, not the file's actual content. The fix adds a `hasValidImageMagic()` function that checks the first bytes of the uploaded file against known image format signatures (PNG, JPEG, GIF, WEBP) and rejects the upload with a 400 response if the magic bytes don't match the claimed extension, before the file is ever written to disk.

Vulnerability at a Glance

cweCWE-434 (Unrestricted Upload of File with Dangerous Type)
fixAdded magic-byte signature verification (`hasValidImageMagic()`) against the declared extension before writing the file
riskMalicious files disguised with image extensions can be written to disk, enabling RCE if the upload path is web-accessible or reprocessed
languageJavaScript (Deno runtime)
root causeFile type was validated using only `file.name` extension, never the actual binary content
vulnerabilityUnrestricted File Upload / Content-Type Spoofing

Introduction

The review-image-handlers.js file handles image uploads for a review workflow in a Deno-based application, but a flaw in reviewImageUploadApi created a real path to remote code execution. The handler pulled the extension from the uploaded file's namefile.name — checked it against an allowlist, and then wrote the raw bytes straight to disk. Nowhere in that flow did the code look at what was actually inside the file.

This is a subtle but dangerous mistake because filenames are entirely attacker-controlled. Any tool capable of crafting a multipart form request — curl, Postman, a custom script — can set file.name to payload.png while the file's actual bytes are a PHP web shell, an ELF binary, or a script the server might later execute. If you're building any upload endpoint in Node.js, Deno, or similar server runtimes, this exact pattern — "trust the extension, skip the content" — is one of the most common and most exploitable mistakes in web application security.

The Vulnerability Explained

Here's the vulnerable logic before the fix, from reviewImageUploadApi:

const extension = normalizedImageExtension(file.name);
if (!extension) return new Response("Unsupported image type.", { status: 400 });

await Deno.mkdir(REVIEW_UPLOAD_DIR, { recursive: true });
const path = join(REVIEW_UPLOAD_DIR, `${crypto.randomUUID()}${extension}`);
await Deno.writeFile(path, new Uint8Array(await file.arrayBuffer()));

normalizedImageExtension(path) (defined lower in the file) simply calls extname(path).toLowerCase() and compares it to a set of accepted image extensions. That's it. There is no inspection of file.type (which is also attacker-supplied and easy to fake), and critically, no inspection of the actual bytes returned by file.arrayBuffer().

Attack scenario: An attacker builds a multipart form POST to the review image upload endpoint. They set the filename field to exploit.png, but the file content is a script or binary payload of their choosing. The check normalizedImageExtension("exploit.png") returns .png, the guard clause passes, and the raw bytes are written to REVIEW_UPLOAD_DIR with a randomized filename and the .png extension attached.

At that point the impact depends on how the upload directory and file are later used:
- If REVIEW_UPLOAD_DIR is served statically or is web-accessible, and the server (or a misconfigured reverse proxy) can be tricked into executing files by content rather than strictly by extension, this becomes direct remote code execution.
- Even without direct execution, a downstream image processor (e.g., an image resizer, thumbnail generator, or metadata parser) that assumes ".png extension = safe PNG data" could be exploited via crafted binary content — a classic confused-deputy scenario.
- At minimum, it's a storage-of-arbitrary-content vulnerability: attackers can use the review system as free hosting for arbitrary files, including malware, disguised as legitimate image uploads.

This maps to CWE-434: Unrestricted Upload of File with Dangerous Type — the root problem is that the "dangerous type" check relied entirely on externally-supplied metadata (file.name) rather than the actual data.

The Fix

The PR adds real content verification via a new function, hasValidImageMagic(), and calls it right after the extension check but before anything is written to disk:

Before:

const extension = normalizedImageExtension(file.name);
if (!extension) return new Response("Unsupported image type.", { status: 400 });

await Deno.mkdir(REVIEW_UPLOAD_DIR, { recursive: true });
const path = join(REVIEW_UPLOAD_DIR, `${crypto.randomUUID()}${extension}`);
await Deno.writeFile(path, new Uint8Array(await file.arrayBuffer()));

After:

const extension = normalizedImageExtension(file.name);
if (!extension) return new Response("Unsupported image type.", { status: 400 });

const bytes = new Uint8Array(await file.arrayBuffer());
if (!hasValidImageMagic(bytes, extension)) return new Response("Invalid image content.", { status: 400 });

await Deno.mkdir(REVIEW_UPLOAD_DIR, { recursive: true });
const path = join(REVIEW_UPLOAD_DIR, `${crypto.randomUUID()}${extension}`);
await Deno.writeFile(path, bytes);

And the new validation function itself:

/** @param {Uint8Array} bytes @param {string} extension */
function hasValidImageMagic(bytes, extension) {
    if (extension === ".png") return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 && bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a;
    if (extension === ".jpg" || extension === ".jpeg") return bytes[0] === 0xff && bytes[1] === 0xd8;
    if (extension === ".gif") return bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
    if (extension === ".webp") return bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
    return false;
}

This closes the gap in two ways:

  1. Content is now checked, not just the name. The function inspects the first few bytes of the actual uploaded file against the well-known binary signatures for PNG (89 50 4E 47 0D 0A 1A 0A), JPEG (FF D8), GIF (GIF ASCII header), and WEBP (the WEBP marker at offset 8, inside the RIFF container). A PHP shell or ELF binary renamed to .png will never produce these bytes, so hasValidImageMagic returns false and the request is rejected with a 400 before any disk write happens.
  2. The extension and content must agree. The function takes the already-normalized extension and checks it against the matching signature — so an attacker can't just embed a valid PNG header inside a file named .exe and rename it .png; the extension the server trusts and the bytes it receives must be consistent for the specific format claimed.

Note that the code also reuses the same bytes buffer (const bytes = new Uint8Array(await file.arrayBuffer())) for both the magic-byte check and the eventual Deno.writeFile call, avoiding a second read of the request body and keeping behavior — and performance — otherwise unchanged, which is why the PR's existing tests continued to pass.

Prevention & Best Practices

  • Never trust file.name or file.type for security decisions. Both are entirely client-controlled and trivially spoofed in any HTTP client.
  • Verify file content, not filenames. Check magic bytes/file signatures (or use a well-audited library like file-type for Node/Deno) to confirm the binary structure matches the claimed format.
  • Store uploads outside web-accessible directories whenever possible, and serve them through a handler that sets safe Content-Type and Content-Disposition headers rather than letting a static file server guess based on extension.
  • Randomize stored filenames (as this code already does with crypto.randomUUID()) to prevent path or extension-based attacks against downstream consumers.
  • Re-encode or re-process images where feasible (e.g., decode and re-save through an image library) — this strips anything that isn't valid image data, providing defense in depth beyond a magic-byte check.
  • Add automated tests that upload files with mismatched extensions and content to ensure the rejection path is exercised in CI.
  • Reference the OWASP guidance directly: the OWASP File Upload Cheat Sheet covers this exact class of issue in depth.

Key Takeaways

  • reviewImageUploadApi previously trusted file.name alone via normalizedImageExtension() — filenames are attacker-controlled and must never be a security boundary.
  • The fix introduces hasValidImageMagic(), which checks real binary signatures for PNG, JPEG, GIF, and WEBP before any bytes touch Deno.writeFile.
  • Content validation now happens before Deno.mkdir/Deno.writeFile, ensuring rejected uploads never reach the REVIEW_UPLOAD_DIR filesystem.
  • The buffer read via file.arrayBuffer() is captured once into bytes and reused for both validation and the write, preserving performance while adding the security check.
  • Extension allowlists are a weak first filter, not a substitute for content inspection — pair them together, as this fix now does.

How Orbis AppSec Detected This

  • Source: The file.name field and raw file bytes from the multipart upload in reviewImageUploadApi, an endpoint reachable by any client submitting a form to the review image upload API.
  • Sink: Deno.writeFile(path, ...) in src/ui/workspace/routes/api/review-image-handlers.js:24, which persists unverified content to disk under the extension the attacker chose.
  • Missing control: No verification that the file's actual binary content matched an expected image format signature — validation relied solely on normalizedImageExtension(file.name).
  • CWE: CWE-434 (Unrestricted Upload of File with Dangerous Type).
  • Fix: Added hasValidImageMagic(bytes, extension) to verify PNG/JPEG/GIF/WEBP magic bytes and reject mismatched or malicious content with a 400 response before the file is written to disk.

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

Extension-based file validation feels safe because it's simple, but it only checks a label the attacker fully controls. The fix in review-image-handlers.js demonstrates the right layered approach: keep the extension allowlist as a first-pass filter, but back it up with a real content check — magic-byte signature verification — before the file is ever committed to disk. It's a small, focused change (one new function and two added lines in the handler) that eliminates an entire class of disguised-file attacks against this upload endpoint. If your application accepts file uploads anywhere, it's worth auditing whether you're making the same assumption this code once did.

References

Frequently Asked Questions

What is unrestricted file upload?

It's a vulnerability where an application accepts and stores uploaded files without properly validating their actual content type, allowing attackers to upload disguised malicious files such as scripts or executables.

How do you prevent unrestricted file upload in JavaScript/Deno?

Validate the file's actual binary content (magic bytes/file signatures) in addition to the extension, store uploads outside web-accessible directories, use randomized filenames, and never trust client-supplied MIME types or names.

What CWE is unrestricted file upload?

It maps to CWE-434 (Unrestricted Upload of File with Dangerous Type), often paired with CWE-646 (Reliance on File Name or Extension of Externally-Supplied File).

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

No. Extensions are client-controlled metadata that can be trivially spoofed; the file's actual byte content must be verified against expected signatures.

Can static analysis detect unrestricted file upload?

Yes, tools like Semgrep and CodeQL can flag file upload handlers that skip content verification, and Orbis AppSec's multi-agent scanner flagged this exact pattern in `review-image-handlers.js`.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #34

Related Articles

critical

How a vulnerable websocket-driver dependency happens in Node.js lockfiles and how to fix it

A Trivy scan flagged `websocket-driver@0.7.4` in this repository's `bun.lock` as affected by CVE-2026-54466, a critical issue in a WebSocket protocol handler that parses untrusted HTTP upgrade requests and frame data. The fix upgrades the package to `0.7.5` and adds an explicit `websocket-driver` entry to the lockfile's override block so every transitive consumer — webpack-dev-server, sockjs, faye-websocket — resolves to the patched build instead of the pinned vulnerable one.

high

How Dependabot Missing Cooldown Periods Enable Supply Chain Attacks and How to Fix It

A critical security vulnerability in `.github/dependabot.yml` was exposing a Node.js library to supply chain attacks by automatically updating to newly published packages without a safety delay. By adding a 7-day cooldown period to each package ecosystem configuration, the project now protects against malicious or unstable package versions that could affect downstream consumers.

high

How Exponential-Time Complexity Causes Denial of Service in brace-expansion and How to Fix It

A critical vulnerability in brace-expansion versions 1.1.13 and earlier allowed attackers to cause denial of service through crafted brace pattern inputs. The fix upgrades to patched versions 1.1.16, 2.1.2, and 5.0.7, eliminating the exponential-time complexity that made exploitation possible.

high

How Missing Dependabot Cooldown Periods Enable Supply Chain Attacks in CI/CD Pipelines and How to Fix Them

We fixed a high-severity supply chain security gap in `.github/dependabot.yml` where missing cooldown periods allowed immediate adoption of newly published packages. The fix adds `cooldown: default-days: 7` to all package ecosystems, creating a critical security buffer against typosquatting and malicious dependency attacks.

high

How Dependabot Missing Cooldown Vulnerability Happens in GitHub Actions and How to Fix It

Dependabot configurations without cooldown periods can automatically propose updates from newly published packages within hours—potentially including malicious or unstable versions. This vulnerability in `.github/dependabot.yml` was fixed by adding a `cooldown` block with `default-days: 7` to delay updates and allow time for community vetting.

high

How dependabot-missing-cooldown happens in GitHub Actions configuration and how to fix it

A high-severity vulnerability in `.github/dependabot.yml` left this repository vulnerable to supply chain attacks through immediate adoption of newly published packages. The fix adds a mandatory 7-day cooldown period to all three package ecosystems, preventing automatic updates to potentially malicious or unstable dependencies before they can be vetted by the community.