Skip to content

feat: add a depth option - #12

Merged
milamer merged 17 commits into
mainfrom
feat/depth-option
Nov 9, 2026
Merged

milamer merged 17 commits into
mainfrom
feat/depth-option

Conversation

@milamer

@milamer milamer commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

What changes

  • New option depth limits the number of path parts in a path. Every part counts, including the first: a.b[0] and a[b][] both have 3. Empty parts (a..b) and the +/&/- prefixes don't count.
  • A path over the limit 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]'). An entry that would be removed (a.toString.b) throws the same error as any other name.
  • Error order: within one entry the depth is checked first, so __proto__.a.b with depth: 2 throws DepthLimitError, not ForbiddenKeyError. Entries are parsed in order, so an earlier entry's error wins. The depth is checked after transformEntry. With removeEmptyString, an entry with an empty string is removed before its depth is checked, so it never throws.
  • Capped error keys: DepthLimitError and ForbiddenKeyError cut key (and so the message) to 100 characters plus ..., so a huge path from a client doesn't end up in full in logs or error responses.
  • depth must be a positive integer or Infinity, otherwise parseFormData throws a TypeError that shows the given value (got 0), also without entries. 0 doesn't mean "unlimited": in qs it means "no nesting" and in Rack a limit of 0 rejects everything.
  • Default Infinity, so nothing changes for existing users. The docs recommend depth: 32 for untrusted input (the default of Rack and Express) and say that 2.0 will lower the default.
  • Docs: README "Options" section, ParseFormDataOptions/parseFormData JSDoc, DepthLimitError and ForbiddenKeyError JSDoc. scripts/smoke-dist.mjs expects the new export.

Why

The parser handles any depth itself, but one field like a.a.a… with thousands of parts creates an object that crashes code walking it recursively (JSON.stringify, validators, deep clones) with RangeError: Maximum call stack size exceeded.

How others handle it

default what counts when exceeded
qs 6.17.1 5 groups after the first name rest becomes one literal key ('[g]'); strictDepth throws RangeError
body-parser 2 / Express 5 32 like qs turns on strictDepth → HTTP 400
Rack 3.2.8 32 every part, including the first throws ParamsTooDeepError
PHP 8.4 max_input_nesting_level 64 brackets after the name drops the whole field silently

This PR follows Rack: count every part and throw. A literal key like '[g]' would be junk for the caller to validate, and a silent drop loses data.

Refactor

src/index.ts is split into modules: path-parts.ts (the path scanner extractPathParts, which now also enforces the limit), transform.ts (defaultTransform), errors.ts and types.ts. index.ts keeps parseFormData and the helpers that share its state. The public exports don't change. The license header moves to a tsdown banner, so it stays on top of every bundle and the .d.ts.

Tests

  • src/__tests__/path-parts.ts: the scanner on its own, with parts and pathToPart for dots, brackets, nested brackets (a[b[c]]), empty parts and depth limits, and the DepthLimitError key.
  • depth option block in src/__tests__/index.ts: no limit by default (10 000 parts), exactly at the limit for dots, [key], [0], [], empty parts and prefixes, one over the limit with the error key, a removed name, the error order (depth before forbidden key within an entry, first failing entry wins), removeEmptyString skipping a too-deep empty string, the error's key/message, the 100-character cap for both errors, and the TypeError for 0, -1, 1.5, NaN, -Infinity.
  • src/__tests__/properties.ts: for random paths and limits 1–6, the outcome (result or error) must equal one worked out from a parse without a limit plus extractPathParts: the unlimited outcome up to the first entry with too many parts, then DepthLimitError with the exact key. Since the oracle counts parts with the scanner, a wrong count in the scanner itself is caught by the hand-written cases above, not by this property. DepthLimitError is in LIBRARY_ERRORS.

Size

ESM 1.17 kB → 1.32 kB, umd.min 1.34 kB → 1.47 kB. The ESM limit goes from 1.3 kB to 1.5 kB, the same as umd.min, to leave room for the next PRs.

npm run validate passes with 100% coverage.

`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).
@codecov-commenter

codecov-commenter commented Nov 8, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (930d438) to head (a60e983).
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@            Coverage Diff            @@
##              main       #12   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            1         4    +3     
  Lines          110       121   +11     
  Branches        38        44    +6     
=========================================
+ Hits           110       121   +11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Christian Schurr added 16 commits November 8, 2026 19:53
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.
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.
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.
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.
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.
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.
The depth property gets the path parts and the error key from
extractPathParts instead of rebuilding them through parseFormData.
A too-deep path throws DepthLimitError even with a forbidden key after
the limit. Across entries, the first failing entry decides the error.
A path that's too deep throws DepthLimitError even if it would also
throw another error, like a ForbiddenKeyError for __proto__.
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.
@milamer
milamer merged commit d549dd3 into main Nov 9, 2026
9 checks passed
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.1.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants