fix(express,fastify): limit field nesting and array indexes by default

fieldNestingDepth and fieldArrayIndexLimit (#18074) defaulted to
Infinity, so a single request could build an arbitrarily deep req.body
or arrays of arbitrary length. They now default to 32 and 1000 on both
adapters; set them to Infinity to lift the limits.

Discovered by Anthropic's OSS Scanner, as vulnerabilities ANT-2026-LDR3CR1E and ANT-2026-SBX67YG6.
This commit is contained in:
Kamil Myśliwiec committed 2026-10-16 10:53:19 +02:00
1 parent a03c7a1db6
commit 94fcc93770
20 files changed
+352 -47

No files matched your search

@@ -476,6 +476,29 @@ describe('File upload: Express (multer) and Fastify parity', () => {
}
});
it('should limit field name nesting and array indexes by default', async () => {
const ok = await expectParity(http =>
http
.post('/none')
.field('a' + '[b]'.repeat(32), 'x')
.field('c[1000]', 'y'),
);
expect(ok.status).toBe(201);
for (const [name, message] of [
['a' + '[b]'.repeat(33), 'Field name nesting too deep'],
['c[1001]', 'Field name array index too large'],
]) {
const res = await expectParity(http =>
http.post('/none').field(name, 'x'),
);
expect(res.body).toEqual({
statusCode: 400,
error: 'Bad Request',
message: `${message} - ${name}`,
});
}
});
it('should not apply fieldArrayIndexLimit to a name kept as a literal key', async () => {
const res = await expectParity(http =>
http.post('/name-limits').field('a[3]x', '1').field('[3]', '2'),
@@ -13,6 +13,7 @@ import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
transformException,
} from '../multer/multer.utils.js';
@@ -35,7 +36,9 @@ export function AnyFilesInterceptor(
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(mergeMulterOptions(options, localOptions));
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
@@ -16,6 +16,7 @@ import {
MulterOptions,
} from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
transformException,
} from '../multer/multer.utils.js';
@@ -39,7 +40,9 @@ export function FileFieldsInterceptor(
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(mergeMulterOptions(options, localOptions));
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
@@ -13,6 +13,7 @@ import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
transformException,
} from '../multer/multer.utils.js';
@@ -37,7 +38,9 @@ export function FileInterceptor(
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(mergeMulterOptions(options, localOptions));
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
@@ -13,6 +13,7 @@ import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
transformException,
} from '../multer/multer.utils.js';
@@ -40,7 +41,9 @@ export function FilesInterceptor(
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(mergeMulterOptions(options, localOptions));
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
@@ -13,6 +13,7 @@ import { MULTER_MODULE_OPTIONS } from '../files.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
applyDefaultLimits,
mergeMulterOptions,
transformException,
} from '../multer/multer.utils.js';
@@ -35,7 +36,9 @@ export function NoFilesInterceptor(
@Inject(MULTER_MODULE_OPTIONS)
options: MulterModuleOptions = {},
) {
this.multer = (multer as any)(mergeMulterOptions(options, localOptions));
this.multer = (multer as any)(
applyDefaultLimits(mergeMulterOptions(options, localOptions)),
);
}
async intercept(
@@ -16,9 +16,17 @@ export interface MulterLimits {
parts?: number;
/** For multipart forms, the max number of header key=> value pairs to parse Default: 2000(same as node's http). */
headerPairs?: number;
/** Max nesting depth of field names; `a[b][c]` has 2 levels (Default: Infinity) */
/**
* Max nesting depth of field names; `a[b][c]` has 2 levels (Default: 32,
* where multer's own default is Infinity). Set it to `Infinity` to lift
* the limit.
*/
fieldNestingDepth?: number;
/** Max numeric array index accepted in field names; `a[3]` uses index 3 (Default: Infinity) */
/**
* Max numeric array index accepted in field names; `a[3]` uses index 3
* (Default: 1000, where multer's own default is Infinity). Set it to
* `Infinity` to lift the limit.
*/
fieldArrayIndexLimit?: number;
}
@@ -32,8 +40,9 @@ export interface MulterOptions {
/** The storage engine to use for uploaded files. */
storage?: any;
/**
* An object specifying the size limits of the following optional properties. This object is passed to busboy
* directly, and the details of properties can be found on https://github.com/mscdex/busboy#busboy-methods
* An object specifying the size limits of the following optional properties. This object is passed to multer
* and on to busboy, with `fieldNestingDepth` and `fieldArrayIndexLimit` set to their defaults when absent.
* The details of the busboy properties can be found on https://github.com/mscdex/busboy#busboy-methods
*
* Since multer 2.4.0, this can also be a function of the request that returns such an object.
*/
@@ -16,6 +16,18 @@ export const multerExceptions = {
// stays a 500.
};
/**
* Defaults of the field name limits that multer leaves at `Infinity`, which
* let a single small request build an arbitrarily deep `req.body`
* (`fieldNestingDepth`) or arrays of arbitrary length (`fieldArrayIndexLimit`).
* `32` is the nesting depth `body-parser` allows in the urlencoded bodies the
* ExpressAdapter parses; `1000` is the number of parameters it parses.
*/
export const DEFAULT_FIELD_NAME_LIMITS = {
fieldNestingDepth: 32,
fieldArrayIndexLimit: 1000,
} as const;
export const busboyExceptions = {
// from https://github.com/mscdex/busboy/blob/master/lib/types/multipart.js
MULTIPART_BOUNDARY_NOT_FOUND: 'Multipart: Boundary not found',
@@ -4,9 +4,16 @@ import {
PayloadTooLargeException,
} from '@nestjs/common';
import { isPlainObject } from '@nestjs/common/internal';
import { multerExceptions, busboyExceptions } from './multer.constants.js';
import {
DEFAULT_FIELD_NAME_LIMITS,
multerExceptions,
busboyExceptions,
} from './multer.constants.js';
import { MulterModuleOptions } from '../interfaces/index.js';
import { MulterOptions } from '../interfaces/multer-options.interface.js';
import {
MulterLimits,
MulterOptions,
} from '../interfaces/multer-options.interface.js';
/**
* Merges module-level (global) multer options with interceptor-level (local)
@@ -29,6 +36,36 @@ export function mergeMulterOptions(
return merged;
}
/**
* Sets the field name limits multer leaves at `Infinity` to
* `DEFAULT_FIELD_NAME_LIMITS` wherever they are not set. multer only checks
* a limit that is present in `limits`, so the defaults are added to the
* object, or to the object a function-valued `limits` returns.
*/
export function applyDefaultLimits<T extends MulterOptions>(options: T): T {
const { limits } = options;
return {
...options,
limits:
typeof limits === 'function'
? (req: any) => withDefaultLimits(limits(req))
: withDefaultLimits(limits),
};
}
function withDefaultLimits(
limits: MulterLimits | null | undefined,
): MulterLimits {
return {
...limits,
fieldNestingDepth:
limits?.fieldNestingDepth ?? DEFAULT_FIELD_NAME_LIMITS.fieldNestingDepth,
fieldArrayIndexLimit:
limits?.fieldArrayIndexLimit ??
DEFAULT_FIELD_NAME_LIMITS.fieldArrayIndexLimit,
};
}
// Multer may add in a 'field' property to the error
// https://github.com/expressjs/multer/blob/aa42bea6ac7d0cb8fcb279b15a7278cda805dc63/lib/multer-error.js#L19
export function transformException(
@@ -40,10 +40,15 @@ describe('FilesInterceptor', () => {
);
});
});
it('should merge global and local limits key-by-key', () => {
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (AnyFilesInterceptor({ limits: { fileSize: 10 } }))({
limits: { files: 2 },
});
expect((target as any).multer.limits).toEqual({ files: 2, fileSize: 10 });
expect((target as any).multer.limits).toEqual({
files: 2,
fileSize: 10,
fieldNestingDepth: 32,
fieldArrayIndexLimit: 1000,
});
});
});
@@ -59,10 +59,15 @@ describe('FileFieldsInterceptor', () => {
);
});
});
it('should merge global and local limits key-by-key', () => {
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (FileFieldsInterceptor([{ name: 'file', maxCount: 1 }], {
limits: { fileSize: 10 },
}))({ limits: { files: 2 } });
expect((target as any).multer.limits).toEqual({ files: 2, fileSize: 10 });
expect((target as any).multer.limits).toEqual({
files: 2,
fileSize: 10,
fieldNestingDepth: 32,
fieldArrayIndexLimit: 1000,
});
});
});
@@ -41,10 +41,15 @@ describe('FileInterceptor', () => {
);
});
});
it('should merge global and local limits key-by-key', () => {
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (FileInterceptor('file', {
limits: { fileSize: 10 },
}))({ limits: { files: 2 } });
expect((target as any).multer.limits).toEqual({ files: 2, fileSize: 10 });
expect((target as any).multer.limits).toEqual({
files: 2,
fileSize: 10,
fieldNestingDepth: 32,
fieldArrayIndexLimit: 1000,
});
});
});
@@ -43,10 +43,15 @@ describe('FilesInterceptor', () => {
);
});
});
it('should merge global and local limits key-by-key', () => {
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (FilesInterceptor('file', 10, {
limits: { fileSize: 10 },
}))({ limits: { files: 2 } });
expect((target as any).multer.limits).toEqual({ files: 2, fileSize: 10 });
expect((target as any).multer.limits).toEqual({
files: 2,
fileSize: 10,
fieldNestingDepth: 32,
fieldArrayIndexLimit: 1000,
});
});
});
@@ -39,10 +39,15 @@ describe('NoFilesInterceptor', () => {
);
});
});
it('should merge global and local limits key-by-key', () => {
it('should merge global and local limits key-by-key, over the default field name limits', () => {
const target = new (NoFilesInterceptor({ limits: { fileSize: 10 } }))({
limits: { files: 2 },
});
expect((target as any).multer.limits).toEqual({ files: 2, fileSize: 10 });
expect((target as any).multer.limits).toEqual({
files: 2,
fileSize: 10,
fieldNestingDepth: 32,
fieldArrayIndexLimit: 1000,
});
});
});
@@ -9,9 +9,12 @@ import {
multerExceptions,
} from '../../../multer/multer/multer.constants.js';
import {
applyDefaultLimits,
mergeMulterOptions,
transformException,
} from '../../../multer/multer/multer.utils.js';
import { MulterOptions } from '../../../multer/interfaces/multer-options.interface.js';
import { Readable } from 'stream';
describe('mergeMulterOptions', () => {
it('should let local options override top-level global options', () => {
@@ -62,6 +65,125 @@ describe('mergeMulterOptions', () => {
});
});
describe('applyDefaultLimits', () => {
const defaults = { fieldNestingDepth: 32, fieldArrayIndexLimit: 1000 };
it('should set the field name limits when no limits are set', () => {
const options = applyDefaultLimits({ dest: '/uploads' });
expect(options).toEqual({ dest: '/uploads', limits: defaults });
});
it('should keep the other limits and the field name limits that are set', () => {
expect(
applyDefaultLimits({
limits: { fileSize: 10, fieldNestingDepth: 0 },
}).limits,
).toEqual({
fileSize: 10,
fieldNestingDepth: 0,
fieldArrayIndexLimit: 1000,
});
expect(
applyDefaultLimits({
limits: { fieldNestingDepth: Infinity, fieldArrayIndexLimit: Infinity },
}).limits,
).toEqual({ fieldNestingDepth: Infinity, fieldArrayIndexLimit: Infinity });
});
it('should set the field name limits that are null or undefined', () => {
expect(
applyDefaultLimits({
limits: { fieldNestingDepth: undefined, fieldArrayIndexLimit: null! },
}).limits,
).toEqual(defaults);
});
it('should not modify the options it is given', () => {
const options = { limits: { fileSize: 10 } };
applyDefaultLimits(options);
expect(options).toEqual({ limits: { fileSize: 10 } });
});
it('should set the field name limits on the limits a function returns', () => {
const { limits } = applyDefaultLimits({
limits: (req: { size: number }) => ({ fileSize: req.size }),
});
expect((limits as Function)({ size: 10 })).toEqual({
fileSize: 10,
...defaults,
});
expect(
(applyDefaultLimits({ limits: () => undefined! }).limits as Function)({}),
).toEqual(defaults);
});
describe('with multer', () => {
/** Runs `multer(options).none()` over a body with one text field. */
const parseField = (options: MulterOptions, fieldName: string) => {
const boundary = 'nestjs';
const body =
`--${boundary}\r\n` +
`Content-Disposition: form-data; name="${fieldName}"\r\n\r\n` +
`x\r\n` +
`--${boundary}--\r\n`;
const req = Object.assign(Readable.from([Buffer.from(body)]), {
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(Buffer.byteLength(body)),
},
});
return new Promise<any>((resolve, reject) =>
multer(applyDefaultLimits(options)).none()(
req as any,
{} as any,
(err?: any) => (err ? reject(err) : resolve((req as any).body)),
),
);
};
/** Reads `a.b.b...` (`depth` times `b`) out of a parsed body. */
const dig = (body: any, depth: number) => {
let value = body.a;
for (let i = 0; i < depth; i++) {
value = value.b;
}
return value;
};
it('should reject names nested deeper than 32 levels by default', async () => {
expect(dig(await parseField({}, 'a' + '[b]'.repeat(32)), 32)).toBe('x');
await expect(
parseField({}, 'a' + '[b]'.repeat(33)),
).rejects.toMatchObject({ code: 'LIMIT_FIELD_NESTING' });
});
it('should reject array indexes over 1000 by default', async () => {
expect((await parseField({}, 'a[1000]')).a).toHaveLength(1001);
await expect(parseField({}, 'a[1001]')).rejects.toMatchObject({
code: 'LIMIT_FIELD_ARRAY_INDEX',
});
});
it('should apply the defaults to the limits a function returns', async () => {
await expect(
parseField({ limits: () => ({}) }, 'a[1001]'),
).rejects.toMatchObject({ code: 'LIMIT_FIELD_ARRAY_INDEX' });
});
it('should lift the defaults when set to Infinity', async () => {
const limits = {
fieldNestingDepth: Infinity,
fieldArrayIndexLimit: Infinity,
};
const body = await parseField(
{ limits },
'a' + '[b]'.repeat(33) + '[1001]',
);
expect(dig(body, 33)).toHaveLength(1002);
});
});
});
describe('transformException', () => {
describe('if error does not exist', () => {
it('should behave as identity', () => {
@@ -53,15 +53,17 @@ export interface MultipartLimits {
/** For multipart forms, the max number of header key => value pairs to parse (Default: 2000) */
headerPairs?: number;
/**
* Max nesting depth of field names; `a[b][c]` has 2 levels (Default:
* Infinity). Like `fieldNameSize`, it does not fall back to the options
* Max nesting depth of field names; `a[b][c]` has 2 levels (Default: 32,
* where multer's own default is Infinity). Set it to `Infinity` to lift
* the limit. Like `fieldNameSize`, it does not fall back to the options
* `@fastify/multipart` was registered with.
*/
fieldNestingDepth?: number;
/**
* Max numeric array index accepted in field names; `a[3]` uses index 3
* (Default: Infinity). Like `fieldNameSize`, it does not fall back to the
* options `@fastify/multipart` was registered with.
* (Default: 1000, where multer's own default is Infinity). Set it to
* `Infinity` to lift the limit. Like `fieldNameSize`, it does not fall back
* to the options `@fastify/multipart` was registered with.
*/
fieldArrayIndexLimit?: number;
}
@@ -23,6 +23,20 @@ export const multerExceptions = {
export type MultipartErrorCode = keyof typeof multerExceptions;
/**
* Defaults of the field name limits that multer leaves at `Infinity`, which
* let a single small request build an arbitrarily deep `req.body`
* (`fieldNestingDepth`) or arrays of arbitrary length (`fieldArrayIndexLimit`).
* `32` is the nesting depth `body-parser` allows in the urlencoded bodies the
* ExpressAdapter parses; `1000` is the number of parameters it parses, and
* the number of parts `@fastify/multipart` accepts. Kept identical to
* platform-express's `DEFAULT_FIELD_NAME_LIMITS`.
*/
export const DEFAULT_FIELD_NAME_LIMITS = {
fieldNestingDepth: 32,
fieldArrayIndexLimit: 1000,
} as const;
/**
* The messages platform-express reports for busboy 1.x parser errors
* (prefixed with "Multipart: "), which is what multer bundles.
@@ -16,6 +16,7 @@ import { memoryStorage } from '../storage/memory.storage.js';
import { appendField } from './append-field.util.js';
import {
busboyExceptions,
DEFAULT_FIELD_NAME_LIMITS,
MISSING_PLUGIN_MESSAGE,
multerExceptions,
} from './multipart.constants.js';
@@ -333,20 +334,23 @@ function exceedsArrayIndexLimit(name: string, limit: number) {
/**
* `fieldNestingDepth` and `fieldArrayIndexLimit` are multer's own limits
* (busboy knows neither), so they are checked here, against the decoded name.
* Unlike multer, they default to `DEFAULT_FIELD_NAME_LIMITS`.
*/
function checkFieldNameShape(
name: string,
limits: MultipartLimits | undefined,
) {
if (
typeof limits?.fieldNestingDepth === 'number' &&
name.split('[').length - 1 > limits.fieldNestingDepth
) {
const nestingDepth =
limits?.fieldNestingDepth ?? DEFAULT_FIELD_NAME_LIMITS.fieldNestingDepth;
if (name.split('[').length - 1 > nestingDepth) {
throw new MultipartError('LIMIT_FIELD_NESTING', name);
}
const arrayIndexLimit =
limits?.fieldArrayIndexLimit ??
DEFAULT_FIELD_NAME_LIMITS.fieldArrayIndexLimit;
if (
typeof limits?.fieldArrayIndexLimit === 'number' &&
exceedsArrayIndexLimit(name, limits.fieldArrayIndexLimit)
arrayIndexLimit !== Infinity &&
exceedsArrayIndexLimit(name, arrayIndexLimit)
) {
throw new MultipartError('LIMIT_FIELD_ARRAY_INDEX', name);
}
@@ -114,6 +114,21 @@ describe('FileStreamInterceptor', () => {
);
});
it('should apply the default field name limits to the fields before the file', async () => {
const target = new (FileStreamInterceptor('upload'))();
await expect(
target.intercept(
createContext(
createFakeRequest([field('a[1001]', 'x'), file('upload')]),
createReply(),
),
handler,
),
).rejects.toEqual(
new BadRequestException('Field name array index too large - a[1001]'),
);
});
it('should reject a file in another field', async () => {
const target = new (FileStreamInterceptor('upload'))();
const req = createFakeRequest([file('other')]);
@@ -145,23 +145,50 @@ describe('NoFilesInterceptor', () => {
}
});
it('should leave nesting and array indexes unlimited by default', async () => {
const name = 'a[b][c][d][7]';
const expected = { a: { b: { c: { d: new Array(7).concat('x') } } } };
expect(await intercept(new (NoFilesInterceptor())(), name)).toEqual(
expected,
/** `{ a: { b: { b: ... value } } }`, for the name `a[b][b]...`. */
const nest = (depth: number, value: unknown) => {
for (let i = 0; i < depth; i++) {
value = { b: value };
}
return { a: value };
};
it('should limit nesting to 32 levels by default', async () => {
const target = new (NoFilesInterceptor())();
expect(await intercept(target, 'a' + '[b]'.repeat(32))).toEqual(
nest(32, 'x'),
);
expect(
await intercept(
new (NoFilesInterceptor({
limits: {
fieldNestingDepth: Infinity,
fieldArrayIndexLimit: Infinity,
},
}))(),
name,
),
).toEqual(expected);
const name = 'a' + '[b]'.repeat(33);
await expect(intercept(target, name)).rejects.toEqual(
new BadRequestException(`Field name nesting too deep - ${name}`),
);
});
it('should limit array indexes to 1000 by default', async () => {
const target = new (NoFilesInterceptor())();
expect((await intercept(target, 'a[1000]')).a).toHaveLength(1001);
await expect(intercept(target, 'a[1001]')).rejects.toEqual(
new BadRequestException('Field name array index too large - a[1001]'),
);
});
it('should apply the default field name limits to limits resolved per request', async () => {
const target = new (NoFilesInterceptor({ limits: () => ({}) }))();
await expect(intercept(target, 'a[1001]')).rejects.toEqual(
new BadRequestException('Field name array index too large - a[1001]'),
);
});
it('should lift the default field name limits when set to Infinity', async () => {
const name = 'a' + '[b]'.repeat(33) + '[1001]';
const expected = nest(33, new Array(1001).concat('x'));
for (const limits of [
{ fieldNestingDepth: Infinity, fieldArrayIndexLimit: Infinity },
() => ({ fieldNestingDepth: Infinity, fieldArrayIndexLimit: Infinity }),
]) {
const target = new (NoFilesInterceptor({ limits }))();
expect(await intercept(target, name)).toEqual(expected);
}
});
it("should check the field name limits in multer's order", async () => {