Configuration and merging
Published
Almost every multi-tenant application merges a document it did not write into a set of defaults it did. The merge helper doing that is configured with a handful of options: which keys are never allowed through, what counts as mergeable, how deep to go. Those are property names on an options object, and the helper reading them came from a package. We protected a file that configures one, renamed the option names a group at a time, and looked at what the tenant document could then do.
What the sample actually does
The file holds a platform's own defaults - an origin allowlist, an upload ceiling, a flag for whether multi-factor authentication is required - and merges a tenant-supplied document into them. The document arrives as text over an API and is parsed, which matters: after JSON.parse the key __proto__ is an ordinary own property of the parsed object rather than a syntax accident, and that is exactly how the payload reaches a merge helper in production.
The application configures the helper with three narrowings and one control. Keys named __proto__, constructor and prototype are never merged. Arrays are not treated as mergeable, so a tenant list replaces the default rather than being appended to it. Recursion stops at four levels. The fourth option is pinned to a value identical to the library default so that its inertness is measured rather than assumed.
The helper is copied in unprotected. Protection alone was applied first on five presets and all five behaved identically to the original, so nothing below is caused by protection on its own.
One renamed key and the document writes to every object in the process
Renaming the option that carries the forbidden-key list is the direct result. The helper reported its guard as (none), the count of skipped keys went from one to zero, and the payload under __proto__ was merged. The sample's probe then found isAdmin on a freshly created, entirely unrelated object.
That is prototype pollution, and its blast radius is the process rather than the request. Every object literal created afterwards inherits the attacker's property unless it defines one of its own. Code that asks if (user.isAdmin) about a user record that has never had such a field now gets true. Code that treats a missing option as false gets whatever the document said. Nothing throws, and nothing is logged, because assigning a property is the most ordinary operation in the language.
The value of the forbidden-key list is that it is the only thing standing between a parsed document and that outcome. It is also, notably, a list of STRINGS, which becomes the subject of the next section.
The sharpest result: the guard and the alarm share a name
The application does not only block the dangerous keys, it also checks afterwards whether pollution happened, by reading isAdmin off a fresh object. That check is the sort of belt-and-braces assertion a careful team writes after reading about this class of bug.
Rename the forbidden-key option and the detector's property name together - one pattern, two names - and the measurement is worth stating slowly. The guard is gone: skipped keys drop to zero and the helper reports (none). The document is merged in full, so the prototype is written exactly as in the previous section. And the application's own pollution check still prints false, because the payload key inside the JSON document is TEXT that nothing rewrites, while the detector's isAdmin is a NAME in code that renaming moved.
So the object is polluted with isAdmin, and the code that looks for pollution is looking for something else. The guard was switched off and the alarm was switched off in the same edit, by the same pattern, with no error anywhere.
The general rule is worth keeping: a security rule expressed as DATA - a string in a denylist, a key inside a document, an entry in an allowlist - does not move when the corresponding property name moves. The guard keeps running and reports itself enabled while matching nothing. If the same name is also how you detect the failure, the detector goes blind at the same moment. This pass measured that shape twice, independently, in two unrelated areas.
Losing one option re-activated a default that had been unreachable
The application supplies its own predicate for what counts as mergeable, and it is deliberately narrower than the library's: plain objects merge, arrays do not. That is why the platform never configured an array strategy at all. It did not need one.
Renaming that predicate took the reported guard from caller-supplied to library-builtin, at which point arrays became mergeable and the library's own array strategy - concatenate - became reachable for the first time. The origin allowlist went from one entry to two, and the extra entry was the platform's decommissioned legacy origin, which every tenant's configuration was supposed to replace.
That is the second-order shape and it is easy to miss when reasoning about a single option: losing option A did not merely restore A's default, it re-activated default B, which had been unreachable while A stood. An audit that asks 'what is the default for each option we set' does not surface it, because the option that changed behaviour is one the application never set.
The concrete consequence is a dangling name. An origin the platform stopped using stays on every tenant's allowlist, and a decommissioned domain is precisely the kind of name that gets picked up by somebody else.
The tenant's own tightening stopped applying
The arm that renames the platform's default keys - the allowlist, the upload ceiling, the multi-factor flag - is the mirror image of the pollution arm, and it moves in the direction people find least intuitive.
After renaming, the tenant document still contains requireMfa as text, and the defaults object contains it under a renamed name. The merge does what it is told: it writes the tenant's key alongside, and the application reads its own renamed key. So the merged configuration reported require-mfa=false where the tenant had asked for true, the upload ceiling reverted from the tenant's 25 MB to the platform's 10 MB, and the origin list reverted to the platform default including the decommissioned entry.
Every one of those is a customer's deliberate tightening being silently discarded. It is not an outage and it does not fail: the application starts, serves traffic, and applies a coherent configuration that is simply not the one the customer configured. The only way to see it is to compare the effective configuration against what was submitted, which almost nothing does.
Renaming the depth cap was the mildest arm: recursion depth went from a capped four to the document's full twelve levels, with the reported cap moving from 4 to Infinity. On a document you control that is nothing. On a document a stranger posts, a depth cap is a denial-of-service control.
What to do about it
The mechanism is the same one this series keeps measuring: renaming rewrites property names in the code it is given, an installed helper is not in that code, and a name it does not recognise means its documented default applies. The defaults of a merge helper are permissive because a merge helper has to work for the case where nobody is worried about a hostile document.
Scope member renaming so that options objects handed to installed packages are excluded, and remember that this area has a second exposure: your defaults object is merged against a document full of literal strings, so its key names are part of a contract too. Building the defaults with quoted string keys and reading them with literal bracket access survives renaming; so does keeping them in a JSON file the build does not transform.
For prototype pollution specifically there is a structural fix that does not depend on any of this. Parse the document with a reviver that drops the dangerous keys before the merge helper ever sees it, or merge into an object created with a null prototype. Both remove the failure from the class of things a renamed option can re-enable. And write the pollution assertion against a literal string key rather than a property name, so the detector cannot go blind with the guard.
Frequently asked questions
Does protecting my JavaScript cause prototype pollution?
No. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. The pollution required member renaming pointed at the option name carrying the forbidden-key list.
Why did the application's own pollution check not fire?
Because the payload key inside the tenant document is text in a JSON string and nothing rewrites it, while the detector reads a property name in code that renaming moved. The object was polluted under the original name and the check looked for a renamed one.
What is the safest way to write that detector?
Read the property with a literal string in bracket notation rather than as a name, so that the check refers to the same text the payload uses. That form survives renaming, which is the same reason it is recommended for reading any key that arrives from outside your build.
Is a forbidden-key list the right defence at all?
It is a good one and it should not be the only one. Dropping dangerous keys in a JSON.parse reviver, or merging into an object with a null prototype, removes the exposure structurally rather than relying on an option name surviving your build.
How did renaming one option change the behaviour of an option we never set?
The application's predicate said arrays are not mergeable, so the library's array strategy was never reached. Losing the predicate restored the library's own, arrays became mergeable, and its concatenate default applied for the first time, appending to an allowlist instead of replacing it.
Our tenants can tighten settings. Would that still work?
Not on the arm that renames the defaults object's keys. The tenant's stricter values were merged under the original names while the application read renamed ones, so a tenant that asked for multi-factor authentication got the platform default instead, with no error.
What is the recommended fix?
Exclude merge options and the defaults object from member renaming, or express both with quoted string keys and literal bracket reads. Add a start-up comparison between the submitted configuration and the effective one, because that is the only check in this area that fails on every arm measured.
Related reading