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:
- 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 (GIFASCII header), and WEBP (theWEBPmarker at offset 8, inside the RIFF container). A PHP shell or ELF binary renamed to.pngwill never produce these bytes, sohasValidImageMagicreturnsfalseand the request is rejected with a 400 before any disk write happens. - 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
.exeand 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.nameorfile.typefor 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-typefor 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-TypeandContent-Dispositionheaders 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
reviewImageUploadApipreviously trustedfile.namealone vianormalizedImageExtension()— 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 touchDeno.writeFile. - Content validation now happens before
Deno.mkdir/Deno.writeFile, ensuring rejected uploads never reach theREVIEW_UPLOAD_DIRfilesystem. - The buffer read via
file.arrayBuffer()is captured once intobytesand 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.namefield and raw file bytes from the multipart upload inreviewImageUploadApi, an endpoint reachable by any client submitting a form to the review image upload API. - Sink:
Deno.writeFile(path, ...)insrc/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.