From c52f1817b38cb03758fee45b1109caae12411ffa Mon Sep 17 00:00:00 2001 From: "n8n-assistant[bot]" <100856346+n8n-assistant[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:21:43 +0000 Subject: [PATCH] fix(core): Create an XML parser for each webhook request body (backport to release-candidate/2.44.x) (#41112) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Mike Repeć --- .../middlewares/__tests__/body-parser.test.ts | 37 ++++++++++++++++++- packages/cli/src/middlewares/body-parser.ts | 21 ++++++----- patches/xml2js@0.6.2.patch | 13 +++++++ pnpm-lock.yaml | 11 +++--- pnpm-workspace.yaml | 1 + 5 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 patches/xml2js@0.6.2.patch diff --git a/packages/cli/src/middlewares/__tests__/body-parser.test.ts b/packages/cli/src/middlewares/__tests__/body-parser.test.ts index 321508eadae..647e1a6d0cc 100644 --- a/packages/cli/src/middlewares/__tests__/body-parser.test.ts +++ b/packages/cli/src/middlewares/__tests__/body-parser.test.ts @@ -2,9 +2,9 @@ import type { Request, Response } from 'express'; import { mock } from 'vitest-mock-extended'; import { Readable } from 'stream'; -import { BadRequestError } from '@n8n/errors'; +import { BadRequestError, UnprocessableRequestError } from '@n8n/errors'; -import { rawBodyReader } from '../body-parser'; +import { parseBody, rawBodyReader } from '../body-parser'; /** Build a request backed by a real readable stream carrying `payload`. */ const mockRequest = (payload = '', headers: Record = {}) => { @@ -57,3 +57,36 @@ describe('rawBodyReader', () => { }); }); }); + +describe('parseBody', () => { + const xmlRequest = (body: string) => + ({ + contentType: 'text/xml', + encoding: 'utf-8', + rawBody: Buffer.from(body), + readRawBody: async () => {}, + }) as unknown as Request; + + // Bodies over 10,000 chars make xml2js parse in chunks across ticks. + const largeXml = (id: string) => + `${'x'.repeat(1000)}${id}`; + + it('parses concurrent large XML bodies independently', async () => { + const requests = ['a', 'b', 'c'].map((id) => xmlRequest(largeXml(id))); + + await Promise.all(requests.map(parseBody)); + + expect(requests.map((req) => req.body.envelope.body.id)).toEqual(['a', 'b', 'c']); + + const after = xmlRequest('after'); + await parseBody(after); + expect(after.body).toEqual({ root: { id: 'after' } }); + }); + + it('rejects an XML body that fails inside an element handler', async () => { + // An element named `_` collides with the text key and throws while closing the tag. + await expect(parseBody(xmlRequest('<_>x'))).rejects.toThrow( + UnprocessableRequestError, + ); + }); +}); diff --git a/packages/cli/src/middlewares/body-parser.ts b/packages/cli/src/middlewares/body-parser.ts index 609c54b18a2..139f455ea28 100644 --- a/packages/cli/src/middlewares/body-parser.ts +++ b/packages/cli/src/middlewares/body-parser.ts @@ -11,14 +11,17 @@ import { createGunzip, createInflate } from 'zlib'; import { BadRequestError, UnprocessableRequestError } from '@n8n/errors'; -const xmlParser = new XmlParser({ - async: true, - normalize: true, // Trim whitespace inside text nodes - normalizeTags: true, // Transform tags to lowercase - explicitArray: false, // Only put properties in array if length > 1 - tagNameProcessors: [sanitizeXmlName], - attrNameProcessors: [sanitizeXmlName], -}); +// One parser per request: xml2js keeps parse state on the instance, and async +// mode spreads large bodies over several ticks. +const createXmlParser = () => + new XmlParser({ + async: true, + normalize: true, // Trim whitespace inside text nodes + normalizeTags: true, // Transform tags to lowercase + explicitArray: false, // Only put properties in array if length > 1 + tagNameProcessors: [sanitizeXmlName], + attrNameProcessors: [sanitizeXmlName], + }); const payloadSizeMax = Container.get(GlobalConfig).endpoints.payloadSizeMax; @@ -97,7 +100,7 @@ export const parseBody = async (req: Request) => { req.body = jsonParse(stripBom(rawBody.toString(encoding))); } else if (contentType?.endsWith('/xml') || contentType?.endsWith('+xml')) { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - req.body = await xmlParser.parseStringPromise(rawBody.toString(encoding)); + req.body = await createXmlParser().parseStringPromise(rawBody.toString(encoding)); } else if (contentType === 'application/x-www-form-urlencoded') { req.body = parseQueryString(stripBom(rawBody.toString(encoding)), undefined, undefined, { maxKeys: 1000, diff --git a/patches/xml2js@0.6.2.patch b/patches/xml2js@0.6.2.patch new file mode 100644 index 00000000000..f5d5caaef30 --- /dev/null +++ b/patches/xml2js@0.6.2.patch @@ -0,0 +1,13 @@ +diff --git a/lib/parser.js b/lib/parser.js +index 7fa2fc5813450bcc5ff3199e2bfd613409a6087b..50c3ca2673b514a25ac9b74b241eef4c8bc41d88 100644 +--- a/lib/parser.js ++++ b/lib/parser.js +@@ -96,7 +96,7 @@ + err = error1; + if (!this.saxParser.errThrown) { + this.saxParser.errThrown = true; +- return this.emit(err); ++ return this.emit('error', err); + } + } + }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05f2cb90b96..75182537d50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1090,6 +1090,7 @@ patchedDependencies: v-code-diff: 21588de80e591bbc1e5a068d9bce311db5254686443652945d2c7887fdafe9d9 vue-tsc@2.2.8: e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f vuedraggable@4.1.0: eaa2c80f80cdc4293b0f1c9c0410823960f839fc15f155c8cb23666d5950e049 + xml2js@0.6.2: 3efbf117e53f23a62e3e3faa4d02fef8e73d5359bfd50a24e94d32d6f1257bf3 z-vue-scan: bbd573be7e456224f369aafb4d66374523c4847964a4655a6a1654fd40e8b0f8 importers: @@ -5200,7 +5201,7 @@ importers: version: 8.21.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) xml2js: specifier: 'catalog:' - version: 0.6.2 + version: 0.6.2(patch_hash=3efbf117e53f23a62e3e3faa4d02fef8e73d5359bfd50a24e94d32d6f1257bf3) xmllint-wasm: specifier: 3.0.1 version: 3.0.1 @@ -7948,7 +7949,7 @@ importers: version: 3.12.2 xml2js: specifier: 'catalog:' - version: 0.6.2 + version: 0.6.2(patch_hash=3efbf117e53f23a62e3e3faa4d02fef8e73d5359bfd50a24e94d32d6f1257bf3) zod: specifier: 3.25.76 version: 3.25.76 @@ -8573,7 +8574,7 @@ importers: version: 11.1.1 xml2js: specifier: 'catalog:' - version: 0.6.2 + version: 0.6.2(patch_hash=3efbf117e53f23a62e3e3faa4d02fef8e73d5359bfd50a24e94d32d6f1257bf3) devDependencies: '@langchain/core': specifier: 'catalog:' @@ -36636,7 +36637,7 @@ snapshots: bluebird: 3.7.2 crlf-normalize: 1.0.19(ts-toolbelt@9.6.0) tslib: 2.8.1 - xml2js: 0.6.2 + xml2js: 0.6.2(patch_hash=3efbf117e53f23a62e3e3faa4d02fef8e73d5359bfd50a24e94d32d6f1257bf3) transitivePeerDependencies: - ts-toolbelt @@ -45381,7 +45382,7 @@ snapshots: sax: 1.2.4 xmlbuilder: 11.0.1 - xml2js@0.6.2: + xml2js@0.6.2(patch_hash=3efbf117e53f23a62e3e3faa4d02fef8e73d5359bfd50a24e94d32d6f1257bf3): dependencies: sax: 1.2.4 xmlbuilder: 11.0.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 87e5f6b6311..a826f89ddce 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -514,6 +514,7 @@ patchedDependencies: '@chat-adapter/teams@4.37.0': patches/@chat-adapter__teams@4.37.0.patch '@chat-adapter/whatsapp@4.37.0': patches/@chat-adapter__whatsapp@4.37.0.patch '@icetee/ftp@0.3.15': patches/@icetee__ftp@0.3.15.patch + xml2js@0.6.2: patches/xml2js@0.6.2.patch allowBuilds: '@biomejs/biome': false