The Defaults That Reached Every Object — JavaScript Bug Hunt

Modelled on lodash CVE-2019-10744 (July 2019). lodash's defaultsDeep walked into whatever keys the source object carried.

  • Language: JavaScript
  • Layer: Backend
  • Difficulty: Medium
  • Concepts: Security, Validation
  • Modelled on: lodash · CVE-2019-10744
  • Visible tests: nested defaults fill the gaps; a constructor.prototype payload pollutes nothing
  • Reward: 50 XP for a complete fix

Briefing

Modelled on lodash CVE-2019-10744 (July 2019). lodash's defaultsDeep walked into whatever keys the source object carried. A payload such as {"constructor": {"prototype": {...}}} led it from a plain object to Object, then to Object.prototype, and it wrote the attacker's keys there — prototype pollution: every object in the process suddenly had new properties. It was fixed in lodash 4.17.12 by refusing to merge those keys.

This reconstruction's settings service merges a user's saved JSON with the app defaults using a hand-written defaultsDeep that has the same blind spot.

Fix defaultsDeep so a merge can never climb out of the data into a prototype.

Bug report

BUG-PROTO · Priority: Critical · Reported by: security

defaultsDeep(target, source) fills keys that are undefined on target with source's value, recursing when both sides hold objects. It must:

  • never read or write through the keys "__proto__", "constructor" or "prototype" — such keys in source are skipped entirely
  • still merge every other key normally (safe siblings of a skipped key are kept)
  • only consider source's own keys

config.build(userJson) must therefore never add a property to Object.prototype, whatever JSON it is given.

Observed: after saving settings {"constructor":{"prototype":{"isAdmin":true}}} every object in the process reports isAdmin === true.

Logs

[settings] saved user 88 preferences (212 bytes)
[authz] ({}).isAdmin === true for a request with no session

The code as shipped

src/util/defaults-deep.js (editable)

function isMergeable(value) {
  return value !== null && (typeof value === "object" || typeof value === "function");
}

// Fills every key missing from target with source's value, recursing into
// nested objects. Mutates and returns target.
function defaultsDeep(target, source) {
  for (var key in source) {
    if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
    var incoming = source[key];
    var existing = target[key];
    if (existing === undefined) {
      target[key] = incoming;
    } else if (isMergeable(existing) && isMergeable(incoming)) {
      defaultsDeep(existing, incoming);
    }
  }
  return target;
}

exports.defaultsDeep = defaultsDeep;

Read-only context: src/settings/config.js.

Open the hunt to edit the files, run the visible tests and submit against the hidden ones. More JavaScript bug hunts.