Back to Blog
critical SEVERITY7 min read

How prototype pollution happens in JavaScript AST traversal and how to fix it

A critical prototype pollution primitive was fixed in `src/traverse/estraverse` where visitor-supplied child keys were merged with `Object.assign(Object.create(this.__keys), visitor.keys)`. Because `Object.assign` uses assignment semantics, a key literally named `__proto__` reached the `Object.prototype` setter and rewired the prototype chain of the traversal key map instead of being stored as data. The fix replaces the merge with an object spread (`{ ...VisitorKeys, ...visitor.keys }`), which *

O
By Orbis AppSec
Published September 8, 2026Reviewed September 8, 2026

Answer Summary

This is a prototype pollution vulnerability (CWE-1321) in the JavaScript AST traversal module `src/traverse/estraverse` at line 358. The `Controller.prototype.__initialize` function merged caller-supplied visitor keys using `Object.assign(Object.create(this.__keys), visitor.keys)`; since `Object.assign` performs `[[Set]]`, a source key named `__proto__` triggered the inherited `Object.prototype.__proto__` setter and replaced the key map's prototype with an attacker-controlled object. The fix uses an object spread — `this.__keys = visitor.keys ? { ...VisitorKeys, ...visitor.keys } : VisitorKeys` — because spread uses `CreateDataPropertyOrThrow`, which defines own properties and never invokes inherited setters.

Vulnerability at a Glance

cweCWE-1321 (Improperly Controlled Modification of Object Prototype Attributes)
fixReplace the merge with `{ ...VisitorKeys, ...visitor.keys }`, which defines each key as an own data property and never invokes setters
riskAttacker-controlled `visitor.keys` could rewire the prototype of the traversal key map, steering AST traversal into arbitrary object properties and handing unexpected objects to `enter`/`leave` callbacks in downstream tooling
languageJavaScript (Node.js library, estraverse-derived AST traversal)
root cause`Object.assign(Object.create(this.__keys), visitor.keys)` uses assignment semantics, so a source key named `__proto__` hits the inherited `Object.prototype.__proto__` setter instead of becoming an own property
vulnerabilityPrototype pollution via unsafe object merge (`Object.assign` into a prototype-linked object)

Answer Summary

This is a prototype pollution vulnerability (CWE-1321) in the JavaScript AST traversal module src/traverse/estraverse at line 358. The Controller.prototype.__initialize function merged caller-supplied visitor keys using Object.assign(Object.create(this.__keys), visitor.keys); since Object.assign performs [[Set]], a source key named __proto__ triggered the inherited Object.prototype.__proto__ setter and replaced the key map's prototype with an attacker-controlled object. The fix uses an object spread — this.__keys = visitor.keys ? { ...VisitorKeys, ...visitor.keys } : VisitorKeys — because spread uses CreateDataPropertyOrThrow, which defines own properties and never invokes inherited setters.


Vulnerability at a Glance

Field Value
ID V-001
Severity Critical
CWE CWE-1321 — Prototype Pollution
File src/traverse/estraverse:358
Function Controller.prototype.__initialize
Pattern Object.assign(Object.create(this.__keys), visitor.keys)
Chain complexity 2-step
Scanner multi_agent_ai

Introduction

The src/traverse/estraverse file is the engine that walks an ESTree abstract syntax tree. To know which properties of a node contain child nodes, it consults a lookup table called VisitorKeysProgram has body, BinaryExpression has left and right, and so on. Because real-world tools invent their own node types (JSX, TypeScript, template languages, custom codemod nodes), the traversal Controller lets callers pass a keys option to extend that table.

That extension point is where the bug lived. In Controller.prototype.__initialize, the merge was written like this:

this.__keys = VisitorKeys
if (visitor.keys) {
  this.__keys = Object.assign(Object.create(this.__keys), visitor.keys)
}

The intent is elegant: create a fresh object whose prototype is the shared VisitorKeys table, then layer the caller's overrides on top. Defaults are inherited, overrides are own properties, and the shared VisitorKeys object is never mutated.

