Get unstuck

Hint 1: Name what may change, not what may not

Start from the three writable top-level fields and refuse or drop every other key. Role, Role, emailVerified and organization then fail without being named.

Hint 2: A nested object needs its own allowlist

Accepting preferences does not make everything inside it writable. Copy the stored preferences, then set theme and digest one at a time after checking each value.

Reveal the reference patch and explanation
Fixedprofile-update.mjsJavaScript
const THEMES = ['light', 'dark', 'system'];
const DIGESTS = ['off', 'daily', 'weekly'];
const isObject = v => v !== null && typeof v === 'object' && !Array.isArray(v);
const isText = (v, min, max) => typeof v === 'string' && v.length >= min && v.length <= max;
const onlyKeys = (object, keys) => Object.keys(object).every(key => keys.includes(key));

export function updateProfile(user, body) {
  const rejected = {status: 'rejected'};
  // The fields this action may write; anything else is refused.
  if (!isObject(body) || !onlyKeys(body, ['displayName', 'bio', 'preferences'])) return rejected;
  const next = {...user, preferences: {...user.preferences}};
  if (Object.hasOwn(body, 'displayName')) {
    if (!isText(body.displayName, 1, 80)) return rejected;
    next.displayName = body.displayName;
  }
  if (Object.hasOwn(body, 'bio')) {
    if (!isText(body.bio, 0, 280)) return rejected;
    next.bio = body.bio;
  }
  if (Object.hasOwn(body, 'preferences')) {
    const prefs = body.preferences;
    if (!isObject(prefs) || !onlyKeys(prefs, ['theme', 'digest'])) return rejected;
    if (Object.hasOwn(prefs, 'theme')) {
      if (!THEMES.includes(prefs.theme)) return rejected;
      next.preferences.theme = prefs.theme;
    }
    if (Object.hasOwn(prefs, 'digest')) {
      if (!DIGESTS.includes(prefs.digest)) return rejected;
      next.preferences.digest = prefs.digest;
    }
  }
  return {status: 'updated', user: next};
}

Alice’s record starts as member of Red Ltd (owned by u_bob), theme light and digest weekly. A new display name and bio, a bio alone, an empty bio, {preferences: {digest: "daily"}} (theme stays light), {preferences: {theme: "system"}}, both preferences together, an 80-character name and an empty body are applied. role, the case variant Role, email with emailVerified, id and organization.ownerId are refused (or ignored, which the tests also accept), so role stays member, the organization keeps its owner and no field is added. {preferences: {theme: "dark", role: "admin"}} adds nothing to preferences. An empty or 81-character display name, a display name that is an object or null, a 281-character bio, the theme "neon", the digest "hourly", preferences sent as a string or null, and a body that is null are never stored.

Compare it with your own fix, then rerun the tests. The editor is not changed when you open this explanation.

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.mjs
How 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

Use the lesson in your own system

The lab binds one self-service action. Changing role, email or organization ownership belongs to separate actions with their own authorization: an admin endpoint, an email-verification flow, an ownership transfer. Real frameworks add their own binding rules: use the input type or schema your framework provides, configured to refuse or drop unknown fields, and check that your ORM does not accept nested writes you did not intend. Response projections, which decide which fields a user may read, are a separate control.

Your next task: Find an endpoint in your code that passes a request body to an ORM update, a merge or Object.assign. Which fields of that entity, including related rows, could a client set today?

OWASP Mass Assignment Cheat Sheet

Related guide: Authorize functions and fields as well as objects