Repository navigation
feat: add a depth option - #12
Merged
Merged
Conversation
`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 Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
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.
|
🎉 This PR is included in version 1.1.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
depthlimits the number of path parts in a path. Every part counts, including the first:a.b[0]anda[b][]both have 3. Empty parts (a..b) and the+/&/-prefixes don't count.DepthLimitErrorwith the path up to the first part over the limit:a.b[0].cwithdepth: 2→DepthLimitError('a.b[0]'). An entry that would be removed (a.toString.b) throws the same error as any other name.__proto__.a.bwithdepth: 2throwsDepthLimitError, notForbiddenKeyError. Entries are parsed in order, so an earlier entry's error wins. The depth is checked aftertransformEntry. WithremoveEmptyString, an entry with an empty string is removed before its depth is checked, so it never throws.DepthLimitErrorandForbiddenKeyErrorcutkey(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.depthmust be a positive integer orInfinity, otherwiseparseFormDatathrows aTypeErrorthat shows the given value (got 0), also without entries.0doesn't mean "unlimited": in qs it means "no nesting" and in Rack a limit of 0 rejects everything.Infinity, so nothing changes for existing users. The docs recommenddepth: 32for untrusted input (the default of Rack and Express) and say that 2.0 will lower the default.ParseFormDataOptions/parseFormDataJSDoc,DepthLimitErrorandForbiddenKeyErrorJSDoc.scripts/smoke-dist.mjsexpects 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) withRangeError: Maximum call stack size exceeded.How others handle it
'[g]');strictDepththrowsRangeErrorstrictDepth→ HTTP 400ParamsTooDeepErrormax_input_nesting_levelThis 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.tsis split into modules:path-parts.ts(the path scannerextractPathParts, which now also enforces the limit),transform.ts(defaultTransform),errors.tsandtypes.ts.index.tskeepsparseFormDataand the helpers that share its state. The public exports don't change. The license header moves to a tsdownbanner, 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 andpathToPartfor dots, brackets, nested brackets (a[b[c]]), empty parts and depth limits, and theDepthLimitErrorkey.depth optionblock insrc/__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),removeEmptyStringskipping a too-deep empty string, the error'skey/message, the 100-character cap for both errors, and theTypeErrorfor0,-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 plusextractPathParts: the unlimited outcome up to the first entry with too many parts, thenDepthLimitErrorwith 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.DepthLimitErroris inLIBRARY_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 validatepasses with 100% coverage.