The problem is the interaction between Object.create and Object.assign. The new object still inherits from Object.prototype, which carries a legacy accessor property named __proto__. And Object.assign copies values by assignment, not by definition. So if visitor.keys contains an own enumerable property literally named __proto__, the merge doesn't store a key list — it calls a setter that rewrites the prototype chain of this.__keys.

This matters far beyond one library. Any Node.js package that accepts an options object and merges it with Object.assign into an object that inherits Object.prototype has the same latent primitive.

The Vulnerability Explained

Why Object.assign is not a safe merge

Object.assign(target, source) is specified to perform Set(target, key, value, true) for each own enumerable key of the source. Set walks the prototype chain looking for an accessor. Object.prototype defines:

Object.getOwnPropertyDescriptor(Object.prototype, '__proto__')
// { get: [Function: get __proto__], set: [Function: set __proto__], ... }

So target.__proto__ = value never creates a data property — it invokes that setter, which replaces target's internal prototype.

Contrast that with object spread. { ...source } is specified using CopyDataProperties, which calls CreateDataPropertyOrThrow — a [[DefineOwnProperty]] operation that ignores the prototype chain entirely. The two look interchangeable in everyday code; they are not interchangeable in the presence of untrusted keys.

Reaching the sink

The keys option is public API. In practice it arrives from places the library author does not control:

  • a linter/codemod configuration file parsed with JSON.parse
  • a plugin manifest downloaded from a registry
  • a web service that lets users describe custom node types
  • an options object forwarded verbatim from a caller several layers up

JSON.parse is the classic amplifier here, because it happily creates a genuine own enumerable property named __proto__:

const parsed = JSON.parse('{"__proto__": {"Program": ["hacked"]}}')
Object.keys(parsed)                       // [ '__proto__' ]  ← own, enumerable
Object.getPrototypeOf(parsed) === Object.prototype  // true — not polluted yet

Nothing bad has happened yet. The pollution happens on the merge.

Step 1 — rewiring the key map's prototype

const { traverse } = require('abstract-syntax-tree')

// Attacker-influenced config, e.g. read from a JSON plugin manifest
const keys = JSON.parse('{"__proto__": {"Program": ["evil"], "Identifier": ["evil"]}}')

traverse(ast, {
  keys,
  enter (node) { /* downstream tool logic */ }
})

Inside __initialize:

this.__keys = Object.assign(Object.create(VisitorKeys), keys)

Object.create(VisitorKeys) produces an object with the chain {} → VisitorKeys → Object.prototype. Object.assign then assigns __proto__, the inherited setter fires, and the chain becomes {} → { Program: ['evil'], Identifier: ['evil'] } → Object.prototype.

VisitorKeys has been unlinked from the chain entirely. The traversal's default child-key table is gone, replaced by an object the attacker authored — and note that not a single own property was added, so a defensive check like Object.keys(this.__keys).includes('__proto__') after the merge would see nothing suspicious.

Step 2 — steering the traversal

Every child lookup in the traversal loop resolves through that chain:

candidates = this.__keys[node.type]   // now answered by the attacker's object

The attacker now controls, for every node type, which properties the traverser treats as child nodes. That yields several concrete outcomes:

  1. Traversal into non-AST properties. Declaring {"Identifier": ["constructor"]} makes the walker descend into node.constructor, so Identifier.prototype.constructor (or Object) gets passed to enter/leave as if it were an AST node. Downstream consumers routinely write to the nodes they visit — node.name = ..., Object.assign(node, replacement) — turning a read primitive into a write primitive against shared objects and prototypes. That is the second step of the chain.
  2. Getter-triggered execution. If the attacker's replacement prototype is built with accessor properties, merely reading this.__keys[node.type] runs attacker code inside the traversal, in whatever privilege context the build tool or server runs.
  3. Silent traversal blinding. Returning [] for security-relevant node types (CallExpression, ImportDeclaration, MemberExpression) makes the walker skip them. For a security linter or dependency scanner built on this traversal, that is an analysis bypass: dangerous code passes review because the analyzer never visited it.

Real-world impact

