Stackline Open Source

JavaScript security guide

How to deep merge untrusted JavaScript objects safely

A deep merge is executable policy over an object graph. Treat it as a trust boundary whenever configuration, JSON, request data, plugins, or persisted state can influence either input.

Updated August 21, 20269 minute readJavaScript and TypeScript

Short answer: reject dangerous property names before reading their values, never mutate either input, choose array behavior explicitly, preserve or reject cycles deliberately, and bound traversal at exposed boundaries.

1. Define the trust boundary first

A shallow assignment copies one level. A deep merge recursively reads properties and decides whether each value should be cloned, traversed, concatenated, replaced, or delegated to a callback. That creates more attack and compatibility surface than { ...defaults, ...input }.

Assume input is untrusted when it can originate in HTTP JSON, YAML, environment-derived configuration, tenant settings, package metadata, imported files, a database row written by another service, or a plugin. Parsing JSON does not make an object safe; it only turns text into values.

QuestionDecision to make
Can keys come from users?Filter or reject unsafe names before property access.
Can graphs be very deep or wide?Set depth and key budgets.
Are arrays lists or keyed records?Concatenate, replace, or merge by index explicitly.
Can objects contain cycles?Preserve identity or fail with a controlled error.
Do getters or class instances occur?Decide whether those objects are mergeable at all.

2. Block unsafe keys before reading values

The familiar prototype-pollution path uses __proto__, prototype, or constructor. A safe merge checks the key itself before evaluating source[key]. Reading first can invoke a getter even when the property is later discarded.

import merge from '@stackline/deepmerge';

const payload = JSON.parse(`{
  "profile": {
    "name": "Ada",
    "constructor": {
      "prototype": { "isAdmin": true }
    }
  }
}`);

const result = merge({}, payload);

console.log(result.profile);          // { name: 'Ada' }
console.log(Object.prototype.isAdmin); // undefined

Skipping is useful for tolerant configuration loaders. At an API boundary, rejection often gives better observability:

const config = merge(defaults, payload, {
  onUnsafeKey: 'throw',
  maxDepth: 60,
  maxKeys: 20_000
});
Important

Do not test this with only an object literal. In JavaScript, { "__proto__": value } has special syntax behavior. Use JSON.parse or Object.defineProperty to create the malicious own property your parser would receive.

3. Security fixes must preserve merge semantics

A secure replacement still fails adoption if it silently changes arrays, custom merge callbacks, class handling, clone behavior, or module interop. Write down the behavior your application already relies on before changing implementation.

Choose array behavior

import merge from '@stackline/deepmerge';

const replaceArrays = (_target, source) => source;

const result = merge(
  { plugins: ['core'], server: { port: 3000 } },
  { plugins: ['metrics'], server: { secure: true } },
  { arrayMerge: replaceArrays }
);

// plugins: ['metrics']
// server: { port: 3000, secure: true }

Concatenation is compatible with the popular deepmerge default, but replacement is usually less surprising for deployment configuration. Neither strategy is universally correct.

Keep extension hooks narrow

A custom merge function is application code. Avoid sending untrusted keys into a callback before the central unsafe-key policy runs, and keep isMergeableObject conservative when inputs can contain class instances or platform objects.

4. Handle cycles and resource exhaustion

Prototype pollution is not the only hostile shape. A graph can be cyclic, thousands of levels deep, or contain millions of enumerable keys. Recursive code can overflow the call stack, while an unbounded iterative implementation can still exhaust CPU and memory.

const graph = { service: { enabled: true } };
graph.self = graph;

const merged = merge({}, graph, {
  maxDepth: 100,
  maxKeys: 50_000
});

merged.self === merged; // true

Set lower limits close to public request boundaries. Higher or unlimited budgets can be reasonable for trusted, generated data, but that decision should be local and visible.

5. Migrate without rewriting imports

For new code, install and import the scoped package directly:

npm install @stackline/deepmergeView on npm
import merge from '@stackline/deepmerge';

For an existing application that imports deepmerge, an npm alias preserves source code while changing the installed implementation:

npm uninstall deepmerge
npm install deepmerge@npm:@stackline/deepmerge
import merge from 'deepmerge'; // unchanged

Run application tests after aliasing. Compatibility means preserving documented contracts; it cannot predict private assumptions in every downstream codebase.

6. Add regression tests that prove the boundary

import assert from 'node:assert/strict';
import merge, { UnsafeKeyError } from '@stackline/deepmerge';

const attack = JSON.parse(
  '{"constructor":{"prototype":{"polluted":true}}}'
);

const result = merge({}, attack);
assert.equal(Object.prototype.polluted, undefined);
assert.equal(Object.hasOwn(result, 'constructor'), false);

assert.throws(
  () => merge({}, attack, { onUnsafeKey: 'throw' }),
  UnsafeKeyError
);

const target = { nested: { original: true } };
const source = { nested: { added: true } };
const output = merge(target, source);
assert.deepEqual(target, { nested: { original: true } });
assert.deepEqual(source, { nested: { added: true } });
assert.deepEqual(output.nested, { original: true, added: true });

Round out the suite with dangerous keys at several depths, null-prototype dictionaries, arrays, custom callbacks, getters, symbols, cycles, shared references, maximum-depth failures, and the actual configuration fixtures used by the application.