Safeguard
Application Security

Secure file uploads in Node.js and Fastify

CWE-22 path traversal climbed three spots to #5 on the 2024 CWE Top 25. Here's how to validate, store, and scan Fastify uploads without trusting the client.

Safeguard Research Team
Research
6 min read

Every file upload endpoint accepts two pieces of information it should never trust: the Content-Type header and the filename, both of which are set entirely by the client and can be forged in a single line of curl. CWE-22, the path-traversal weakness this enables, rose from #8 to #5 on MITRE's CWE Top 25 Most Dangerous Software Weaknesses between the 2023 and 2024 editions — one of the largest rank jumps of any weakness on the list that year. OWASP's File Upload Cheat Sheet calls the underlying problem out directly, warning that extension and MIME-type checks based on client-supplied metadata provide no real security guarantee. The bug class keeps showing up in production code that looked fine on review: AdonisJS shipped a path-traversal advisory in its multipart file-handling core (GHSA-gvq6-hvvp-h34h), and the widely used tar-fs package carried CVE-2024-12905 (GHSA-pq67-2wwv-3xjx), a path-traversal and symlink-following flaw triggered during archive extraction. Both bugs trace back to the same root cause — a server-side write operation trusting a path that originated with the uploader. Fastify's own @fastify/multipart plugin ships limits options for file size, file count, and field count, defaulting to a 1 MB fileSize cap and a 1,000-part cap, specifically because unbounded multipart parsing is a known denial-of-service vector. This post walks through the four control points — content validation, filename/path handling, size limits, and malware scanning — that a Fastify upload endpoint needs to get right, in the order an attacker would try to break them.

Why can't you trust the Content-Type header or file extension?

You can't trust them because both are values the client sets in the multipart request, and Fastify (or any framework) simply reports what it was told — it does not independently verify the file's actual format. An attacker can rename a PHP web shell to photo.jpg and set Content-Type: image/jpeg, and every naive check based on those two fields passes. OWASP's File Upload Cheat Sheet recommends validating the file's actual signature — the "magic bytes" at the start of the binary content, such as 89 50 4E 47 for PNG or 25 50 44 46 for PDF — as a stronger server-side check than trusting client metadata. Critically, OWASP also flags that magic-byte validation is not a complete solution on its own: polyglot files (valid as two formats simultaneously) and files with a legitimate signature prepended to malicious content can pass a signature check while still containing dangerous payloads. Signature validation is a necessary layer, not a sufficient one — it has to be combined with the filename, storage, and scanning controls below, not deployed as the sole gate.

How should filenames and storage paths be handled to prevent traversal?

They should never be handled at all — the safest pattern is to discard the client-supplied filename entirely and generate a random, server-side identifier (a UUID or a hash) for the stored file, keeping the original name only as metadata in a database record if it's needed for display. This sidesteps CWE-22 outright: there's no user-controlled string ever concatenated into a filesystem path, so ../../etc/passwd-style payloads have nothing to attach to. If a use case genuinely requires preserving structure from an upload (for example, extracting a zip or tar archive server-side), every extracted path must be resolved with path.resolve() and explicitly checked that it still starts within the intended base directory before any write — the exact control that was missing in the tar-fs CVE-2024-12905 case, where archive entries could escape the extraction target via crafted paths and symlinks. Store uploaded files outside the web root and outside any directory served statically, so that even a successful write can't be requested back as an executable script.

What size and rate limits actually stop a multipart DoS?

The limits that matter are the ones @fastify/multipart exposes directly on the plugin options: fileSize (max bytes per file), files (max number of file parts), parts (max combined fields plus files), and fields/fieldSize (max non-file form fields and their size) — all of which exist because an attacker can otherwise send a multipart body that never stops streaming, or hundreds of file parts in one request, and exhaust memory or disk before your application code ever runs. The plugin's own defaults, a 1,048,576-byte fileSize cap and a 1,000-part cap, exist precisely so a handler that forgets to configure limits explicitly still isn't wide open. Most real applications need to raise fileSize for legitimate use cases, but every raise should be a deliberate decision tied to the largest file type the endpoint actually needs, and enforced again at the reverse proxy or load balancer in front of Node, since a limit that only exists inside application code still lets an oversized request consume bandwidth and connection slots on the way in. Pair per-file limits with a per-user or per-IP rate limit on the upload route itself, since a single allowed file size doesn't prevent someone from uploading the maximum allowed size in a tight loop.

Where does malware scanning fit relative to these other controls?

Malware scanning fits as the last layer, not a replacement for the earlier ones — OWASP's guidance places AV/malware scanning of uploaded content alongside signature validation, storage isolation, and safe filename handling as one part of a layered defense, not a single control you can rely on exclusively. In practice this means routing every accepted upload through a scanning step (commercial or open-source antivirus engines integrated via API or as a sidecar service) before the file is ever served back to another user or opened by downstream processing, and treating the upload as quarantined — unreadable and unservable — until that scan completes. This ordering matters operationally: signature and path checks are cheap and run synchronously on every request, while malware scanning is comparatively expensive, so it should run after the fast structural checks have already rejected malformed or clearly forged files, not before them. Files that fail scanning should be deleted rather than flagged and retained, since a quarantine bucket accessible from the application server is itself a target.

Does hardening Content-Disposition and serving headers matter for uploaded files?

Yes — even a file that passes every upstream check can still cause harm if it's served back with headers that let a browser execute it. Serving uploaded files with Content-Disposition: attachment forces a download instead of inline rendering, and setting an explicit, restrictive Content-Type on the response (based on the validated signature, not the original client-supplied header) prevents a browser from re-sniffing an HTML-disguised-as-image file and rendering it as a page, which is how stored uploads have historically been turned into stored cross-site-scripting delivery. Serving user uploads from a separate subdomain or object-storage bucket with no cookies or session context also limits the blast radius if one of the earlier controls is ever bypassed — a compromised upload served from usercontent.example.com can't read session cookies scoped to example.com. None of these header-level protections replace signature validation or path handling; they're the control that limits damage on the rare occasion an earlier layer fails, which is the same reasoning behind treating every layer in this pipeline as independently necessary rather than mutually redundant.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.