This module is a Node.js library, so the blast radius is downstream consumers: bundlers, linters, minifiers, codemods, and any service that parses or transforms user-submitted JavaScript. Those processes typically run with filesystem and network access in CI. A prototype-level write inside a build step is a supply-chain foothold, and a traversal that can be told to skip node types undermines every security tool layered on top of it. Hence the critical rating even though exploitation requires a caller that forwards untrusted keys.

The Fix

The patch is one statement in Controller.prototype.__initialize.

Before (src/traverse/estraverse:358):

this.__keys = VisitorKeys
if (visitor.keys) {
  this.__keys = Object.assign(Object.create(this.__keys), visitor.keys)
}

After:

// Visitor keys override the defaults; any node type the visitor does not
// mention keeps its default child keys. Spreading defines each key rather
// than assigning it, so a key named __proto__ is stored like any other
// instead of reaching the setter on Object.prototype.
this.__keys = visitor.keys ? { ...VisitorKeys, ...visitor.keys } : VisitorKeys

Three things change, and each one matters:

1. Define instead of assign. The spread of visitor.keys runs through CreateDataPropertyOrThrow, which never consults the prototype chain. A malicious {"__proto__": {...}} now becomes an inert own data property on this.__keys:

const merged = { ...VisitorKeys, ...JSON.parse('{"__proto__":{"Program":["evil"]}}') }
Object.getPrototypeOf(merged) === Object.prototype  // true — chain intact
merged.Program                                      // the real default, e.g. [ 'body' ]

Since no ESTree node type is named __proto__, the stored key is simply never looked up. The exploit primitive is gone without any filtering, allow-listing, or extra runtime checks.

2. Defaults become own properties. { ...VisitorKeys, ...visitor.keys } flattens the two-level prototype chain into a single object with own properties. That removes the inheritance trick that made the old code fragile in the first place: there is no longer a prototype-linked layer for a setter to hijack. Lookup order is preserved because later spreads win — visitor.keys still overrides VisitorKeys for the node types it mentions.

3. VisitorKeys is still not mutated. The spread builds a brand-new object, so the shared module-level VisitorKeys table is untouched — the same safety property the original Object.create was reaching for. And when no keys option is supplied, the code short-circuits to VisitorKeys directly, avoiding a needless copy on the hot path.

Tests that lock in the behavior

Because the fix changes how defaults are reachable (inherited → own), the PR adds regression tests in test/traverse.js to prove the observable contract did not change:

test("it visits the children named by a custom key", () => {
  const { traverse } = AbstractSyntaxTree
  const visited = []
  const tree = {
    type: "CustomNode",
    children: [
      { type: "Identifier", name: "a" },
      { type: "Identifier", name: "b" },
    ],
  }
  traverse(tree, {
    keys: { CustomNode: ["children"] },
    enter(node) {
      visited.push(node.name || node.type)
    },
  })
  assert.deepEqual(visited, ["CustomNode", "a", "b"])
})

test("it keeps the default keys for node types the visitor does not mention", () => {
  const { parse, traverse } = AbstractSyntaxTree
  const visited = []
  const tree = parse("var a = 1")
  traverse(tree, {
    keys: { CustomNode: ["children"] },
    // ...asserts the standard VariableDeclaration/VariableDeclarator/Identifier
    // children are still visited
  })
})

The first test proves custom keys still drive traversal; the second proves the spread did not drop the built-in VisitorKeys entries for node types the visitor never mentions. Together they pin down exactly the behavior that a naive "just delete `__

Frequently Asked Questions

What is prototype pollution?

Prototype pollution is a JavaScript-specific vulnerability where an attacker injects a property named `__proto__`, `constructor`, or `prototype` into an object merge, clone, or path-assignment routine. Instead of being stored as ordinary data, the key modifies an object's prototype — potentially `Object.prototype` itself — so every object in the process suddenly inherits attacker-chosen values. That can flip security flags, inject default configuration, or supply gadget properties that other code trusts.

How do you prevent prototype pollution in JavaScript?

