Accept avatars without publishing pages

A profile-picture upload trusts the client’s Content-Type and file name, stores files in the public folder and serves them with the client’s type, so an SVG with a script runs in the site’s origin. Repair the upload decision.

Requires Node.js 22 or newer. No package installation or server needed. To work in a container instead, open the folder with VS Code Dev Containers or GitHub Codespaces: .devcontainer/devcontainer.json uses the Dev Containers Node.js 24 image, pinned by digest.
1. Unzip all files into one folder.
2. In that folder, run: node --test exercise.test.mjs
3. Edit avatar-upload.mjs, then run the same command again.

The starter deliberately fails some tests. A successful repair passes every case, including cases that catch common wrong fixes. The browser runs this same cases.mjs with the same fixtures; locally Node runs your code directly. If you completed the optional first stage in the browser, Download my work adds your input as stage1-input.json and the tests include it. Only run code you wrote or reviewed.

1. Reproduce the problem
Run the starter. The SVG case is accepted because its declared type starts with image/, it is stored in public/uploads/ and served inline as image/svg+xml, so opening its URL runs its script. The traversal case writes outside the upload folder.

2. Allow two types, and require agreement
Refuse a missing file with 400 and a file over 524,288 bytes with 413. Take the extension after the last dot in lower case, look up its type, and require the declared Content-Type and the first bytes to match it, or answer 415.

3. Store under a name you generate
Ignore the client’s file name except for its extension. Store the file as storage/avatars/<newId()>.png or .jpg, outside public/, so two users’ avatar.png files never collide and ../ means nothing.

4. Serve it as an image download
Return the verified Content-Type, Content-Disposition: attachment and X-Content-Type-Options: nosniff. Ordinary PNG and JPEG avatars, a .JPG, a .jpeg and a file of exactly 512 KiB must still be accepted.

SUPPLIED CONTRACT
acceptAvatar(file, newId) returns a decision as data. file is null when the request had no file part, or {filename, contentType, size, head}: the client’s file name and declared Content-Type (both chosen by the client), the number of bytes the server received, and the first 16 bytes of the file as numbers. newId() returns a new random 32-character hexadecimal ID. Return {status: 400} when there is no file, {status: 413} when size is over 524,288 bytes (512 KiB; exactly 524,288 is allowed), and {status: 415} unless the extension after the last dot (any case), the declared Content-Type and the first bytes all name the same allowed type: PNG (.png, image/png, first bytes 89 50 4E 47 0D 0A 1A 0A) or JPEG (.jpg or .jpeg, image/jpeg, first bytes FF D8 FF). Otherwise return {status: 201, path, headers}: path is where the file is stored, "storage/avatars/<id>.png" or "storage/avatars/<id>.jpg" with an ID from newId(); headers are the response headers the avatar handler serves it with. The web root is public/: everything under it is served as it is, by its extension.

REFERENCE
reference.mjs contains an explained solution: avatar.png, a.png, holiday.jpg, me.jpeg, IMG_0042.JPG and a PNG of exactly 524,288 bytes are accepted and stored as storage/avatars/<id>.png or .jpg, served with the verified type, Content-Disposition: attachment and X-Content-Type-Options: nosniff; two uploads of avatar.png get two different IDs. A missing file is 400 and 524,289 bytes is 413. An HTML page, an SVG with a script, a GIF, an HTML page named avatar.png and declared image/png, a PNG declared text/html, text/html; charset=image/png, image/png;text/html or an empty type, a PNG named avatar.jpg or avatar.png.html, a name without an extension and an empty file are all 415. A PNG with an HTML page appended is accepted, because its first bytes are a PNG’s, but it is served only as an image/png attachment with nosniff, so a direct visit downloads it. ../../public/index.png and /srv/app/public/avatar.png are stored under generated names like any other PNG.

SCOPE
The lab models the decision, not the transfer: a real server enforces the size limit while it reads the request (multer’s limits.fileSize, a reverse proxy’s body limit) rather than after storing the file, creates the file with create-new semantics, and runs the serving route with an authorization check, because an unguessable name is not access control. The signature check is a floor, not proof of a harmless file: re-encoding images with a maintained library under resource limits, malware scanning and serving user files from a separate origin are further layers. CSRF protection on the upload form is out of scope here.

NEXT TASK
Where does your application store files that users send, can the web server serve that folder directly, and which response headers does a direct visit to an uploaded file get?

SOURCE
https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
