From 513a1991df7e318a8d01ce0eb74a7e13e754a7f3 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Sun, 8 Nov 2026 17:41:32 +0100 Subject: [PATCH 01/17] feat: add a depth option `depth` limits the number of path parts in a path. Every part counts, including the first: `a.b[0]` has 3. A path with more parts throws the new `DepthLimitError` with the path up to the first part over the limit (`a.b[0].c` with `depth: 2` => `DepthLimitError('a.b[0]')`). The default is `Infinity`, so nothing changes for existing users. `depth` must be a positive integer or `Infinity`, otherwise `parseFormData` throws a `TypeError`. Like Rack and Express (body-parser), an exceeded limit throws instead of keeping the rest as a literal key like qs's default. The parser handles any depth itself, but a deep result crashes code that walks it recursively (`JSON.stringify`, validators, deep clones). --- README.md | 25 ++++++++++++++++ scripts/smoke-dist.mjs | 1 + src/__tests__/index.ts | 58 +++++++++++++++++++++++++++++++++++++ src/__tests__/properties.ts | 45 +++++++++++++++++++++++++++- src/index.ts | 45 ++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 109d338..25060be 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,31 @@ parseFormData(formData, {removeEmptyString: true}) // {foo: [{foo: 'test3', baz: '4'}, {bar: 'test2'}]} ``` +### depth: `[number]` + +_Default: `Infinity`_ + +The maximum number of path parts in a path. Every part counts, so `a.b[0]` and +`a[b][]` both have 3. A path with more parts throws a `DepthLimitError` with the +path up to the first part over the limit. Must be a positive integer or +`Infinity`, otherwise `parseFormData` throws a `TypeError`. + +The parser itself handles any depth, but a field like `a.a.a…` with thousands of +parts creates an object that crashes code walking it recursively +(`JSON.stringify`, validators, deep clones). Set a limit that fits your forms +when the input comes from untrusted clients. + +```ts +const formData = new FormData() +formData.append('user.address.street', 'Main St') +parseFormData(formData, {depth: 3}) +// {user: {address: {street: 'Main St'}}} + +formData.append('user.address.geo.lat', '52.5') +parseFormData(formData, {depth: 3}) +// throws DepthLimitError('user.address.geo') +``` + ### transformEntry: `(entry: [path: string, value: string | File], defaultTransform: DefaultTransform) => {path: string, value: JsonLeafValue}` ```ts diff --git a/scripts/smoke-dist.mjs b/scripts/smoke-dist.mjs index a9ed77b..82c547b 100644 --- a/scripts/smoke-dist.mjs +++ b/scripts/smoke-dist.mjs @@ -18,6 +18,7 @@ const entries = [ ] const expected = {a: [{b: 'x', c: 'y'}], n: 1} const expectedExports = [ + 'DepthLimitError', 'DuplicateKeyError', 'ForbiddenKeyError', 'MixedArrayError', diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 2f36e7b..5f70651 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -1,6 +1,7 @@ import {afterEach, describe, expect, it} from 'vitest' import {Blob as RemixBlob, File} from '@remix-run/web-file' import { + DepthLimitError, DuplicateKeyError, ForbiddenKeyError, MixedArrayError, @@ -248,6 +249,63 @@ describe('handle options', () => { }) }) +describe('depth option', () => { + it('has no limit by default', () => { + const path = 'a' + '.a'.repeat(9_999) + expect(() => parseFormData([[path, 'x']])).not.toThrow() + }) + + it.each([ + ['a', 1, {a: 'x'}], + ['a.b', 2, {a: {b: 'x'}}], + ['a[b]', 2, {a: {b: 'x'}}], + ['a[0]', 2, {a: ['x']}], + ['a[]', 2, {a: ['x']}], + ['a.b[0][c]', 4, {a: {b: [{c: 'x'}]}}], + ['a..b', 2, {a: {b: 'x'}}], + ['+a.b', 2, {a: {b: Number('x')}}], + ['a.b', Infinity, {a: {b: 'x'}}], + ])('parses %s with depth %d', (path, depth, expected) => { + expect(parseFormData([[path, 'x']], {depth})).toStrictEqual(expected) + }) + + it.each([ + ['a.b', 1, 'a.b'], + ['a[b]', 1, 'a[b]'], + ['a[]', 1, 'a[]'], + ['a[0]', 1, 'a[0]'], + ['a.b.c', 2, 'a.b.c'], + ['a.b[0].c', 2, 'a.b[0]'], + ['a[b][c][d]', 3, 'a[b][c][d]'], + ])('throws DepthLimitError for %s with depth %d', (path, depth, key) => { + expect(() => parseFormData([[path, 'x']], {depth})).toThrow( + new DepthLimitError(key), + ) + }) + + it('throws for an entry that would be removed, like for any other name', () => { + expect(() => parseFormData([['a.toString.b', 'x']], {depth: 2})).toThrow( + new DepthLimitError('a.toString.b'), + ) + }) + + it('names the path part over the limit in the error', () => { + const error = new DepthLimitError('a.b') + expect(error).toBeInstanceOf(Error) + expect(error.key).toBe('a.b') + expect(error.message).toBe('Depth limit exceeded at path part a.b') + }) + + it.each([0, -1, 1.5, Number.NaN, -Infinity])( + 'throws a TypeError for depth %d, even without entries', + depth => { + expect(() => parseFormData([], {depth})).toThrow( + new TypeError('depth must be a positive integer or Infinity'), + ) + }, + ) +}) + describe('errors', () => { it('throws if value is already set `foo, foo -> throw DuplicateKeyError("foo")`', () => { const formData = new FormData() diff --git a/src/__tests__/properties.ts b/src/__tests__/properties.ts index e41a91f..6ba305c 100644 --- a/src/__tests__/properties.ts +++ b/src/__tests__/properties.ts @@ -1,6 +1,7 @@ import * as fc from 'fast-check' import {describe, expect, it} from 'vitest' import { + DepthLimitError, DuplicateKeyError, ForbiddenKeyError, MixedArrayError, @@ -103,7 +104,12 @@ const entriesArb = ( ): fc.Arbitrary> => fc.array(fc.tuple(pathArb, fc.string()), {maxLength: 6}) -const LIBRARY_ERRORS = [DuplicateKeyError, MixedArrayError, ForbiddenKeyError] +const LIBRARY_ERRORS = [ + DuplicateKeyError, + MixedArrayError, + ForbiddenKeyError, + DepthLimitError, +] function thrownBy(fn: () => unknown): unknown { try { @@ -162,6 +168,33 @@ function outcomeWithoutName(entries: Array): unknown { } } +// Whether `entries` parse to nothing deeper than `depth`, or throw +// `DepthLimitError`. Without a path over the limit, the limit changes nothing: +// the result or error is the same as without it. +function respectsDepth(entries: Array, depth: number): boolean { + try { + const result = parseFormData(entries, {depth}) + return ( + nestingOf(result) <= depth && + JSON.stringify(result) === JSON.stringify(parseFormData(entries)) + ) + } catch (error) { + if (error instanceof DepthLimitError) return true + const unlimited = thrownBy(() => parseFormData(entries)) + return ( + Object.getPrototypeOf(unlimited) === Object.getPrototypeOf(error) && + String(unlimited) === String(error) + ) + } +} + +// How many objects and arrays deep the deepest value is (`{a: {b: 'x'}}` has +// 2). Every value is as deep as its path has parts. +function nestingOf(value: unknown): number { + if (typeof value !== 'object' || value === null) return 0 + return Math.max(0, ...Object.values(value).map(item => 1 + nestingOf(item))) +} + function isNoneOrLibraryError(error: unknown): boolean { return error === undefined || LIBRARY_ERRORS.some(cls => error instanceof cls) } @@ -266,6 +299,16 @@ describe('properties', () => { ) }) + it('limits the nesting to depth or throws DepthLimitError', () => { + fc.assert( + fc.property( + entriesArb(anyPathArb), + fc.integer({min: 1, max: 6}), + (entries, depth) => respectsDepth(entries, depth), + ), + ) + }) + // Inputs here are small (at most 6 entries, indices below 1000). Large // inputs crash with a RangeError, which is covered in `known bugs`. it('only throws the library errors', () => { diff --git a/src/index.ts b/src/index.ts index 2882d5a..a73790d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,6 +93,25 @@ export class ForbiddenKeyError extends Error { this.key = key } } +/** + * Thrown when a path has more path parts than the `depth` option allows. + * `key` is the path up to the first path part over the limit. + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('a.b[0].c', 'x') + * parseFormData(formData, {depth: 2}) + * // throws DepthLimitError('a.b[0]') + * ``` + */ +export class DepthLimitError extends Error { + key: string + constructor(key: string) { + super(`Depth limit exceeded at path part ${key}`) + this.key = key + } +} // Rejected even when a runtime doesn't define them on `Object.prototype` // (e.g. `node --disable-proto=delete`). @@ -208,6 +227,19 @@ type DefaultTransform = typeof defaultTransform * @param transformEntry - a function to transform the FormData entry into a path and a value * (default: `defaultTransform`) * @param removeEmptyString - skip empty values '' (default: `false`) + * @param depth - the maximum number of path parts in a path, counting every + * part (`a.b[0]` has 3); a positive integer or `Infinity` (default: `Infinity`) + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('a.b', 'x') + * parseFormData(formData, {depth: 2}) + * // => {a: {b: 'x'}} + * formData.append('a.c.d', 'y') + * parseFormData(formData, {depth: 2}) + * // throws DepthLimitError('a.c.d') + * ``` * * @example * ```ts @@ -237,6 +269,7 @@ type DefaultTransform = typeof defaultTransform */ export type ParseFormDataOptions = { removeEmptyString?: boolean + depth?: number transformEntry?: ( entry: [path: string, value: string | File], defaultTransform: DefaultTransform, @@ -547,7 +580,12 @@ function handlePathPart( * - `transformEntry` - a function to transform the path and the value before * inserting the value at the path in the resulting object (default: `defaultTransform`) * - `removeEmptyString` - if `true` removes all entries where the value is an empty string + * - `depth` - the maximum number of path parts in a path, counting every part + * (default: `Infinity`) * @returns {JsonObject} the parsed JSON object + * @throws `TypeError` if `depth` isn't a positive integer or `Infinity` + * @throws `DepthLimitError` if a path has more path parts than `depth` + * - e.g. `a.b[0]` with `depth: 2` => `DepthLimitError('a.b[0]')` * @throws `DuplicateKeyError` if * - two entries set a value at the same path * - e.g. `a` and `a`, `a[0]` and `a[0]`, `a.b` and `a[b]` @@ -577,8 +615,12 @@ export function parseFormData( { removeEmptyString = false, transformEntry = defaultTransform, + depth = Infinity, }: ParseFormDataOptions = {}, ): JsonObject { + if (depth !== Infinity && !(Number.isInteger(depth) && depth > 0)) { + throw new TypeError('depth must be a positive integer or Infinity') + } const result: JsonObject = {} // all arrays we need to squash (in place) later @@ -600,6 +642,9 @@ export function parseFormData( const {path, value} = transformEntry(entry, defaultTransform) const pathParts = extractPathParts(path) + if (pathParts.length > depth) { + throw new DepthLimitError(pathParts[depth]!.pathToPart) + } // Removed entries are still built like any other entry, so they cause the // same errors as an unused name, and undone below. const isRemoved = pathParts.some( From 322ed913301dd3108f1dd5ad202d50dc835cfa8a Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Sun, 8 Nov 2026 19:53:26 +0100 Subject: [PATCH 02/17] fix: stop splitting a path at the depth limit extractPathParts takes a maximum number of parts and stops once it has `depth + 1`, so a long path is rejected without splitting all of it. The pass that matched every bracket up front is replaced by closeOf, which finds the matching `]` only when the loop reaches a `[`. Brackets match as before, and the loop continues after the `]`, so the path is still read once. Rejecting a 1M-part path with `depth: 5` takes ~2 ms and ~1 MB instead of ~100 ms and ~180 MB. - README: the example throws DepthLimitError('user.address.geo.lat'), the path including the first part over the limit. - The depth property test checks that a DepthLimitError is thrown for a path deeper than the limit, with the key as its start, instead of accepting any DepthLimitError. - Test that DepthLimitError wins over ForbiddenKeyError. --- README.md | 2 +- src/__tests__/index.ts | 6 +++++ src/__tests__/properties.ts | 40 +++++++++++++++++++++++++++---- src/index.ts | 47 ++++++++++++++++++++++++------------- 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 25060be..9c0b587 100644 --- a/README.md +++ b/README.md @@ -380,7 +380,7 @@ parseFormData(formData, {depth: 3}) formData.append('user.address.geo.lat', '52.5') parseFormData(formData, {depth: 3}) -// throws DepthLimitError('user.address.geo') +// throws DepthLimitError('user.address.geo.lat') ``` ### transformEntry: `(entry: [path: string, value: string | File], defaultTransform: DefaultTransform) => {path: string, value: JsonLeafValue}` diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 5f70651..5beacbd 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -289,6 +289,12 @@ describe('depth option', () => { ) }) + it('throws DepthLimitError before ForbiddenKeyError', () => { + expect(() => parseFormData([['__proto__.a.b', 'x']], {depth: 2})).toThrow( + new DepthLimitError('__proto__.a.b'), + ) + }) + it('names the path part over the limit in the error', () => { const error = new DepthLimitError('a.b') expect(error).toBeInstanceOf(Error) diff --git a/src/__tests__/properties.ts b/src/__tests__/properties.ts index 6ba305c..973d7a5 100644 --- a/src/__tests__/properties.ts +++ b/src/__tests__/properties.ts @@ -6,6 +6,7 @@ import { ForbiddenKeyError, MixedArrayError, parseFormData, + type ParseFormDataOptions, } from '../' import {outcomeWithName} from './utils' @@ -169,25 +170,56 @@ function outcomeWithoutName(entries: Array): unknown { } // Whether `entries` parse to nothing deeper than `depth`, or throw -// `DepthLimitError`. Without a path over the limit, the limit changes nothing: -// the result or error is the same as without it. +// `DepthLimitError` for a path that is deeper. Without a path over the limit, +// the limit changes nothing: the result or error is the same as without it. function respectsDepth(entries: Array, depth: number): boolean { + let lastPath = '' + const transformEntry: ParseFormDataOptions['transformEntry'] = ( + entry, + defaultTransform, + ) => { + const transformed = defaultTransform(entry) + lastPath = transformed.path + return transformed + } try { - const result = parseFormData(entries, {depth}) + const result = parseFormData(entries, {depth, transformEntry}) return ( nestingOf(result) <= depth && JSON.stringify(result) === JSON.stringify(parseFormData(entries)) ) } catch (error) { - if (error instanceof DepthLimitError) return true + // The depth is checked right after the transform, so the last path is + // the one that threw. + if (error instanceof DepthLimitError) { + return isOverDepth(lastPath, error.key, depth) + } const unlimited = thrownBy(() => parseFormData(entries)) return ( + unlimited instanceof Error && Object.getPrototypeOf(unlimited) === Object.getPrototypeOf(error) && String(unlimited) === String(error) ) } } +// Whether `path` starts with `key` and nests deeper than `depth` when parsed +// alone without a limit. A path that can't be built (a forbidden key) or is +// removed (a name on `Object.prototype`) has no nesting to check. +function isOverDepth(path: string, key: string, depth: number): boolean { + if (!path.startsWith(key)) return false + try { + const nesting = nestingOf( + parseFormData([[path, 'x']], { + transformEntry: ([entryPath, value]) => ({path: entryPath, value}), + }), + ) + return nesting === 0 || nesting > depth + } catch (error) { + return error instanceof ForbiddenKeyError + } +} + // How many objects and arrays deep the deepest value is (`{a: {b: 'x'}}` has // 2). Every value is as deep as its path has parts. function nestingOf(value: unknown): number { diff --git a/src/index.ts b/src/index.ts index a73790d..0dfca78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -298,11 +298,33 @@ type PathPart = { default: JsonObject | JsonArray pathToPart: string } +/** + * Returns where the bracket part of the `[` at `open` ends, or -1 if it's part + * of the key. Brackets nest like in qs (`a[b[c]]`: the last `]` closes the + * first `[`). A `[` that no `]` closes ends at the last `]` (`a[[0]` => key + * `[0`). Without a `]` after it, it's part of the key (`a[0` => key `a[0`), + * like a `]` that closes no `[` (`a]b` => key `a]b`, `a[b]]` => key `]` in + * `b`). + * + * The scan stops at the returned `]`, where the caller continues, so the + * scans of a path don't overlap. + */ +function closeOf(path: string, open: number, lastClose: number): number { + if (open > lastClose) return -1 + let depth = 0 + for (let idx = open; idx < lastClose; idx++) { + if (path[idx] === '[') depth++ + else if (path[idx] === ']' && --depth === 0) return idx + } + return lastClose +} /** * * Transforms a FormData path into an array of `PathPart`s. * * @param path - the path to extract the path parts from + * @param maxParts - stop after this many path parts (default: `Infinity`). + * The `default` of the last returned part is only right if the path ends there. * @returns {Array} the extracted path parts * * @example @@ -342,15 +364,7 @@ type PathPart = { * * */ -function extractPathParts(path: string): Array { - // Where the bracket part of each `[` ends. Brackets nest like in qs - // (`a[b[c]]`: the last `]` closes the first `[`). - const closeOf = new Map() - const open: Array = [] - for (let idx = 0; idx < path.length; idx++) { - if (path[idx] === '[') open.push(idx) - else if (path[idx] === ']' && open.length > 0) closeOf.set(open.pop()!, idx) - } +function extractPathParts(path: string, maxParts = Infinity): Array { const lastClose = path.lastIndexOf(']') const parts: Array = [] @@ -361,11 +375,8 @@ function extractPathParts(path: string): Array { } let start = 0 - for (let idx = 0; idx < path.length; idx++) { - // A `[` that no `]` closes ends at the last `]` (`a[[0]` => key `[0`). - // Without a `]` after it, it's part of the key (`a[0` => key `a[0`), like - // a `]` that closes no `[` (`a]b` => key `a]b`, `a[b]]` => key `]` in `b`). - const close = path[idx] === '[' ? (closeOf.get(idx) ?? lastClose) : -1 + for (let idx = 0; idx < path.length && parts.length < maxParts; idx++) { + const close = path[idx] === '[' ? closeOf(path, idx, lastClose) : -1 if (path[idx] !== '.' && close < idx) continue addPart(path.slice(start, idx), 'object', idx) @@ -378,7 +389,11 @@ function extractPathParts(path: string): Array { } start = idx + 1 } - addPart(path.slice(start), 'object', path.length) + if (parts.length < maxParts) { + addPart(path.slice(start), 'object', path.length) + } + // The bracket part can add one more after the limit was reached. + if (parts.length > maxParts) parts.length = maxParts // A part holds an array if the next part is an array index. parts.forEach((part, idx) => { @@ -641,7 +656,7 @@ export function parseFormData( if (removeEmptyString && entry[1] === '') continue const {path, value} = transformEntry(entry, defaultTransform) - const pathParts = extractPathParts(path) + const pathParts = extractPathParts(path, depth + 1) if (pathParts.length > depth) { throw new DepthLimitError(pathParts[depth]!.pathToPart) } From 7af4ef7e7760fb0cf3cf77243854ce28234943ee Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Sun, 8 Nov 2026 20:30:37 +0100 Subject: [PATCH 03/17] fix: drop the uncovered part limit trim and widen the depth test oracle extractPathParts no longer trims the extra part a bracket can add after `maxParts` is reached. parseFormData only reads the part at `depth`, so the trim did nothing, and no test reached it, which broke the 100% coverage threshold. The depth property test accepted only ForbiddenKeyError from a path that can't be built alone. A path starting with an array part (`[]a`) throws DuplicateKeyError on the root object without a limit and DepthLimitError with `depth: 1`, so any library error is accepted there. --- src/__tests__/properties.ts | 7 ++++--- src/index.ts | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/__tests__/properties.ts b/src/__tests__/properties.ts index 973d7a5..75cd000 100644 --- a/src/__tests__/properties.ts +++ b/src/__tests__/properties.ts @@ -204,8 +204,9 @@ function respectsDepth(entries: Array, depth: number): boolean { } // Whether `path` starts with `key` and nests deeper than `depth` when parsed -// alone without a limit. A path that can't be built (a forbidden key) or is -// removed (a name on `Object.prototype`) has no nesting to check. +// alone without a limit. A path that can't be built (a forbidden key, or an +// array part on the root like `[]a`) or is removed (a name on +// `Object.prototype`) has no nesting to check. function isOverDepth(path: string, key: string, depth: number): boolean { if (!path.startsWith(key)) return false try { @@ -216,7 +217,7 @@ function isOverDepth(path: string, key: string, depth: number): boolean { ) return nesting === 0 || nesting > depth } catch (error) { - return error instanceof ForbiddenKeyError + return isNoneOrLibraryError(error) } } diff --git a/src/index.ts b/src/index.ts index 0dfca78..9d061e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -323,8 +323,9 @@ function closeOf(path: string, open: number, lastClose: number): number { * Transforms a FormData path into an array of `PathPart`s. * * @param path - the path to extract the path parts from - * @param maxParts - stop after this many path parts (default: `Infinity`). - * The `default` of the last returned part is only right if the path ends there. + * @param maxParts - stop once there are this many path parts (default: + * `Infinity`). A bracket part can add one more. The `default` of the last + * returned part is only right if the path ends there. * @returns {Array} the extracted path parts * * @example @@ -392,8 +393,6 @@ function extractPathParts(path: string, maxParts = Infinity): Array { if (parts.length < maxParts) { addPart(path.slice(start), 'object', path.length) } - // The bracket part can add one more after the limit was reached. - if (parts.length > maxParts) parts.length = maxParts // A part holds an array if the next part is an array index. parts.forEach((part, idx) => { From f513c9a85f89db721a371030185f06b8b063125b Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Sun, 8 Nov 2026 20:49:12 +0100 Subject: [PATCH 04/17] test: cover removed entries within the depth limit and list depth in the README TOC --- README.md | 1 + src/__tests__/index.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/README.md b/README.md index 9c0b587..2f80a03 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ object out of the values. You can tell the parser to do not parse empty values. - [Advanced usage](#advanced-usage) - [Options](#options) - [removeEmptyString: `[boolean]`](#removeemptystring-boolean) + - [depth: `[number]`](#depth-number) - [transformEntry: `(entry: [path: string, value: string | File], defaultTransform: DefaultTransform) => {path: string, value: JsonLeafValue}`](#transformentry-entry-path-string-value-string--file-defaulttransform-defaulttransform--path-string-value-jsonleafvalue) - [Issues](#issues) - [🐛 Bugs](#-bugs) diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 5beacbd..6d229a5 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -283,6 +283,18 @@ describe('depth option', () => { ) }) + it.each([ + ['toString', 1], + ['a.toString', 2], + ['a[toString]', 2], + ['a.toString.b', 3], + ])( + 'removes %s with depth %d without throwing, like for any other name', + (path, depth) => { + expect(parseFormData([[path, 'x']], {depth})).toStrictEqual({}) + }, + ) + it('throws for an entry that would be removed, like for any other name', () => { expect(() => parseFormData([['a.toString.b', 'x']], {depth: 2})).toThrow( new DepthLimitError('a.toString.b'), From 6a12a73700e18e22893a8dedc77287978dee6859 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Sun, 8 Nov 2026 21:02:26 +0100 Subject: [PATCH 05/17] refactor: rename the bracket counter in closeOf --- src/index.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 9d061e9..53e4650 100644 --- a/src/index.ts +++ b/src/index.ts @@ -311,10 +311,10 @@ type PathPart = { */ function closeOf(path: string, open: number, lastClose: number): number { if (open > lastClose) return -1 - let depth = 0 + let nesting = 0 for (let idx = open; idx < lastClose; idx++) { - if (path[idx] === '[') depth++ - else if (path[idx] === ']' && --depth === 0) return idx + if (path[idx] === '[') nesting++ + else if (path[idx] === ']' && --nesting === 0) return idx } return lastClose } From b5b9a8b82e9073e8cd414fa36f02ac7a6e34c12a Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Sun, 8 Nov 2026 22:10:19 +0100 Subject: [PATCH 06/17] refactor: throw the depth limit error while extracting path parts extractPathParts now takes the depth and throws DepthLimitError at the first part over the limit, so it no longer returns a cut-off list for the caller to check. This removes the maxParts parameter and its caveats. --- src/index.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 53e4650..dc427b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -323,10 +323,10 @@ function closeOf(path: string, open: number, lastClose: number): number { * Transforms a FormData path into an array of `PathPart`s. * * @param path - the path to extract the path parts from - * @param maxParts - stop once there are this many path parts (default: - * `Infinity`). A bracket part can add one more. The `default` of the last - * returned part is only right if the path ends there. + * @param depth - the maximum number of path parts (default: `Infinity`) * @returns {Array} the extracted path parts + * @throws `DepthLimitError` if the path has more path parts than `depth`, at + * the first part over the limit * * @example * ```ts @@ -365,18 +365,19 @@ function closeOf(path: string, open: number, lastClose: number): number { * * */ -function extractPathParts(path: string, maxParts = Infinity): Array { +function extractPathParts(path: string, depth = Infinity): Array { const lastClose = path.lastIndexOf(']') const parts: Array = [] const addPart = (key: string, type: PathPart['type'], end: number): void => { // Empty keys come from `a..b`, `a.`, `.a` or a `.` or `[` after `]`. if (key === '' && type === 'object') return + if (parts.length === depth) throw new DepthLimitError(path.slice(0, end)) parts.push({path: key, type, default: {}, pathToPart: path.slice(0, end)}) } let start = 0 - for (let idx = 0; idx < path.length && parts.length < maxParts; idx++) { + for (let idx = 0; idx < path.length; idx++) { const close = path[idx] === '[' ? closeOf(path, idx, lastClose) : -1 if (path[idx] !== '.' && close < idx) continue @@ -390,9 +391,7 @@ function extractPathParts(path: string, maxParts = Infinity): Array { } start = idx + 1 } - if (parts.length < maxParts) { - addPart(path.slice(start), 'object', path.length) - } + addPart(path.slice(start), 'object', path.length) // A part holds an array if the next part is an array index. parts.forEach((part, idx) => { @@ -655,10 +654,7 @@ export function parseFormData( if (removeEmptyString && entry[1] === '') continue const {path, value} = transformEntry(entry, defaultTransform) - const pathParts = extractPathParts(path, depth + 1) - if (pathParts.length > depth) { - throw new DepthLimitError(pathParts[depth]!.pathToPart) - } + const pathParts = extractPathParts(path, depth) // Removed entries are still built like any other entry, so they cause the // same errors as an unused name, and undone below. const isRemoved = pathParts.some( From 9109a5d0cd0783ca4a42420ef32d690edd00422b Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Sun, 8 Nov 2026 22:18:39 +0100 Subject: [PATCH 07/17] test: check the exact depth outcome in the property test The depth property now works out the expected outcome from unlimited parses and compares it to the parse with a limit. It counts the parts of removed, forbidden and root array paths by renaming those names and putting the path under a root key, and checks the DepthLimitError key exactly instead of as any prefix. A path generator built from whole path parts makes the limit and these names show up in most runs. --- src/__tests__/properties.ts | 153 ++++++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 52 deletions(-) diff --git a/src/__tests__/properties.ts b/src/__tests__/properties.ts index 75cd000..33f9306 100644 --- a/src/__tests__/properties.ts +++ b/src/__tests__/properties.ts @@ -6,7 +6,6 @@ import { ForbiddenKeyError, MixedArrayError, parseFormData, - type ParseFormDataOptions, } from '../' import {outcomeWithName} from './utils' @@ -100,6 +99,30 @@ const anyPathArb = fc // Large array indices take seconds to parse (see the expected failure at // the bottom of this file). Keep indices small so the suite stays fast. .filter(path => !/\d{4}/.test(path)) +// Paths built from whole path parts, so they often have more parts than the +// depth limit. `toString` is removed from the result, `__proto__` throws and a +// root array part (`[0]a`) can't be built. +const partsPathArb = fc + .tuple( + prefixArb, + fc.array( + fc.constantFrom( + 'a', + '.b', + '[c]', + '[0]', + '[1]', + '[]', + '.toString', + '[__proto__]', + '.', + '[', + ']', + ), + {maxLength: 8}, + ), + ) + .map(([prefix, parts]) => prefix + parts.join('')) const entriesArb = ( pathArb: fc.Arbitrary, ): fc.Arbitrary> => @@ -169,63 +192,85 @@ function outcomeWithoutName(entries: Array): unknown { } } -// Whether `entries` parse to nothing deeper than `depth`, or throw -// `DepthLimitError` for a path that is deeper. Without a path over the limit, -// the limit changes nothing: the result or error is the same as without it. -function respectsDepth(entries: Array, depth: number): boolean { - let lastPath = '' - const transformEntry: ParseFormDataOptions['transformEntry'] = ( - entry, - defaultTransform, - ) => { - const transformed = defaultTransform(entry) - lastPath = transformed.path - return transformed - } +// The outcome of a parse: the result, or the class and message of the error. +type Outcome = {result: unknown} | {error: unknown; message: string} +function outcomeOf(parse: () => unknown): Outcome { try { - const result = parseFormData(entries, {depth, transformEntry}) - return ( - nestingOf(result) <= depth && - JSON.stringify(result) === JSON.stringify(parseFormData(entries)) - ) + return {result: parse()} } catch (error) { - // The depth is checked right after the transform, so the last path is - // the one that threw. - if (error instanceof DepthLimitError) { - return isOverDepth(lastPath, error.key, depth) - } - const unlimited = thrownBy(() => parseFormData(entries)) - return ( - unlimited instanceof Error && - Object.getPrototypeOf(unlimited) === Object.getPrototypeOf(error) && - String(unlimited) === String(error) - ) + const type = error instanceof Error ? error.constructor : error + return {error: type, message: String(error)} } } -// Whether `path` starts with `key` and nests deeper than `depth` when parsed -// alone without a limit. A path that can't be built (a forbidden key, or an -// array part on the root like `[]a`) or is removed (a name on -// `Object.prototype`) has no nesting to check. -function isOverDepth(path: string, key: string, depth: number): boolean { - if (!path.startsWith(key)) return false - try { - const nesting = nestingOf( - parseFormData([[path, 'x']], { - transformEntry: ([entryPath, value]) => ({path: entryPath, value}), - }), - ) - return nesting === 0 || nesting > depth - } catch (error) { - return isNoneOrLibraryError(error) +// The outcome of `entries` with `depth`, worked out without the limit. Entries +// are built in order, so it's the unlimited outcome up to the first entry with +// more path parts than `depth`, which throws `DepthLimitError` with the path up +// to its part `depth + 1`. +function expectedWithDepth(entries: Array, depth: number): Outcome { + const paths = entries.map(entry => transformedPath(entry)) + const tooDeep = paths.findIndex(path => partsOf(path).length > depth) + if (tooDeep === -1) return outcomeOf(() => parseFormData(entries)) + const before = outcomeOf(() => parseFormData(entries.slice(0, tooDeep))) + if ('error' in before) return before + const path = paths[tooDeep]! + return outcomeOf(() => { + throw new DepthLimitError(keyAt(path, depth)) + }) +} + +// The path of `entry` after the default transform (`+a` => `a`). +function transformedPath(entry: Entry): string { + let path = '' + parseFormData([entry], { + transformEntry: (item, defaultTransform) => { + path = defaultTransform(item).path + return {path: 'a', value: ''} + }, + }) + return path +} + +// Names on Object.prototype are removed from the result, and `__proto__`, +// `constructor` and `prototype` throw. `partsOf` renames them to `ǂ`, which is +// never generated, so they are built like any other key. +const UNBUILT_NAME = new RegExp( + [...Object.getOwnPropertyNames(Object.prototype), 'prototype'].join('|'), +) + +// The keys of the path parts of `path` (`a.b[0]` => `.a`, `.b`, `[]0`; array +// indices are squashed). Under the root key `r`, a path that starts with an +// array index is built too. +function partsOf(path: string): Array { + const built = parseFormData( + [[`r.${path.split(UNBUILT_NAME).join('ǂ')}`, 'x']], + { + transformEntry: ([entryPath, value]) => ({path: entryPath, value}), + }, + ) + const parts: Array = [] + let container: unknown = built + while (isContainer(container)) { + const [key, item] = Object.entries(container)[0]! + parts.push(`${Array.isArray(container) ? '[]' : '.'}${key}`) + container = item } + return parts.slice(1) +} + +function isContainer(value: unknown): value is Record { + return typeof value === 'object' && value !== null } -// How many objects and arrays deep the deepest value is (`{a: {b: 'x'}}` has -// 2). Every value is as deep as its path has parts. -function nestingOf(value: unknown): number { - if (typeof value !== 'object' || value === null) return 0 - return Math.max(0, ...Object.values(value).map(item => 1 + nestingOf(item))) +// The shortest start of `path` with its first `depth + 1` path parts. +function keyAt(path: string, depth: number): string { + const expected = JSON.stringify(partsOf(path).slice(0, depth + 1)) + for (let end = 0; end < path.length; end++) { + if (JSON.stringify(partsOf(path.slice(0, end))) === expected) { + return path.slice(0, end) + } + } + return path } function isNoneOrLibraryError(error: unknown): boolean { @@ -335,9 +380,13 @@ describe('properties', () => { it('limits the nesting to depth or throws DepthLimitError', () => { fc.assert( fc.property( - entriesArb(anyPathArb), + entriesArb(fc.oneof(anyPathArb, partsPathArb)), fc.integer({min: 1, max: 6}), - (entries, depth) => respectsDepth(entries, depth), + (entries, depth) => { + expect( + outcomeOf(() => parseFormData(entries, {depth})), + ).toStrictEqual(expectedWithDepth(entries, depth)) + }, ), ) }) From ea253a434e4b41b53769a9386a46eab27654a925 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 09:31:49 +0100 Subject: [PATCH 08/17] test: find the depth error key by where its part ends Different starts of a path can have the same parts: 'a.[' and 'a.[[]' both parse to a, [. keyAt took the shortest one, so the property failed for 'a.[[]' with depth 1 although the parser was right. It now inserts a marker part at each end and takes the first end where the marker becomes the next part. --- src/__tests__/properties.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/__tests__/properties.ts b/src/__tests__/properties.ts index 33f9306..0cc6674 100644 --- a/src/__tests__/properties.ts +++ b/src/__tests__/properties.ts @@ -262,15 +262,21 @@ function isContainer(value: unknown): value is Record { return typeof value === 'object' && value !== null } -// The shortest start of `path` with its first `depth + 1` path parts. +// The start of `path` up to the end of its part `depth + 1`. Different starts +// can have the same parts (`a.[` and `a.[[]` both have `a`, `[`), so the end +// is where a marker part `ǁ` inserted into the path becomes the next part: +// `a.[[].ǁ.` has `a`, `[`, `ǁ`, but the `[` in `a.[.ǁ.[]` takes in the +// marker. Of the ends where it does (`a.b` and `a.b.` in `a.b.c`), the part +// ends at the first. function keyAt(path: string, depth: number): string { - const expected = JSON.stringify(partsOf(path).slice(0, depth + 1)) - for (let end = 0; end < path.length; end++) { - if (JSON.stringify(partsOf(path.slice(0, end))) === expected) { + const expected = JSON.stringify([...partsOf(path).slice(0, depth + 1), '.ǁ']) + for (let end = 0; end <= path.length; end++) { + const marked = `${path.slice(0, end)}.ǁ.${path.slice(end)}` + if (JSON.stringify(partsOf(marked).slice(0, depth + 2)) === expected) { return path.slice(0, end) } } - return path + throw new Error(`no end for part ${depth + 1} of ${path}`) } function isNoneOrLibraryError(error: unknown): boolean { From 32c4db1faee59e15281cd429106e4ff96a75f02b Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 09:53:12 +0100 Subject: [PATCH 09/17] docs: explain closeOf in plainer words --- src/index.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index dc427b0..dcb4579 100644 --- a/src/index.ts +++ b/src/index.ts @@ -299,15 +299,22 @@ type PathPart = { pathToPart: string } /** - * Returns where the bracket part of the `[` at `open` ends, or -1 if it's part - * of the key. Brackets nest like in qs (`a[b[c]]`: the last `]` closes the - * first `[`). A `[` that no `]` closes ends at the last `]` (`a[[0]` => key - * `[0`). Without a `]` after it, it's part of the key (`a[0` => key `a[0`), - * like a `]` that closes no `[` (`a]b` => key `a]b`, `a[b]]` => key `]` in - * `b`). - * - * The scan stops at the returned `]`, where the caller continues, so the - * scans of a path don't overlap. + * Finds the `]` that ends the bracket part starting at the `[` at `open`. + * Returns -1 if this `[` is not a bracket part but a normal character of the key. + * + * Brackets can be nested, like in qs: in `a[b[c]]` the last `]` belongs to + * the first `[`. + * + * Special cases: + * - Some `[` are never closed. The part then ends at the last `]`: + * `a[[0]` => key `[0`. + * - If there is no `]` after the `[` at all, the `[` is a normal character: + * `a[0` => key `a[0`. + * - The same goes for a `]` without a `[`: `a]b` => key `a]b`, and + * `a[b]]` => key `]` inside `b`. + * + * The scan stops at the returned `]`. The caller continues from there, so no + * character of the path is scanned twice. */ function closeOf(path: string, open: number, lastClose: number): number { if (open > lastClose) return -1 From ec17c58e24d4fdf1b8cf33bc1d00ed48943cf4db Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 09:53:13 +0100 Subject: [PATCH 10/17] feat: show the given value in the depth TypeError --- src/__tests__/index.ts | 4 +++- src/index.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 6d229a5..3d8010e 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -318,7 +318,9 @@ describe('depth option', () => { 'throws a TypeError for depth %d, even without entries', depth => { expect(() => parseFormData([], {depth})).toThrow( - new TypeError('depth must be a positive integer or Infinity'), + new TypeError( + `depth must be a positive integer or Infinity, got ${depth}`, + ), ) }, ) diff --git a/src/index.ts b/src/index.ts index dcb4579..4c842af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -639,7 +639,9 @@ export function parseFormData( }: ParseFormDataOptions = {}, ): JsonObject { if (depth !== Infinity && !(Number.isInteger(depth) && depth > 0)) { - throw new TypeError('depth must be a positive integer or Infinity') + throw new TypeError( + `depth must be a positive integer or Infinity, got ${String(depth)}`, + ) } const result: JsonObject = {} From 4141d31181ce79e58268e61e93de6f388d3fd090 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 10:02:16 +0100 Subject: [PATCH 11/17] docs: recommend a depth limit of 32 --- README.md | 5 +++-- src/index.ts | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2f80a03..ab3b7c5 100644 --- a/README.md +++ b/README.md @@ -370,8 +370,9 @@ path up to the first part over the limit. Must be a positive integer or The parser itself handles any depth, but a field like `a.a.a…` with thousands of parts creates an object that crashes code walking it recursively -(`JSON.stringify`, validators, deep clones). Set a limit that fits your forms -when the input comes from untrusted clients. +(`JSON.stringify`, validators, deep clones). When the input comes from untrusted +clients, set a limit; `depth: 32` fits nearly every form. Version 2.0 will lower +the default from `Infinity`. ```ts const formData = new FormData() diff --git a/src/index.ts b/src/index.ts index 4c842af..2dbbb16 100644 --- a/src/index.ts +++ b/src/index.ts @@ -228,7 +228,8 @@ type DefaultTransform = typeof defaultTransform * (default: `defaultTransform`) * @param removeEmptyString - skip empty values '' (default: `false`) * @param depth - the maximum number of path parts in a path, counting every - * part (`a.b[0]` has 3); a positive integer or `Infinity` (default: `Infinity`) + * part (`a.b[0]` has 3); a positive integer or `Infinity` (default: `Infinity`). + * For untrusted input, `32` is recommended; 2.0 will lower the default. * * @example * ```ts From d108d0a8e843e74c2913ce04308731f2cec0d3b6 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 10:16:04 +0100 Subject: [PATCH 12/17] test: explain the expected depth outcome in plainer words --- src/__tests__/properties.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/__tests__/properties.ts b/src/__tests__/properties.ts index 0cc6674..3eb2aa1 100644 --- a/src/__tests__/properties.ts +++ b/src/__tests__/properties.ts @@ -203,10 +203,12 @@ function outcomeOf(parse: () => unknown): Outcome { } } -// The outcome of `entries` with `depth`, worked out without the limit. Entries -// are built in order, so it's the unlimited outcome up to the first entry with -// more path parts than `depth`, which throws `DepthLimitError` with the path up -// to its part `depth + 1`. +// What `entries` should give with `depth`, worked out without using the limit. +// Entries are read one by one, in order. Every entry before the first one with +// more than `depth` path parts gives the same result as with no limit, and that +// includes any error they throw. If none of them throws, the first entry with +// too many parts throws `DepthLimitError`. The error shows its path only up to +// the end of part number `depth + 1`. function expectedWithDepth(entries: Array, depth: number): Outcome { const paths = entries.map(entry => transformedPath(entry)) const tooDeep = paths.findIndex(path => partsOf(path).length > depth) From 3ca8477ff2fddcbf8e4a925b10c9ca7542b6a067 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 10:39:53 +0100 Subject: [PATCH 13/17] refactor: split the scanner, transform, errors and types into modules index.ts keeps parseFormData and the helpers that share its state. The scanner and defaultTransform can now be tested on their own. The license header moves to a tsdown banner so it stays on top of every bundle and the .d.ts. --- src/errors.ts | 109 +++++++++++++++ src/index.ts | 334 ++-------------------------------------------- src/path-parts.ts | 138 +++++++++++++++++++ src/transform.ts | 72 ++++++++++ src/types.ts | 13 ++ tsdown.config.mts | 10 ++ 6 files changed, 352 insertions(+), 324 deletions(-) create mode 100644 src/errors.ts create mode 100644 src/path-parts.ts create mode 100644 src/transform.ts create mode 100644 src/types.ts diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..3d68c89 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,109 @@ +// The errors `parseFormData` throws. All of them are exported from the package. + +/** + * Thrown when a path is used multiple times or has mismatching path parts. + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('a[0]', 'b') + * formData.append('a[0]', 'c') + * parseFormData(formData) + * // throws DuplicateKeyError('a[0]') + * ``` + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('a', 'b') + * formData.append('a', 'c') + * parseFormData(formData) + * // throws DuplicateKeyError('a') + * ``` + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('a', 'b') + * formData.append('a[]', 'c') + * parseFormData(formData) + * // throws DuplicateKeyError('a[]') + * ``` + * + */ +export class DuplicateKeyError extends Error { + key: string + constructor(key: string) { + super(`Duplicate key at path part ${key}`) + this.key = key + } +} +/** + * Thrown when an array is used at the same path with an order parameter and + * without an order parameter. + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('a[0]', 'a') + * formData.append('a[]', 'b') + * parseFormData(formData) + * // => throws `MixedArrayError(a[])` + * ``` + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('a[]', 'a') + * formData.append('a[0]', 'b') + * parseFormData(formData) + * // => throws `MixedArrayError(a[0])` + * ``` + */ +export class MixedArrayError extends Error { + key: string + constructor(key: string) { + super(`Mixed array at path part ${key}`) + this.key = key + } +} +/** + * Thrown when an object path part is `__proto__`, `constructor` or + * `prototype`, which could modify `Object.prototype`. The names are rejected + * at any position in the path. Entries with other names on `Object.prototype` + * (`toString`, ...) are removed instead (see `parseFormData`). + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('__proto__.polluted', 'yes') + * parseFormData(formData) + * // throws ForbiddenKeyError('__proto__') + * ``` + */ +export class ForbiddenKeyError extends Error { + key: string + constructor(key: string) { + super(`Forbidden key at path part ${key}`) + this.key = key + } +} +/** + * Thrown when a path has more path parts than the `depth` option allows. + * `key` is the path up to the first path part over the limit. + * + * @example + * ```ts + * const formData = new FormData() + * formData.append('a.b[0].c', 'x') + * parseFormData(formData, {depth: 2}) + * // throws DepthLimitError('a.b[0]') + * ``` + */ +export class DepthLimitError extends Error { + key: string + constructor(key: string) { + super(`Depth limit exceeded at path part ${key}`) + this.key = key + } +} diff --git a/src/index.ts b/src/index.ts index 2dbbb16..355fb78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,117 +1,14 @@ -/** - * @name parse-nested-form-data - * @license MIT license. - * @copyright (c) 2022 Christian Schurr - * @author Christian Schurr - */ +import { + DepthLimitError, + DuplicateKeyError, + ForbiddenKeyError, + MixedArrayError, +} from './errors' +import {extractPathParts, type PathPart} from './path-parts' +import {defaultTransform, type DefaultTransform} from './transform' +import type {JsonArray, JsonLeafValue, JsonObject, JsonValue} from './types' -/** - * Thrown when a path is used multiple times or has mismatching path parts. - * - * @example - * ```ts - * const formData = new FormData() - * formData.append('a[0]', 'b') - * formData.append('a[0]', 'c') - * parseFormData(formData) - * // throws DuplicateKeyError('a[0]') - * ``` - * - * @example - * ```ts - * const formData = new FormData() - * formData.append('a', 'b') - * formData.append('a', 'c') - * parseFormData(formData) - * // throws DuplicateKeyError('a') - * ``` - * - * @example - * ```ts - * const formData = new FormData() - * formData.append('a', 'b') - * formData.append('a[]', 'c') - * parseFormData(formData) - * // throws DuplicateKeyError('a[]') - * ``` - * - */ -export class DuplicateKeyError extends Error { - key: string - constructor(key: string) { - super(`Duplicate key at path part ${key}`) - this.key = key - } -} -/** - * Thrown when an array is used at the same path with an order parameter and - * without an order parameter. - * - * @example - * ```ts - * const formData = new FormData() - * formData.append('a[0]', 'a') - * formData.append('a[]', 'b') - * parseFormData(formData) - * // => throws `MixedArrayError(a[])` - * ``` - * - * @example - * ```ts - * const formData = new FormData() - * formData.append('a[]', 'a') - * formData.append('a[0]', 'b') - * parseFormData(formData) - * // => throws `MixedArrayError(a[0])` - * ``` - */ -export class MixedArrayError extends Error { - key: string - constructor(key: string) { - super(`Mixed array at path part ${key}`) - this.key = key - } -} -/** - * Thrown when an object path part is `__proto__`, `constructor` or - * `prototype`, which could modify `Object.prototype`. The names are rejected - * at any position in the path. Entries with other names on `Object.prototype` - * (`toString`, ...) are removed instead (see `parseFormData`). - * - * @example - * ```ts - * const formData = new FormData() - * formData.append('__proto__.polluted', 'yes') - * parseFormData(formData) - * // throws ForbiddenKeyError('__proto__') - * ``` - */ -export class ForbiddenKeyError extends Error { - key: string - constructor(key: string) { - super(`Forbidden key at path part ${key}`) - this.key = key - } -} -/** - * Thrown when a path has more path parts than the `depth` option allows. - * `key` is the path up to the first path part over the limit. - * - * @example - * ```ts - * const formData = new FormData() - * formData.append('a.b[0].c', 'x') - * parseFormData(formData, {depth: 2}) - * // throws DepthLimitError('a.b[0]') - * ``` - */ -export class DepthLimitError extends Error { - key: string - constructor(key: string) { - super(`Depth limit exceeded at path part ${key}`) - this.key = key - } -} +export {DepthLimitError, DuplicateKeyError, ForbiddenKeyError, MixedArrayError} // Rejected even when a runtime doesn't define them on `Object.prototype` // (e.g. `node --disable-proto=delete`). @@ -123,18 +20,6 @@ function isPrototypeKey(key: string): boolean { return Object.getOwnPropertyDescriptor(Object.prototype, key) !== undefined } -type JsonObject = {[Key in string]?: JsonValue} -type JsonArray = Array -type JsonValue = - | string - | number - | JsonObject - | JsonArray - | boolean - | null - | File -type JsonLeafValue = Exclude - /** * Detects `File` and `Blob` values by their `Symbol.toStringTag`, which works * for native instances, polyfills (e.g. `@remix-run/web-file`) and values from @@ -154,74 +39,6 @@ function isJsonObject(val: JsonValue): val is JsonObject { ) } -/** - * Default Transformer for `parseFormData`. - * - * Transforms a FormData Entry into a path and a `JsonLeafValue`. - * - * - `path` starts with `+` -> transform value to `number` - * - `path` starts with `&` -> transform value to `boolean` - * - `path` starts with `-` -> transform value to `null` - * - * @example - * ```ts - * const entry = ['a[0]', 'b'] - * const result = defaultTransform(entry) - * // => {path: 'a[0]', value: 'b'} - * ``` - * - * @example - * ```ts - * const entry = ['+a[0]', '1'] - * const result = defaultTransform(entry) - * // => {path: 'a[0]', value: 1} - * ``` - * - * @example - * ```ts - * const entry = ['&a[0]', 'true'] - * const result = defaultTransform(entry) - * // => {path: 'a[0]', value: true} - * ``` - * - * @example - * ```ts - * const entry = ['-a[0]', 'null'] - * const result = defaultTransform(entry) - * // => {path: 'a[0]', value: null} - * ``` - * - * @example - * ```ts - * const entry = ['a[0]', new File([''], 'file.txt')] - * const result = defaultTransform(entry) - * // => {path: 'a[0]', value: File} - * ``` - * - * - * @param entry [path, value]: the FormData entry - * @returns the path and the transformed value - */ -function defaultTransform(entry: [path: string, value: string | File]): { - path: string - value: JsonLeafValue -} { - let path = entry[0] - let value: JsonLeafValue = entry[1] - if (path.startsWith('+')) { - path = path.slice(1) - value = Number(value) - } else if (path.startsWith('&')) { - path = path.slice(1) - value = value === 'on' || value === 'true' || Boolean(Number(value)) - } else if (path.startsWith('-')) { - path = path.slice(1) - value = null - } - return {path, value} -} - -type DefaultTransform = typeof defaultTransform /** * Options to change the behavior of `parseFormData`. * @param transformEntry - a function to transform the FormData entry into a path and a value @@ -277,137 +94,6 @@ export type ParseFormDataOptions = { ) => {path: string; value: JsonLeafValue} } -/** - * A parsed part of a FormData path. It consists of four parts: - * @param path - the key to access the value in the intermediate result - * @param type - the type of the path part (array - e.g. [\d*], or object .e.g. .key or [key]) - * @param default - the default value of the next path part ([] for array, {} for object) - * @param pathToPart - the full path to the current path part - * - * @example - * ```ts - * const path = 'a[0].b' - * const result = extractPathParts(path) - * // => [{path: 'a', type: 'object', default: [], pathToPart: 'a'}, - * // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'}, - * // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}] - * ``` - */ -type PathPart = { - path: string - type: 'object' | 'array' - default: JsonObject | JsonArray - pathToPart: string -} -/** - * Finds the `]` that ends the bracket part starting at the `[` at `open`. - * Returns -1 if this `[` is not a bracket part but a normal character of the key. - * - * Brackets can be nested, like in qs: in `a[b[c]]` the last `]` belongs to - * the first `[`. - * - * Special cases: - * - Some `[` are never closed. The part then ends at the last `]`: - * `a[[0]` => key `[0`. - * - If there is no `]` after the `[` at all, the `[` is a normal character: - * `a[0` => key `a[0`. - * - The same goes for a `]` without a `[`: `a]b` => key `a]b`, and - * `a[b]]` => key `]` inside `b`. - * - * The scan stops at the returned `]`. The caller continues from there, so no - * character of the path is scanned twice. - */ -function closeOf(path: string, open: number, lastClose: number): number { - if (open > lastClose) return -1 - let nesting = 0 - for (let idx = open; idx < lastClose; idx++) { - if (path[idx] === '[') nesting++ - else if (path[idx] === ']' && --nesting === 0) return idx - } - return lastClose -} -/** - * - * Transforms a FormData path into an array of `PathPart`s. - * - * @param path - the path to extract the path parts from - * @param depth - the maximum number of path parts (default: `Infinity`) - * @returns {Array} the extracted path parts - * @throws `DepthLimitError` if the path has more path parts than `depth`, at - * the first part over the limit - * - * @example - * ```ts - * const path = 'a[0].b' - * const result = extractPathParts(path) - * // => [{path: 'a', type: 'object', default: [], pathToPart: 'a'}, - * // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'}, - * // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}] - * ``` - * - * @example - * ```ts - * const path = 'a.b' - * const result = extractPathParts(path) - * // => [{path: 'a', type: 'object', default: {}, pathToPart: 'a'}, - * // {path: 'b', type: 'object', default: {}, pathToPart: 'a.b'}] - * ``` - * - * @example - * ```ts - * const path = 'a[b][0]' - * const result = extractPathParts(path) - * // => [{path: 'a', type: 'object', default: {}, pathToPart: 'a'}, - * // {path: 'b', type: 'object', default: [], pathToPart: 'a[b]'}, - * // {path: '0', type: 'array', default: {}, pathToPart: 'a[b][0]'}] - * ``` - * - * @example - * ```ts - * const path = 'a[][0]' - * const result = extractPathParts(path) - * // => [{path: 'a', type: 'object', default: [], pathToPart: 'a'}, - * // {path: '', type: 'array', default: [], pathToPart: 'a[]'}, - * // {path: '0', type: 'array', default: {}, pathToPart: 'a[][0]'}] - * ``` - * - * - */ -function extractPathParts(path: string, depth = Infinity): Array { - const lastClose = path.lastIndexOf(']') - - const parts: Array = [] - const addPart = (key: string, type: PathPart['type'], end: number): void => { - // Empty keys come from `a..b`, `a.`, `.a` or a `.` or `[` after `]`. - if (key === '' && type === 'object') return - if (parts.length === depth) throw new DepthLimitError(path.slice(0, end)) - parts.push({path: key, type, default: {}, pathToPart: path.slice(0, end)}) - } - - let start = 0 - for (let idx = 0; idx < path.length; idx++) { - const close = path[idx] === '[' ? closeOf(path, idx, lastClose) : -1 - if (path[idx] !== '.' && close < idx) continue - - addPart(path.slice(start, idx), 'object', idx) - if (close > idx) { - // Digits or nothing is an array index, anything else is the key as it - // is (`a[b.c]` => key `b.c`). - const key = path.slice(idx + 1, close) - addPart(key, /^\d*$/.test(key) ? 'array' : 'object', close + 1) - idx = close - } - start = idx + 1 - } - addPart(path.slice(start), 'object', path.length) - - // A part holds an array if the next part is an array index. - parts.forEach((part, idx) => { - if (parts[idx + 1]?.type === 'array') part.default = [] - }) - return parts -} - /** * * Returns the value accessed via `pathPart` in the `currentPathObject`, diff --git a/src/path-parts.ts b/src/path-parts.ts new file mode 100644 index 0000000..a3d4f94 --- /dev/null +++ b/src/path-parts.ts @@ -0,0 +1,138 @@ +// Splits a FormData path into its path parts. + +import {DepthLimitError} from './errors' +import type {JsonArray, JsonObject} from './types' + +/** + * A parsed part of a FormData path. It consists of four parts: + * @param path - the key to access the value in the intermediate result + * @param type - the type of the path part (array - e.g. [\d*], or object .e.g. .key or [key]) + * @param default - the default value of the next path part ([] for array, {} for object) + * @param pathToPart - the full path to the current path part + * + * @example + * ```ts + * const path = 'a[0].b' + * const result = extractPathParts(path) + * // => [{path: 'a', type: 'object', default: [], pathToPart: 'a'}, + * // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'}, + * // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}] + * ``` + */ +export type PathPart = { + path: string + type: 'object' | 'array' + default: JsonObject | JsonArray + pathToPart: string +} +/** + * Finds the `]` that ends the bracket part starting at the `[` at `open`. + * Returns -1 if this `[` is not a bracket part but a normal character of the key. + * + * Brackets can be nested, like in qs: in `a[b[c]]` the last `]` belongs to + * the first `[`. + * + * Special cases: + * - Some `[` are never closed. The part then ends at the last `]`: + * `a[[0]` => key `[0`. + * - If there is no `]` after the `[` at all, the `[` is a normal character: + * `a[0` => key `a[0`. + * - The same goes for a `]` without a `[`: `a]b` => key `a]b`, and + * `a[b]]` => key `]` inside `b`. + * + * The scan stops at the returned `]`. The caller continues from there, so no + * character of the path is scanned twice. + */ +function closeOf(path: string, open: number, lastClose: number): number { + if (open > lastClose) return -1 + let nesting = 0 + for (let idx = open; idx < lastClose; idx++) { + if (path[idx] === '[') nesting++ + else if (path[idx] === ']' && --nesting === 0) return idx + } + return lastClose +} +/** + * + * Transforms a FormData path into an array of `PathPart`s. + * + * @param path - the path to extract the path parts from + * @param depth - the maximum number of path parts (default: `Infinity`) + * @returns {Array} the extracted path parts + * @throws `DepthLimitError` if the path has more path parts than `depth`, at + * the first part over the limit + * + * @example + * ```ts + * const path = 'a[0].b' + * const result = extractPathParts(path) + * // => [{path: 'a', type: 'object', default: [], pathToPart: 'a'}, + * // {path: '0', type: 'array', default: {}, pathToPart: 'a[0]'}, + * // {path: 'b', type: 'object', default: {}, pathToPart: 'a[0].b'}] + * ``` + * + * @example + * ```ts + * const path = 'a.b' + * const result = extractPathParts(path) + * // => [{path: 'a', type: 'object', default: {}, pathToPart: 'a'}, + * // {path: 'b', type: 'object', default: {}, pathToPart: 'a.b'}] + * ``` + * + * @example + * ```ts + * const path = 'a[b][0]' + * const result = extractPathParts(path) + * // => [{path: 'a', type: 'object', default: {}, pathToPart: 'a'}, + * // {path: 'b', type: 'object', default: [], pathToPart: 'a[b]'}, + * // {path: '0', type: 'array', default: {}, pathToPart: 'a[b][0]'}] + * ``` + * + * @example + * ```ts + * const path = 'a[][0]' + * const result = extractPathParts(path) + * // => [{path: 'a', type: 'object', default: [], pathToPart: 'a'}, + * // {path: '', type: 'array', default: [], pathToPart: 'a[]'}, + * // {path: '0', type: 'array', default: {}, pathToPart: 'a[][0]'}] + * ``` + * + * + */ +export function extractPathParts( + path: string, + depth: number = Infinity, +): Array { + const lastClose = path.lastIndexOf(']') + + const parts: Array = [] + const addPart = (key: string, type: PathPart['type'], end: number): void => { + // Empty keys come from `a..b`, `a.`, `.a` or a `.` or `[` after `]`. + if (key === '' && type === 'object') return + if (parts.length === depth) throw new DepthLimitError(path.slice(0, end)) + parts.push({path: key, type, default: {}, pathToPart: path.slice(0, end)}) + } + + let start = 0 + for (let idx = 0; idx < path.length; idx++) { + const close = path[idx] === '[' ? closeOf(path, idx, lastClose) : -1 + if (path[idx] !== '.' && close < idx) continue + + addPart(path.slice(start, idx), 'object', idx) + if (close > idx) { + // Digits or nothing is an array index, anything else is the key as it + // is (`a[b.c]` => key `b.c`). + const key = path.slice(idx + 1, close) + addPart(key, /^\d*$/.test(key) ? 'array' : 'object', close + 1) + idx = close + } + start = idx + 1 + } + addPart(path.slice(start), 'object', path.length) + + // A part holds an array if the next part is an array index. + parts.forEach((part, idx) => { + if (parts[idx + 1]?.type === 'array') part.default = [] + }) + return parts +} diff --git a/src/transform.ts b/src/transform.ts new file mode 100644 index 0000000..5e301c7 --- /dev/null +++ b/src/transform.ts @@ -0,0 +1,72 @@ +// Turns a FormData entry into a path and a value before it is parsed. + +import type {JsonLeafValue} from './types' + +/** + * Default Transformer for `parseFormData`. + * + * Transforms a FormData Entry into a path and a `JsonLeafValue`. + * + * - `path` starts with `+` -> transform value to `number` + * - `path` starts with `&` -> transform value to `boolean` + * - `path` starts with `-` -> transform value to `null` + * + * @example + * ```ts + * const entry = ['a[0]', 'b'] + * const result = defaultTransform(entry) + * // => {path: 'a[0]', value: 'b'} + * ``` + * + * @example + * ```ts + * const entry = ['+a[0]', '1'] + * const result = defaultTransform(entry) + * // => {path: 'a[0]', value: 1} + * ``` + * + * @example + * ```ts + * const entry = ['&a[0]', 'true'] + * const result = defaultTransform(entry) + * // => {path: 'a[0]', value: true} + * ``` + * + * @example + * ```ts + * const entry = ['-a[0]', 'null'] + * const result = defaultTransform(entry) + * // => {path: 'a[0]', value: null} + * ``` + * + * @example + * ```ts + * const entry = ['a[0]', new File([''], 'file.txt')] + * const result = defaultTransform(entry) + * // => {path: 'a[0]', value: File} + * ``` + * + * + * @param entry [path, value]: the FormData entry + * @returns the path and the transformed value + */ +export function defaultTransform(entry: [path: string, value: string | File]): { + path: string + value: JsonLeafValue +} { + let path = entry[0] + let value: JsonLeafValue = entry[1] + if (path.startsWith('+')) { + path = path.slice(1) + value = Number(value) + } else if (path.startsWith('&')) { + path = path.slice(1) + value = value === 'on' || value === 'true' || Boolean(Number(value)) + } else if (path.startsWith('-')) { + path = path.slice(1) + value = null + } + return {path, value} +} + +export type DefaultTransform = typeof defaultTransform diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..7281fe9 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,13 @@ +// The values `parseFormData` builds. + +export type JsonObject = {[Key in string]?: JsonValue} +export type JsonArray = Array +export type JsonValue = + | string + | number + | JsonObject + | JsonArray + | boolean + | null + | File +export type JsonLeafValue = Exclude diff --git a/tsdown.config.mts b/tsdown.config.mts index 12ddae9..d88e61e 100644 --- a/tsdown.config.mts +++ b/tsdown.config.mts @@ -3,6 +3,15 @@ import {defineConfig, type UserConfig} from 'tsdown' // The published file names are part of the 1.x contract (`main`, `module`, // `types` in package.json, CDN links to the UMD build), so they are pinned here: // dist/parse-nested-form-data.{esm,cjs,umd,umd.min}.js + dist/index.d.ts +// The source is split into modules, so the header goes on top of each bundle +// here instead of into one of them. +const LICENSE = `/** + * @name parse-nested-form-data + * @license MIT license. + * @copyright (c) 2022 Christian Schurr + * @author Christian Schurr + */` + const shared = { entry: {'parse-nested-form-data': 'src/index.ts'}, platform: 'neutral', @@ -10,6 +19,7 @@ const shared = { hash: false, clean: false, // `npm run build` cleans dist once before all configs run dts: false, + banner: {js: LICENSE, dts: LICENSE}, } satisfies UserConfig const umd = { From b94f9b5e3f096b329d4feb15ce271ae89472daff Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 10:39:54 +0100 Subject: [PATCH 14/17] test: test the scanner directly and use it as the depth oracle The depth property gets the path parts and the error key from extractPathParts instead of rebuilding them through parseFormData. --- src/__tests__/path-parts.ts | 76 +++++++++++++++++++++++++++++++++++++ src/__tests__/properties.ts | 76 +++++-------------------------------- 2 files changed, 86 insertions(+), 66 deletions(-) create mode 100644 src/__tests__/path-parts.ts diff --git a/src/__tests__/path-parts.ts b/src/__tests__/path-parts.ts new file mode 100644 index 0000000..55a76da --- /dev/null +++ b/src/__tests__/path-parts.ts @@ -0,0 +1,76 @@ +import {describe, expect, it} from 'vitest' +import {DepthLimitError} from '../errors' +import {extractPathParts} from '../path-parts' + +// Only the key, type and path of each part, written as `type:key@pathToPart`. +function partsOf(path: string, depth?: number): Array { + return extractPathParts(path, depth).map( + part => `${part.type}:${part.path}@${part.pathToPart}`, + ) +} + +describe('extractPathParts', () => { + it.each([ + ['a', ['object:a@a']], + ['a.b', ['object:a@a', 'object:b@a.b']], + ['a[b]', ['object:a@a', 'object:b@a[b]']], + ['a[0].b', ['object:a@a', 'array:0@a[0]', 'object:b@a[0].b']], + ['a[][0]', ['object:a@a', 'array:@a[]', 'array:0@a[][0]']], + // the text in brackets is the key as it is + ['a[b.c]', ['object:a@a', 'object:b.c@a[b.c]']], + ['a[0b]', ['object:a@a', 'object:0b@a[0b]']], + // empty object keys are skipped + ['a..b', ['object:a@a', 'object:b@a..b']], + ['.a', ['object:a@.a']], + ['a.', ['object:a@a']], + ['a[b].c', ['object:a@a', 'object:b@a[b]', 'object:c@a[b].c']], + ['a[b][c]', ['object:a@a', 'object:b@a[b]', 'object:c@a[b][c]']], + // a path can start with an array part + ['[0]', ['array:0@[0]']], + ])('splits %s', (path, expected) => { + expect(partsOf(path)).toStrictEqual(expected) + }) + + it.each([ + // nested brackets: the last `]` belongs to the first `[` + ['a[b[c]]', ['object:a@a', 'object:b[c]@a[b[c]]']], + // a `[` that is never closed: the part ends at the last `]` + ['a[[0]', ['object:a@a', 'object:[0@a[[0]']], + // no `]` after the `[`: it's a normal character + ['a[0', ['object:a[0@a[0']], + // a `]` without a `[` is a normal character + ['a]b', ['object:a]b@a]b']], + ['a[b]]', ['object:a@a', 'object:b@a[b]', 'object:]@a[b]]']], + ])('splits %s with unmatched brackets', (path, expected) => { + expect(partsOf(path)).toStrictEqual(expected) + }) + + it.each([ + ['a', {}], + ['a.b', {}], + ['a[0]', []], + ['a[0][]', []], + ])('gives the first part of %s the default %j', (path, expected) => { + expect(extractPathParts(path)[0]!.default).toStrictEqual(expected) + }) + + it.each([ + ['a.b', 2, ['object:a@a', 'object:b@a.b']], + ['a.b[0]', 3, ['object:a@a', 'object:b@a.b', 'array:0@a.b[0]']], + // skipped empty keys don't count + ['a..b', 2, ['object:a@a', 'object:b@a..b']], + ])('splits %s within depth %d', (path, depth, expected) => { + expect(partsOf(path, depth)).toStrictEqual(expected) + }) + + it.each([ + ['a.b', 1, 'a.b'], + ['a.b[0].c', 2, 'a.b[0]'], + ['a[b[c]].d', 1, 'a[b[c]]'], + ['a[0][1][2]', 2, 'a[0][1]'], + ])('throws DepthLimitError for %s with depth %d', (path, depth, key) => { + expect(() => extractPathParts(path, depth)).toThrow( + new DepthLimitError(key), + ) + }) +}) diff --git a/src/__tests__/properties.ts b/src/__tests__/properties.ts index 3eb2aa1..ad41191 100644 --- a/src/__tests__/properties.ts +++ b/src/__tests__/properties.ts @@ -7,6 +7,8 @@ import { MixedArrayError, parseFormData, } from '../' +import {extractPathParts} from '../path-parts' +import {defaultTransform} from '../transform' import {outcomeWithName} from './utils' type Entry = [string, string] @@ -207,78 +209,20 @@ function outcomeOf(parse: () => unknown): Outcome { // Entries are read one by one, in order. Every entry before the first one with // more than `depth` path parts gives the same result as with no limit, and that // includes any error they throw. If none of them throws, the first entry with -// too many parts throws `DepthLimitError`. The error shows its path only up to -// the end of part number `depth + 1`. +// too many parts throws `DepthLimitError` with the path up to the end of the +// first part over the limit. function expectedWithDepth(entries: Array, depth: number): Outcome { - const paths = entries.map(entry => transformedPath(entry)) - const tooDeep = paths.findIndex(path => partsOf(path).length > depth) + const partsPerEntry = entries.map(entry => + extractPathParts(defaultTransform(entry).path), + ) + const tooDeep = partsPerEntry.findIndex(parts => parts.length > depth) if (tooDeep === -1) return outcomeOf(() => parseFormData(entries)) const before = outcomeOf(() => parseFormData(entries.slice(0, tooDeep))) if ('error' in before) return before - const path = paths[tooDeep]! + const firstOverLimit = partsPerEntry[tooDeep]![depth]! return outcomeOf(() => { - throw new DepthLimitError(keyAt(path, depth)) - }) -} - -// The path of `entry` after the default transform (`+a` => `a`). -function transformedPath(entry: Entry): string { - let path = '' - parseFormData([entry], { - transformEntry: (item, defaultTransform) => { - path = defaultTransform(item).path - return {path: 'a', value: ''} - }, + throw new DepthLimitError(firstOverLimit.pathToPart) }) - return path -} - -// Names on Object.prototype are removed from the result, and `__proto__`, -// `constructor` and `prototype` throw. `partsOf` renames them to `ǂ`, which is -// never generated, so they are built like any other key. -const UNBUILT_NAME = new RegExp( - [...Object.getOwnPropertyNames(Object.prototype), 'prototype'].join('|'), -) - -// The keys of the path parts of `path` (`a.b[0]` => `.a`, `.b`, `[]0`; array -// indices are squashed). Under the root key `r`, a path that starts with an -// array index is built too. -function partsOf(path: string): Array { - const built = parseFormData( - [[`r.${path.split(UNBUILT_NAME).join('ǂ')}`, 'x']], - { - transformEntry: ([entryPath, value]) => ({path: entryPath, value}), - }, - ) - const parts: Array = [] - let container: unknown = built - while (isContainer(container)) { - const [key, item] = Object.entries(container)[0]! - parts.push(`${Array.isArray(container) ? '[]' : '.'}${key}`) - container = item - } - return parts.slice(1) -} - -function isContainer(value: unknown): value is Record { - return typeof value === 'object' && value !== null -} - -// The start of `path` up to the end of its part `depth + 1`. Different starts -// can have the same parts (`a.[` and `a.[[]` both have `a`, `[`), so the end -// is where a marker part `ǁ` inserted into the path becomes the next part: -// `a.[[].ǁ.` has `a`, `[`, `ǁ`, but the `[` in `a.[.ǁ.[]` takes in the -// marker. Of the ends where it does (`a.b` and `a.b.` in `a.b.c`), the part -// ends at the first. -function keyAt(path: string, depth: number): string { - const expected = JSON.stringify([...partsOf(path).slice(0, depth + 1), '.ǁ']) - for (let end = 0; end <= path.length; end++) { - const marked = `${path.slice(0, end)}.ǁ.${path.slice(end)}` - if (JSON.stringify(partsOf(marked).slice(0, depth + 2)) === expected) { - return path.slice(0, end) - } - } - throw new Error(`no end for part ${depth + 1} of ${path}`) } function isNoneOrLibraryError(error: unknown): boolean { From 1b250f77a96021b357afbee16491b28c92cd713d Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 11:15:11 +0100 Subject: [PATCH 15/17] test: pin the order of the depth and forbidden key errors A too-deep path throws DepthLimitError even with a forbidden key after the limit. Across entries, the first failing entry decides the error. --- src/__tests__/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index 3d8010e..d825c73 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -307,6 +307,20 @@ describe('depth option', () => { ) }) + it('stops at the first part over the limit, before a later forbidden key', () => { + expect(() => parseFormData([['a.b.c.__proto__', 'x']], {depth: 2})).toThrow( + new DepthLimitError('a.b.c'), + ) + }) + + it.each([ + [['__proto__.x', 'a.b.c'], new ForbiddenKeyError('__proto__')], + [['a.b.c', '__proto__.x'], new DepthLimitError('a.b.c')], + ])('throws the error of the first failing entry in %j', (paths, error) => { + const entries = paths.map((path): [string, string] => [path, 'x']) + expect(() => parseFormData(entries, {depth: 2})).toThrow(error) + }) + it('names the path part over the limit in the error', () => { const error = new DepthLimitError('a.b') expect(error).toBeInstanceOf(Error) From e02793550d61a1100c6f67a875c7dca931e99194 Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 11:16:23 +0100 Subject: [PATCH 16/17] docs: say that the depth is checked before other errors A path that's too deep throws DepthLimitError even if it would also throw another error, like a ForbiddenKeyError for __proto__. --- README.md | 4 +++- src/errors.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ab3b7c5..4a59d27 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,9 @@ _Default: `Infinity`_ The maximum number of path parts in a path. Every part counts, so `a.b[0]` and `a[b][]` both have 3. A path with more parts throws a `DepthLimitError` with the -path up to the first part over the limit. Must be a positive integer or +path up to the first part over the limit. The depth is checked first, so a path +that's too deep throws `DepthLimitError` even if it would also throw another +error, like a `ForbiddenKeyError` for `__proto__`. Must be a positive integer or `Infinity`, otherwise `parseFormData` throws a `TypeError`. The parser itself handles any depth, but a field like `a.a.a…` with thousands of diff --git a/src/errors.ts b/src/errors.ts index 3d68c89..b7a4299 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -91,6 +91,9 @@ export class ForbiddenKeyError extends Error { /** * Thrown when a path has more path parts than the `depth` option allows. * `key` is the path up to the first path part over the limit. + * The depth is checked first, so a path that's too deep throws + * `DepthLimitError` even if it would also throw another error, like a + * `ForbiddenKeyError` for `__proto__`. * * @example * ```ts From a60e983e4b3cbed66753f6128618fd43f14c591c Mon Sep 17 00:00:00 2001 From: Christian Schurr Date: Mon, 9 Nov 2026 11:40:01 +0100 Subject: [PATCH 17/17] fix: cap the path in DepthLimitError and ForbiddenKeyError Both errors put the path from the client into `key` and the message, so a huge path ended up in full in logs and error responses. Paths longer than 100 characters are now cut and end in `...`. The docs now say the depth is checked first within one entry, that an earlier entry's error wins, and that `removeEmptyString` removes an empty string before its depth is checked. The esm size limit goes up to 1.5 kB, like the umd one. --- .size-limit.json | 2 +- README.md | 14 +++++++++----- src/__tests__/index.ts | 22 ++++++++++++++++++++++ src/errors.ts | 17 +++++++++++++++-- 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/.size-limit.json b/.size-limit.json index 7575143..e19a5cc 100644 --- a/.size-limit.json +++ b/.size-limit.json @@ -2,7 +2,7 @@ { "name": "esm (import *)", "path": "dist/parse-nested-form-data.esm.js", - "limit": "1.3 kB" + "limit": "1.5 kB" }, { "name": "umd.min", diff --git a/README.md b/README.md index 4a59d27..45d7f37 100644 --- a/README.md +++ b/README.md @@ -263,7 +263,7 @@ Paths with misplaced brackets are parsed without an error: `__proto__`, `constructor` and `prototype` can't be used as an object path part, at any position, with or without brackets, because they could modify `Object.prototype`. `parseFormData` throws a `ForbiddenKeyError` with the path -up to that part: +up to that part, cut to 100 characters plus `...` when it's longer: ```ts const formData = new FormData() @@ -365,10 +365,14 @@ _Default: `Infinity`_ The maximum number of path parts in a path. Every part counts, so `a.b[0]` and `a[b][]` both have 3. A path with more parts throws a `DepthLimitError` with the -path up to the first part over the limit. The depth is checked first, so a path -that's too deep throws `DepthLimitError` even if it would also throw another -error, like a `ForbiddenKeyError` for `__proto__`. Must be a positive integer or -`Infinity`, otherwise `parseFormData` throws a `TypeError`. +path up to the first part over the limit, cut to 100 characters plus `...` when +it's longer. Within one entry the depth is checked first, so a path that's too +deep throws `DepthLimitError` even if it would also throw another error, like a +`ForbiddenKeyError` for `__proto__`. Entries are parsed in order, so an earlier +entry's error wins. The depth is checked after `transformEntry`, and with +`removeEmptyString` an entry with an empty string is removed before its depth is +checked, so it never throws. Must be a positive integer or `Infinity`, otherwise +`parseFormData` throws a `TypeError`. The parser itself handles any depth, but a field like `a.a.a…` with thousands of parts creates an object that crashes code walking it recursively diff --git a/src/__tests__/index.ts b/src/__tests__/index.ts index d825c73..ebe3198 100644 --- a/src/__tests__/index.ts +++ b/src/__tests__/index.ts @@ -328,6 +328,21 @@ describe('depth option', () => { expect(error.message).toBe('Depth limit exceeded at path part a.b') }) + it('cuts a key longer than 100 characters and ends it with ...', () => { + const path = `a.${'b'.repeat(200)}` + expect(() => parseFormData([[path, 'x']], {depth: 1})).toThrow( + `Depth limit exceeded at path part ${path.slice(0, 100)}...`, + ) + expect(new DepthLimitError(path).key).toBe(`${path.slice(0, 100)}...`) + expect(new DepthLimitError('b'.repeat(100)).key).toBe('b'.repeat(100)) + }) + + it('removes an empty string with removeEmptyString before checking the depth', () => { + expect( + parseFormData([['a.b.c', '']], {depth: 2, removeEmptyString: true}), + ).toStrictEqual({}) + }) + it.each([0, -1, 1.5, Number.NaN, -Infinity])( 'throws a TypeError for depth %d, even without entries', depth => { @@ -757,6 +772,13 @@ describe('prototype pollution protection', () => { expect(Reflect.get(proto, 'polluted')).toBeUndefined() }, ) + it('cuts a key longer than 100 characters and ends it with ...', () => { + const path = `${'a'.repeat(200)}.__proto__` + expect(() => parseFormData([[path, 'x']])).toThrow( + `Forbidden key at path part ${path.slice(0, 100)}...`, + ) + expect(new ForbiddenKeyError(path).key).toBe(`${path.slice(0, 100)}...`) + }) }) // Parses `key` with that property removed from Object.prototype and returns diff --git a/src/errors.ts b/src/errors.ts index b7a4299..dd1550f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -67,11 +67,21 @@ export class MixedArrayError extends Error { this.key = key } } +/** + * Cuts a path to 100 characters plus `...`, so a huge path from a client + * doesn't end up in full in logs or error responses. + */ +function shortKey(key: string): string { + return key.length > 100 ? `${key.slice(0, 100)}...` : key +} + /** * Thrown when an object path part is `__proto__`, `constructor` or * `prototype`, which could modify `Object.prototype`. The names are rejected * at any position in the path. Entries with other names on `Object.prototype` * (`toString`, ...) are removed instead (see `parseFormData`). + * `key` is the path up to the forbidden part, cut to 100 characters plus `...` + * when it's longer. * * @example * ```ts @@ -84,14 +94,16 @@ export class MixedArrayError extends Error { export class ForbiddenKeyError extends Error { key: string constructor(key: string) { + key = shortKey(key) super(`Forbidden key at path part ${key}`) this.key = key } } /** * Thrown when a path has more path parts than the `depth` option allows. - * `key` is the path up to the first path part over the limit. - * The depth is checked first, so a path that's too deep throws + * `key` is the path up to the first path part over the limit, cut to + * 100 characters plus `...` when it's longer. + * Within one entry the depth is checked first, so a path that's too deep throws * `DepthLimitError` even if it would also throw another error, like a * `ForbiddenKeyError` for `__proto__`. * @@ -106,6 +118,7 @@ export class ForbiddenKeyError extends Error { export class DepthLimitError extends Error { key: string constructor(key: string) { + key = shortKey(key) super(`Depth limit exceeded at path part ${key}`) this.key = key }