Merge with operations that *define* properties rather than assign them (object spread, `Object.defineProperty`, `structuredClone` on validated input), reject or strip the keys `__proto__`, `constructor`, and `prototype` before merging, use `Object.create(null)` or `Map` for user-keyed lookup tables, and freeze shared defaults with `Object.freeze`. Validating the shape of caller-supplied options with a schema validator adds a second layer.

What CWE is prototype pollution?

CWE-1321, "Improperly Controlled Modification of Object Prototype Attributes ('Prototype Pollution')". It is a specialization of CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes) and is closely related to mass-assignment issues.

Is using Object.create() enough to prevent prototype pollution?

No — and this vulnerability is the proof. `Object.create(this.__keys)` was intended to make the defaults a read-only backing layer, but the new object still inherits `Object.prototype`, including its `__proto__` accessor. Because `Object.assign` triggers that inherited setter, `Object.create()` on its own provided no protection. Only `Object.create(null)` removes the accessor, and even then you need define-semantics for the merge.

Can static analysis detect prototype pollution?

Yes, for recognizable patterns. Taint-tracking and pattern rules reliably flag `Object.assign`, `lodash.merge`, and recursive `for...in` copy loops fed by external data, which is exactly how this `Object.assign(Object.create(this.__keys), visitor.keys)` call was surfaced. What static tools struggle with is proving exploitability, since that depends on which gadget properties downstream consumers read — so these findings are usually treated as exploit primitives worth removing regardless.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #119

Related Articles

critical

How SQL injection happens in Python DuckDB view creation and how to fix it

A critical SQL injection flaw in `python/src/idx/api.py:265` built five DuckDB `CREATE VIEW` statements with Python f-strings, interpolating a filesystem path directly into SQL text. The fix replaces the interpolated path with a bound parameter (`read_parquet(?)`) and moves the view names into a hardcoded, non-interpolated statement map — eliminating any path where filenames or directory values can alter SQL structure.

high

How JavaScript Injection via String Interpolation Happens in Go Wails Applications and How to Fix It

A high-severity JavaScript injection vulnerability in `internal/clusterconfigs/input.go` allowed arbitrary code execution through malicious kubeconfig filenames. The `saveClusterConfigFile` function at line 20 constructed JavaScript code by directly interpolating unsanitized filenames into `window.ExecJS()` calls, enabling attackers to break out of string literals and execute arbitrary JavaScript in the Webview context.

high

How Denial of Service via Prototype Pollution happens in Axios and how to fix it

Axios versions prior to 1.15.1 merged untrusted configuration objects without guarding against the `__proto__` key, letting attacker-controlled input pollute `Object.prototype` and crash or destabilize applications. Upgrading axios (and its transitive dependencies `form-data`, `follow-redirects`, `proxy-from-env`) closes this Denial of Service and prototype-pollution attack surface without changing any application code.

critical

How Server-Side Request Forgery happens in Node.js and how to fix it

The order-flow service in a Node.js e-commerce backend built an outbound fetch() URL by directly concatenating a configurable `sendingOrder.url` value with a query string, with no validation of protocol or destination. This allowed order data—including customer and payment-adjacent information—to be silently redirected to an attacker-controlled endpoint simply by changing a config value or environment variable.

high

How Infinite Loop Denial of Service Happens in nanoid and How to Fix It

CVE-2026-67213 is a high-severity infinite loop vulnerability in nanoid's `customAlphabet` function that could cause Denial of Service through CPU exhaustion. The fix upgrades nanoid from 3.3.12 to patched versions 3.3.18 and 5.1.6, eliminating the loop condition that trapped ID generation when processing certain input patterns.

critical

How origin validation bypass happens in Express.js and how to fix it

A `POST /changeData` route in `src/main/server/routes/index.js` guarded state-changing writes with an origin allowlist, but the guard was wrapped in an `if (origin && ...)` truthiness check. Any request that simply omitted both `Origin` and `Referer` — a one-line `curl` command, a local script, a background process — skipped validation entirely and modified application data. The fix removes the truthiness short-circuit so a *missing* header is now treated as a rejection, not a pass.