fix(core): Strip leading BOM before parsing webhook request bodies (#35933)

This commit is contained in:
Mat Leszkiewicz 2026-08-10 14:45:59 +02:00 committed by GitHub
parent 7784aa2298
commit aa03666bc6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 88 additions and 3 deletions

View File

@ -23,6 +23,14 @@ const xmlParser = new XmlParser({
const payloadSizeMax = Container.get(GlobalConfig).endpoints.payloadSizeMax;
/**
* A leading byte order mark is not valid JSON, but RFC 8259 §8.1 permits
* implementations to ignore it rather than treating it as an error. Both the
* UTF-8 (EF BB BF) and UTF-16 (FF FE) BOMs decode to U+FEFF, so stripping the
* decoded character covers every declared charset.
*/
const stripBom = (text: string) => (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
const isClientAbortError = (error: unknown): boolean =>
error instanceof Error &&
'type' in error &&
@ -87,12 +95,12 @@ export const parseBody = async (req: Request) => {
try {
if (contentType === 'application/json') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
req.body = jsonParse(rawBody.toString(encoding));
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));
} else if (contentType === 'application/x-www-form-urlencoded') {
req.body = parseQueryString(rawBody.toString(encoding), undefined, undefined, {
req.body = parseQueryString(stripBom(rawBody.toString(encoding)), undefined, undefined, {
maxKeys: 1000,
});
} else if (contentType === 'text/plain') {

View File

@ -5,7 +5,8 @@ import request from 'supertest';
import { gzipSync, deflateSync } from 'zlib';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { rawBodyReader, bodyParser } from '@/middlewares/body-parser';
import { UnprocessableRequestError } from '@/errors/response-errors/unprocessable.error';
import { rawBodyReader, bodyParser, parseBody } from '@/middlewares/body-parser';
describe('bodyParser', () => {
const server = createServer((req, res) => {
@ -65,4 +66,80 @@ describe('bodyParser', () => {
await expect(req.readRawBody()).rejects.toThrow(BadRequestError);
});
describe('leading byte order mark', () => {
const utf8Bom = Buffer.from([0xef, 0xbb, 0xbf]);
const utf16leBom = Buffer.from([0xff, 0xfe]);
// supertest/superagent JSON-serializes any non-string `.send()` payload
// whose content-type matches a registered serializer (application/json,
// x-www-form-urlencoded), which would silently mangle these BOM-prefixed
// buffers. An identity `.serialize()` override keeps the raw bytes intact
// on the wire — necessary for a byte-for-byte BOM test.
const identitySerializer = ((data: unknown) => data) as (obj: unknown) => string;
const sendRaw = (body: Buffer) =>
request(server).post('/').serialize(identitySerializer).send(body);
it('should strip a UTF-8 BOM before parsing an application/json body', async () => {
const body = Buffer.concat([utf8Bom, Buffer.from(JSON.stringify({ hello: 'world' }))]);
const response = await sendRaw(body).set('content-type', 'application/json').expect(200);
expect(response.text).toEqual('{"hello":"world"}');
});
it('should parse an application/json body without a BOM unchanged', async () => {
const body = Buffer.from(JSON.stringify({ hello: 'world' }));
const response = await sendRaw(body).set('content-type', 'application/json').expect(200);
expect(response.text).toEqual('{"hello":"world"}');
});
it('should strip a UTF-16LE BOM before parsing an application/json body', async () => {
const body = Buffer.concat([
utf16leBom,
Buffer.from(JSON.stringify({ hello: 'world' }), 'utf16le'),
]);
const response = await sendRaw(body)
.set('content-type', 'application/json; charset=utf-16le')
.expect(200);
expect(response.text).toEqual('{"hello":"world"}');
});
it('should strip a BOM before parsing an x-www-form-urlencoded body', async () => {
const body = Buffer.concat([utf8Bom, Buffer.from('a=1')]);
const response = await sendRaw(body)
.set('content-type', 'application/x-www-form-urlencoded')
.expect(200);
expect(JSON.parse(response.text)).toEqual({ a: '1' });
});
it('should not crash on a body that is only a BOM', async () => {
// The bare http.Server test harness above has no error-handling
// middleware to turn a thrown ResponseError into an HTTP response
// (that's the real app's job), so this asserts against parseBody
// directly rather than hanging the request/response round trip.
const req = {
contentType: 'application/json',
encoding: 'utf8',
rawBody: utf8Bom,
readRawBody: async () => {},
} as unknown as Request;
await expect(parseBody(req)).rejects.toThrow(UnprocessableRequestError);
});
it('should not strip a BOM character inside a JSON string value', async () => {
const valueWithEmbeddedBom = String.fromCharCode(0xfeff) + 'world';
const body = Buffer.concat([
utf8Bom,
Buffer.from(JSON.stringify({ hello: valueWithEmbeddedBom })),
]);
const response = await sendRaw(body).set('content-type', 'application/json').expect(200);
expect(JSON.parse(response.text)).toEqual({ hello: valueWithEmbeddedBom });
});
it('should parse an application/xml body with a leading BOM unchanged', async () => {
const body = Buffer.concat([utf8Bom, Buffer.from('<test><a>1</a></test>')]);
const response = await sendRaw(body).set('content-type', 'application/xml').expect(200);
expect(JSON.parse(response.text)).toEqual({ test: { a: '1' } });
});
});
});