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

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.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #34

Related Articles

high

CWE-778: Tribute WebHookHandler Logs No Subscription Events

The Tribute payment webhook handler verified request signatures and then mutated subscription state — granting new subscriptions and cancelling existing ones — while logging nothing on success. Only error paths and a no-op test hook produced log lines, so a forged or replayed `NewSubscription` event that passed signature verification left no record of which `TelegramUserID` was affected. The fix adds structured `slog.Info` records at signature verification and at both state-changing branches.

medium

path-to-regexp 0.1.12 DoS: Catastrophic Backtracking on Malformed URL

path-to-regexp version 0.1.12 contains a regular expression engine vulnerability where maliciously crafted URL parameters trigger exponential backtracking, causing CPU exhaustion and service unavailability. Upgrading to version 0.1.13 eliminates the vulnerable pattern from the parsing logic.

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