Hint 1: The client chooses the name and the type
The file name and Content-Type are request data. Check them against an allowlist, and require the first bytes to match: 89 50 4E 47 0D 0A 1A 0A for PNG, FF D8 FF for JPEG.
Hint 1 unlocks after your first failed run.
Hint 2: Where and how the file is served decides what it can do
A file under public/ is served by the web server by its extension. Store avatars outside it under a generated name, and serve them with a fixed type, attachment and nosniff.
Hint 2 unlocks after 2 failed runs.
Reveal the reference patch and explanation
const LIMIT = 524288; // 512 KiB
// Each type's file signature, as hexadecimal text: what its first bytes must read.
const TYPES = [
{extensions: ['png'], mime: 'image/png', saveAs: 'png', signature: '89504e470d0a1a0a'},
{extensions: ['jpg', 'jpeg'], mime: 'image/jpeg', saveAs: 'jpg', signature: 'ffd8ff'},
];
const hex = bytes => bytes.map(b => b.toString(16).padStart(2, '0')).join('');
export function acceptAvatar(file, newId) {
if (!file) return {status: 400};
if (!(file.size <= LIMIT)) return {status: 413};
const name = String(file.filename);
const dot = name.lastIndexOf('.');
const extension = dot === -1 ? '' : name.slice(dot + 1).toLowerCase();
const type = TYPES.find(t => t.extensions.includes(extension));
if (
!type ||
String(file.contentType).toLowerCase() !== type.mime ||
!hex(file.head).startsWith(type.signature)
)
return {status: 415};
return {
status: 201,
path: `storage/avatars/${newId()}.${type.saveAs}`,
headers: {
'Content-Type': type.mime,
'Content-Disposition': 'attachment',
'X-Content-Type-Options': 'nosniff',
},
};
}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.
Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.
The reference patch unlocks after you run code you have edited.
Inspect the actual test harness
The browser and the lab download run the same cases, including cases that catch common wrong fixes, such as comparing with one fixture value instead of the real rule. The download includes the complete test adapter and cryptographic fixtures.
Download cases.mjsHow the browser sandbox runs your code
Your code runs in QuickJS 2025-09-13 (quickjs-emscripten 0.32.0) inside a Web Worker, isolated from the page, with a 32 MiB memory limit and a 2.5-second time limit. The test cases and the pass or fail verdict run outside that sandbox, so changing built-ins such as Array.prototype.push cannot change the result. The lab download runs the same cases under Node.js, where your code runs directly.
The same as in current Node.js:
- ES2025 syntax and built-ins, including Set methods such as intersection, iterator helpers, Promise.try, RegExp.escape and the RegExp v flag
Added to the sandbox so that it behaves like Node.js:
- structuredClone, Array.fromAsync, queueMicrotask, atob and btoa
- URL and URLSearchParams, parsed by your browser’s own URL implementation
- TextEncoder and TextDecoder for UTF-8
- Intl.Collator and a locale-aware String.prototype.localeCompare
- console.log and related methods, whose output appears with your results
Not available in the browser sandbox:
- Timers (setTimeout, setInterval), fetch and other network or storage APIs; the exercises are deterministic and offline
- Node.js modules and globals: import, require, process and Buffer
- Web Crypto (crypto.getRandomValues, crypto.subtle); each workshop supplies the cryptographic operations it needs
- Intl other than Collator: toLocaleString and toLocaleDateString ignore the locale, so numbers have no digit grouping and dates use one fixed format in the device’s time zone
- using declarations, Symbol.dispose and DisposableStack (explicit resource management)
- Uint8Array.fromBase64 and toBase64, JSON.rawJSON, Atomics and Error.captureStackTrace
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.
Your 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?