An upload endpoint accepts arbitrary bytes from the internet and writes them to disk. Uploads have been used to plant server-side scripts, overwrite configuration files, host malware, and inject scripts into other users' browsers. A handful of rules closes almost every one of those doors.
In this lesson you will learn what can go wrong with uploads, how to validate a file by its content rather than its name, where to store it, and how to serve it back without creating a new vulnerability.
| Threat | How it works | Primary control |
|---|---|---|
| Server-side code execution | An uploaded .php or .jsp file is placed where the web server executes it | Never store uploads inside a directory the server executes from |
| Path traversal | A filename like ../../config.env overwrites files | Generate the filename on the server |
| Stored XSS | An SVG or HTML file containing script is served from your origin | Serve as attachment, from a separate origin, with a strict content type |
| Denial of service | Huge files, thousands of files, or archives that expand enormously | Size and count limits, no automatic extraction |
The filename and the Content-Type header both come from the client and can say anything (invoice.pdf.exe and .PhP have defeated many naive checks). The only reliable signal is the file's bytes. The sample code uses the file-type package to read the magic bytes that identify a format and compares the result with an allowlist of MIME types. Three further rules:
limits.fileSize in multer) so oversized uploads are rejected early.Never reuse the client's filename on disk. Generate a UUID, append an extension derived from the detected type, and keep the original name in the database for display only (HTML-escaped when rendered).
const uploadRoot = "/srv/uploads"; // outside the application directory
const target = path.join(uploadRoot, crypto.randomUUID() + ".png");
// Defensive check: the resolved path must stay under the root
if (!path.resolve(target).startsWith(uploadRoot + path.sep)) throw new Error("path escape");Better still, avoid local disk entirely. Object storage keeps uploads off the application server, cannot execute anything, and supports presigned URLs so the browser uploads directly without the file passing through your API.
Serving an uploaded SVG from your application origin means any script inside it runs with your cookies. Serve downloads from a separate domain and set headers that stop the browser interpreting the content:
app.get("/files/:id", async (req, res) => {
const file = await lookupFile(req.params.id); // validated id, ownership checked
res.set({
"Content-Type": file.mime, // the detected type, never user input
"Content-Disposition": `attachment; filename="${file.id}"`,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox"
});
res.send(await readFromObjectStorage(file.id));
});nosniff stops type guessing, attachment forces a download, and the sandboxed CSP neutralises any script if the file is displayed anyway.
Re-encode images so the stored file is one you generated, not one the user crafted:
const sharp = require("sharp");
const clean = await sharp(req.file.buffer).rotate().png().toBuffer(); // drops EXIF and hidden payloadsFor documents, run an antivirus scan (ClamAV is the common self-hosted choice) and keep the file quarantined until the scan passes. Never automatically extract archives; a small zip can expand to gigabytes.
Why is checking the file extension insufficient for validating an upload?
nosniff, attachment and a sandboxed CSP.Next lesson: The OWASP Top 10 Explained — the industry's standard list of the most critical web application risks.