Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -262,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()
Expand Down Expand Up @@ -358,6 +359,38 @@ 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, 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
(`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()
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.lat')
```

### transformEntry: `(entry: [path: string, value: string | File], defaultTransform: DefaultTransform) => {path: string, value: JsonLeafValue}`

```ts
Expand Down
1 change: 1 addition & 0 deletions scripts/smoke-dist.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const entries = [
]
const expected = {a: [{b: 'x', c: 'y'}], n: 1}
const expectedExports = [
'DepthLimitError',
'DuplicateKeyError',
'ForbiddenKeyError',
'MixedArrayError',
Expand Down
114 changes: 114 additions & 0 deletions src/__tests__/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -248,6 +249,112 @@ 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.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'),
)
})

it('throws DepthLimitError before ForbiddenKeyError', () => {
expect(() => parseFormData([['__proto__.a.b', 'x']], {depth: 2})).toThrow(
new DepthLimitError('__proto__.a.b'),
)
})

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)
expect(error.key).toBe('a.b')
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 => {
expect(() => parseFormData([], {depth})).toThrow(
new TypeError(
`depth must be a positive integer or Infinity, got ${depth}`,
),
)
},
)
})

describe('errors', () => {
it('throws if value is already set `foo, foo -> throw DuplicateKeyError("foo")`', () => {
const formData = new FormData()
Expand Down Expand Up @@ -665,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
Expand Down
76 changes: 76 additions & 0 deletions src/__tests__/path-parts.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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),
)
})
})
Loading
Loading