Skip to content

fix: restore array parsing for req.query repeated keys (#7147)#7181

Merged
jonchurch merged 2 commits into
expressjs:4.xfrom
SAY-5:fix-query-array-limit
May 8, 2026
Merged

fix: restore array parsing for req.query repeated keys (#7147)#7181
jonchurch merged 2 commits into
expressjs:4.xfrom
SAY-5:fix-query-array-limit

Conversation

@SAY-5
Copy link
Copy Markdown

@SAY-5 SAY-5 commented Apr 15, 2026

Closes #7147.

The bump to qs@~6.14.1 in 4.22.0 (#6972) pulled in qs's new default arrayLimit of 20, which causes the extended query parser to silently collapse repeated-key query strings with more than 20 values into an object (e.g. { "0": "a", "1": "b", ... }) instead of producing an array. This broke consumers that had been passing larger batches of repeated parameters through req.query.ids (the issue reporter hits it with ~20 tenancy IDs).

body-parser already got an opt-out path for the extended URL-encoded middleware, but the parseExtendedQueryString used by req.query for the 'extended' query parser still only passes allowPrototypes: true and otherwise inherits qs defaults — so the regression is only visible on req.query, which is what #7147 reports.

This PR restores the pre-6.14 behavior by passing arrayLimit: Infinity arrayLimit: 1000 to qs.parse inside parseExtendedQueryString. Repeated-key values of any length round-trip back to an array, matching Express <= 4.21.x.

Test plan

  • Added a regression test in test/req.query.js that sends 25 repeated ids values and asserts req.query.ids is an array of 25 strings. Verified the test fails on 4.x without the fix and passes with it.
  • Full test/req.query.js suite: 11 passing.

If it would help, I'm happy to open a matching PR against 5.x — the same parseExtendedQueryString lives there with the same defaults.

Copy link
Copy Markdown
Contributor

@krzysdz krzysdz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Infinity completely removes protection from sending something like arr[999999]=x and allows creating large arrays by sending just 1 parameter with an index.

PR #7151 (to master) uses a value of 1000 (same as the parameter limit), which is a better idea, but it still relaxes the limit compared to qs 6.14.0.

)

qs 6.14 enforces a default `arrayLimit` of 20, which caused the
extended query parser to collapse query strings with more than 20
repeated values (e.g. `?ids=1&ids=2&...&ids=25`) into an object
instead of an array. This regressed in 4.22.0 (with the bump to
`qs@~6.14.1`) and broke existing consumers relying on the pre-6.14
behavior.

Pass `arrayLimit: Infinity` to `qs.parse` in
`parseExtendedQueryString` so repeated keys always produce arrays,
matching the behavior of Express \<= 4.21.x. Added a regression test
in `test/req.query.js` that sends 25 repeated values and asserts
they round-trip as an array.
@SAY-5 SAY-5 force-pushed the fix-query-array-limit branch from e8b025b to c0b4eaa Compare April 15, 2026 08:05
@SAY-5
Copy link
Copy Markdown
Author

SAY-5 commented Apr 15, 2026

Thanks @krzysdz — good catch. Updated to arrayLimit: 1000 to match PR #7151 and the parameter limit used elsewhere. That still lets apps pass repeated-key arrays up to 1000 elements (which covers the failing real-world case in the issue) while bounding single-parameter expansions like arr[999999]=x.

Also added a second regression test asserting that arr[9999]=x now collapses to an object instead of allocating a sparse array, so the arrayLimit bound stays covered.

@jonchurch
Copy link
Copy Markdown
Member

I pushed an update to drop refs to the specific issue (prefer commit history for that) and to drop the sparse array test which is not related to the regression.

Copy link
Copy Markdown
Member

@jonchurch jonchurch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity, the specific root cause and knock on effect is:

qs's arrayLimit default of 20 was historically not enforced on repeated-key parsing.

qs 6.14.1 closed that gap, and Express 4.22.0 pulled the fix in via the qs@~6.14.1 bump (#6972) — so ?ids=1&ids=2&... past 20 values silently flips req.query.ids from array to object on default config.

This PR sets arrayLimit: 1000 in parseExtendedQueryString (matching parameterLimit) to restore the repeated-key behavior Express 4.x has historically supported.

Note on indexed-bracket notation: qs uses arrayLimit as a single knob across three parsing paths (repeated keys, empty-bracket arr[]=, and indexed-bracket arr[N]=). For indexed-bracket queries at indices 21..999, this PR widens the threshold past 4.21's effective limit of 20 — ?arr[500]=v will now parse as an array ["v"] rather than an object {"500":"v"}.

EDIT: see update and change suggestion #7181 (comment)

This is, to me, a surprising side effect. But I do think it is reasonable as my own experience expects that the repeated key form is more common. Open to input here though

@ljharb
Copy link
Copy Markdown

ljharb commented Apr 27, 2026

it'd actually parse as an array where index 500 will be v, not index 0, so virtually all usage patterns will work the same.

@jonchurch
Copy link
Copy Markdown
Member

@ljharb this is how Im testing it:

// express 4.21 baseline (qs 6.13.0, default arrayLimit:20)
qs.parse('arr[500]=v')
// { arr: { '500': 'v' } }

// Proposed fix (qs 6.14.2, arrayLimit:1000)
qs.parse('arr[500]=v', { arrayLimit: 1000 })
// { arr: [ 'v' ] }

arr[500]=v flips from object (key "500") to an array (value at index 0)

@ljharb
Copy link
Copy Markdown

ljharb commented Apr 27, 2026

ah right, you'd need the allowSparse: true option for the index to remain correct.

@jonchurch
Copy link
Copy Markdown
Member

with allowSparse: true we get a sparse array, but it doesnt restore the previous behavior of arr[500]=v coming back as an object.

@ljharb
Copy link
Copy Markdown

ljharb commented Apr 27, 2026

Definitely. But if you treat it like an object with 500 as a key, as someone's current code would, it would continue to work the same.

Comment thread lib/utils.js
@SAY-5
Copy link
Copy Markdown
Author

SAY-5 commented May 1, 2026

Right — that's the deciding factor. If the keyed access status[500] would still work after the change, it stays backward-compatible for everyone who's been treating it as a plain object. Should I rework the patch to keep the numeric-key access path intact (e.g. via a Proxy or a hybrid getter), or is the current shape OK?

@SAY-5
Copy link
Copy Markdown
Author

SAY-5 commented May 2, 2026

Closing — using Infinity does remove the arr[999999] protection that #7151 was discussing. Wrong tradeoff on my side; thanks for catching it.

@SAY-5 SAY-5 closed this May 2, 2026
@jonchurch jonchurch reopened this May 8, 2026
Copy link
Copy Markdown
Member

@jonchurch jonchurch left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reopened this, it is my preferred fix for the 4.x line, it is neat and tidy and fixes a regression in default settings that has been open too long.

PR restores the default extended req.query behavior 4.x had before, allowing up to 1000 array elements, by passing arrayLimit: 1000 when constructing the extended query parser.

@jonchurch jonchurch requested a review from krzysdz May 8, 2026 19:19
@jonchurch jonchurch merged commit 8d09bfe into expressjs:4.x May 8, 2026
104 checks passed
Copy link
Copy Markdown
Contributor

@krzysdz krzysdz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Objects suddenly becoming arrays should not be a big problem (arrays can be used as objects, JS being JS...).

The thing that concerns me is that someone may have relied on e.g. ?filter[1001]=abc being accessible as req.query.filter[1001]. While this has never worked for ?filter[5]=abc (req.query.filter would be ['abc']), I wouldn’t be surprised if someone has used hardcoded numbers > 20 as keys.

I think we can try releasing this as is and see if anyone reports new issues. With the allowSparse: true alternative, there would be a difference for indices <20 (small arrays suddenly are sparse and have empty elements) and surely someone would report this as a security issue (DoS).

EDIT: If this becomes an issue again, here's a small demonstration of the old behaviour and possible changes to arrayLimit and allowSparse: https://gist.github.com/krzysdz/35d7ce9962a08534226ba91d51530d7c#file-results-md

mergify Bot added a commit to robfrank/linklift that referenced this pull request Jun 4, 2026
[//]: # (dependabot-start)
⚠️ \*\*Dependabot is rebasing this PR\*\* ⚠️
Rebasing might not happen immediately, so don't worry if this takes some time.
Note: if you make any changes to this PR yourself, they will take precedence over the rebase.
---
[//]: # (dependabot-end)
Bumps [qs](https://github.com/ljharb/qs) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.
Updates `qs` from 6.14.2 to 6.15.2
Changelog

*Sourced from [qs's changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md).*

> **6.15.2**
> ----------
>
> * [Fix] `stringify`: skip null/undefined entries in `arrayFormat: 'comma'` + `encodeValuesOnly` instead of crashing in `encoder`
> * [Fix] `stringify`: use configured `delimiter` after `charsetSentinel` ([#555](https://redirect.github.com/ljharb/qs/issues/555))
> * [Fix] `stringify`: apply `formatter` to encoded key under `strictNullHandling` ([#554](https://redirect.github.com/ljharb/qs/issues/554))
> * [Fix] `stringify`: skip null/undefined filter-array entries instead of crashing in `encoder` ([#551](https://redirect.github.com/ljharb/qs/issues/551))
> * [Fix] `parse`: handle nested bracket groups and add regression tests ([#530](https://redirect.github.com/ljharb/qs/issues/530))
> * [readme] fix grammar ([#550](https://redirect.github.com/ljharb/qs/issues/550))
> * [Dev Deps] update `@ljharb/eslint-config`
> * [Tests] add regression tests for keys containing percent-encoded bracket text
>
> **6.15.1**
> ----------
>
> * [Fix] `parse`: `parameterLimit: Infinity` with `throwOnLimitExceeded: true` silently drops all parameters
> * [Deps] update `@ljharb/eslint-config`
> * [Dev Deps] update `@ljharb/eslint-config`, `iconv-lite`
> * [Tests] increase coverage
>
> **6.15.0**
> ----------
>
> * [New] `parse`: add `strictMerge` option to wrap object/primitive conflicts in an array ([#425](https://redirect.github.com/ljharb/qs/issues/425), [#122](https://redirect.github.com/ljharb/qs/issues/122))
> * [Fix] `duplicates` option should not apply to bracket notation keys ([#514](https://redirect.github.com/ljharb/qs/issues/514))


Commits

* [`9aca407`](ljharb/qs@9aca407) v6.15.2
* [`5e33d33`](ljharb/qs@5e33d33) [Dev Deps] update `@ljharb/eslint-config`
* [`21f80b3`](ljharb/qs@21f80b3) [Fix] `stringify`: skip null/undefined entries in `arrayFormat: 'comma'` + `e...
* [`a0a81ea`](ljharb/qs@a0a81ea) [Fix] `stringify`: use configured `delimiter` after `charsetSentinel`
* [`e3062f7`](ljharb/qs@e3062f7) [Fix] `stringify`: apply `formatter` to encoded key under `strictNullHandling`
* [`0c180a4`](ljharb/qs@0c180a4) [Fix] `stringify`: skip null/undefined filter-array entries instead of crashi...
* [`3a8b94a`](ljharb/qs@3a8b94a) [Tests] add regression tests for keys containing percent-encoded bracket text
* [`96755ab`](ljharb/qs@96755ab) [readme] fix grammar
* [`a419ce5`](ljharb/qs@a419ce5) [Fix] `parse`: handle nested bracket groups and add regression tests
* [`3f5e1c5`](ljharb/qs@3f5e1c5) v6.15.1
* Additional commits viewable in [compare view](ljharb/qs@v6.14.2...v6.15.2)
  
Updates `express` from 4.22.1 to 4.22.2
Release notes

*Sourced from [express's releases](https://github.com/expressjs/express/releases).*

> v4.22.2
> -------
>
> What's Changed
> --------------
>
> * fix: restore >20 array parsing for `req.query` repeated keys ([`8d09bfe6`](expressjs/express@8d09bfe))
>   + This also unifies array-cap behavior across notations. Indexed notation (`a[0]=...`) was historically capped at qs's default `arrayLimit` of 20 even in older qs versions; after this change it also allows up to 1000 items.
> * deps: qs@~6.15.1
> * deps: body-parser@~1.20.5
>
> New Contributors
> ----------------
>
> * [`@​suuuuuuminnnnnn`](https://github.com/suuuuuuminnnnnn) made their first contribution in [expressjs/express#7021](https://redirect.github.com/expressjs/express/pull/7021)
> * [`@​SAY-5`](https://github.com/SAY-5) made their first contribution in [expressjs/express#7181](https://redirect.github.com/expressjs/express/pull/7181)
>
> **Full Changelog**: <expressjs/express@v4.22.1...v4.22.2>


Changelog

*Sourced from [express's changelog](https://github.com/expressjs/express/blob/v4.22.2/History.md).*

> 4.22.2 / 2026-05-011
> ====================
>
> * fix: restore >20 array parsing for `req.query` repeated keys ([`8d09bfe6`](expressjs/express@8d09bfe))
>   + This also unifies array-cap behavior across notations. Indexed notation (`a[0]=...`) was historically capped at qs's default `arrayLimit` of 20 even in older qs versions; after this change it also allows up to 1000 items.
> * deps: qs@~6.15.1
> * deps: body-parser@~1.20.5


Commits

* [`df0abc9`](expressjs/express@df0abc9) 4.22.2
* [`836d366`](expressjs/express@836d366) `4.x` update qs to 6.15.1, body-parser 1.20.5 ([#7224](https://redirect.github.com/expressjs/express/issues/7224))
* [`8d09bfe`](expressjs/express@8d09bfe) fix: restore array parsing for req.query repeated keys ([#7181](https://redirect.github.com/expressjs/express/issues/7181))
* [`d39e8ad`](expressjs/express@d39e8ad) deps: body-parser@~1.20.4 ([#7021](https://redirect.github.com/expressjs/express/issues/7021))
* [`efe85d9`](expressjs/express@efe85d9) deps: qs@^6.14.1 ([#6972](https://redirect.github.com/expressjs/express/issues/6972))
* [`f62378e`](expressjs/express@f62378e) 📝 add note to history
* See full diff in [compare view](expressjs/express@v4.22.1...v4.22.2)
  
Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
  
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot show  ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/robfrank/linklift/network/alerts).
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.

4 participants