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 sessionThe 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.