fix(core): Create an XML parser for each webhook request body (backport to release-candidate/2.44.x) (#41112)

Co-authored-by: Mike Repeć <mike.repec@n8n.io>
This commit is contained in:
n8n-assistant[bot] 2026-10-14 14:21:43 +00:00 committed by GitHub
parent 16b49eb412
commit c52f1817b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 67 additions and 16 deletions

View File

@ -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<string, string> = {}) => {
@ -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) =>
`<?xml version="1.0"?><Envelope><Body>${'<Item><Name>x</Name></Item>'.repeat(1000)}<Id>${id}</Id></Body></Envelope>`;
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('<root><id>after</id></root>');
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('<a><_>x</_></a>'))).rejects.toThrow(
UnprocessableRequestError,
);
});
});

View File

@ -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,

View File

@ -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);
}
}
};

View File

@ -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

View File

@ -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