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 109d338..45d7f37 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) @@ -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() @@ -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 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..ebe3198 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,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() @@ -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 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 e41a91f..ad41191 100644 --- a/src/__tests__/properties.ts +++ b/src/__tests__/properties.ts @@ -1,11 +1,14 @@ import * as fc from 'fast-check' import {describe, expect, it} from 'vitest' import { + DepthLimitError, DuplicateKeyError, ForbiddenKeyError, MixedArrayError, parseFormData, } from '../' +import {extractPathParts} from '../path-parts' +import {defaultTransform} from '../transform' import {outcomeWithName} from './utils' type Entry = [string, string] @@ -98,12 +101,41 @@ 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> => 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 +194,37 @@ function outcomeWithoutName(entries: Array): unknown { } } +// 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 { + return {result: parse()} + } catch (error) { + const type = error instanceof Error ? error.constructor : error + return {error: type, message: String(error)} + } +} + +// 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` with the path up to the end of the +// first part over the limit. +function expectedWithDepth(entries: Array, depth: number): Outcome { + 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 firstOverLimit = partsPerEntry[tooDeep]![depth]! + return outcomeOf(() => { + throw new DepthLimitError(firstOverLimit.pathToPart) + }) +} + function isNoneOrLibraryError(error: unknown): boolean { return error === undefined || LIBRARY_ERRORS.some(cls => error instanceof cls) } @@ -266,6 +329,20 @@ describe('properties', () => { ) }) + it('limits the nesting to depth or throws DepthLimitError', () => { + fc.assert( + fc.property( + entriesArb(fc.oneof(anyPathArb, partsPathArb)), + fc.integer({min: 1, max: 6}), + (entries, depth) => { + expect( + outcomeOf(() => parseFormData(entries, {depth})), + ).toStrictEqual(expectedWithDepth(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/errors.ts b/src/errors.ts new file mode 100644 index 0000000..dd1550f --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,125 @@ +// 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 + } +} +/** + * 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 + * const formData = new FormData() + * formData.append('__proto__.polluted', 'yes') + * parseFormData(formData) + * // throws ForbiddenKeyError('__proto__') + * ``` + */ +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, 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__`. + * + * @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) { + key = shortKey(key) + super(`Depth limit exceeded at path part ${key}`) + this.key = key + } +} diff --git a/src/index.ts b/src/index.ts index 2882d5a..355fb78 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,98 +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 - } -} +export {DepthLimitError, DuplicateKeyError, ForbiddenKeyError, MixedArrayError} // Rejected even when a runtime doesn't define them on `Object.prototype` // (e.g. `node --disable-proto=delete`). @@ -104,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 @@ -135,79 +39,25 @@ 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 * (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`). + * For untrusted input, `32` is recommended; 2.0 will lower the default. + * + * @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,123 +87,13 @@ type DefaultTransform = typeof defaultTransform */ export type ParseFormDataOptions = { removeEmptyString?: boolean + depth?: number transformEntry?: ( entry: [path: string, value: string | File], defaultTransform: DefaultTransform, ) => {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 -} -/** - * - * Transforms a FormData path into an array of `PathPart`s. - * - * @param path - the path to extract the path parts from - * @returns {Array} the extracted path parts - * - * @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): 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) - } - 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 - parts.push({path: key, type, default: {}, pathToPart: path.slice(0, end)}) - } - - 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 - 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`, @@ -547,7 +287,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 +322,14 @@ 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, got ${String(depth)}`, + ) + } const result: JsonObject = {} // all arrays we need to squash (in place) later @@ -599,7 +350,7 @@ export function parseFormData( if (removeEmptyString && entry[1] === '') continue const {path, value} = transformEntry(entry, defaultTransform) - const pathParts = extractPathParts(path) + 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( 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 = {