fix: do not resolve query-param keys that name inherited prototype members - #1372
fix: do not resolve query-param keys that name inherited prototype members#1372mattbodle wants to merge 1 commit into
Conversation
…mbers
queryStringParser takes a caller-supplied list of keys and looks each one up on
a plain object. `obj[name]` walks the prototype chain, so a key named
`constructor` resolves to Object.prototype.constructor, passes the truthiness
gate, and is copied into the result as though the URL had supplied it:
queryStringParser('https://x.com?foo=bar', ['constructor'])
// before: { constructor: 'function Object() { [native code] }' }
// after: {}
A real `?constructor=legitimate` in the URL still resolves — the guard is an
own-property check, not a name ban.
Not reachable in production today: both callers pass hardcoded key lists
(integrationCapture's click ids, pageViewTracker's ALLOWED_QUERY_PARAMS) and
neither contains a prototype member name. It is a latent bug in a shared
exported util, and it becomes reachable input the moment either list can be
extended from configuration, which is the next PR.
Guarding the lookup rather than dropping the returned object's prototype is
deliberate. `Object.create(null)` would be a smaller-looking fix but
queryStringParser returns that same map directly when no keys are passed, and
integrationCapture calls `.hasOwnProperty()` on the result — which throws on a
null-prototype object. There is a test pinning the returned object as plain.
Object.prototype is NOT polluted by any of this, before or after. This is a
data-hygiene bug, not a prototype-pollution vulnerability.
Also switches pageViewTracker's capturedNames() from `in` to the same
own-property check. That half has no test here, honestly: capturedNames only
ever tests names drawn FROM ALLOWED_QUERY_PARAMS, so with a hardcoded list
containing no prototype member name the distinction is unobservable. It gains
its regression test in the PR that makes the allowlist configurable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR SummaryLow Risk Overview Adds shared New Jest cases cover false prototype-key resolution, legitimate same-name params, and plain-object results for callers that use Reviewed by Cursor Bugbot for commit 98e2e9a. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
|
Superseded by #1375 — identical commit, branch renamed from |



Summary
queryStringParsertakes a caller-supplied list of keys and looks each one up on a plain object.obj[name]walks the prototype chain, so a key namedconstructorresolves toObject.prototype.constructor, passes the truthiness gate, and is copied into the result as though the URL had supplied it.A real
?constructor=legitimatein the URL still resolves — the guard is an own-property check, not a name ban. There is a test for that.Reachability, stated plainly
Not reachable in production today. Both callers pass hardcoded key lists —
integrationCapture's click IDs andpageViewTracker'sALLOWED_QUERY_PARAMS— and neither contains a prototype member name.It is a latent bug in a shared exported util, and it becomes reachable input the moment either list can be extended from configuration. That is the next PR, which is why this one goes first and on its own.
Object.prototypeis not polluted by any of this, before or after. I verified. This is a data-hygiene and dedup-integrity bug, not a prototype-pollution vulnerability, and I would rather label it accurately than escalate it.Why guard the lookup instead of
Object.create(null)Object.create(null)on the lookup map looks like the smaller fix and kills the whole class at the root. It is the wrong call here for two concrete reasons:queryStringParserreturns that same map directly when no keys are passed (if (isEmpty(keys)) return lowerCaseUrlParams), so the null prototype would escape to callers.integrationCapture.ts:289calls.hasOwnProperty()on the result, which throws on a null-prototype object.So the returned object stays plain, and a test pins that contract.
The other half, and its missing test
This also switches
pageViewTracker'scapturedNames()frominto the same own-property check.That half has no test in this PR, deliberately and with an explanation.
capturedNames()only ever tests names drawn fromALLOWED_QUERY_PARAMS; with a hardcoded list containing no prototype member name,inandhasOwnPropertycannot be told apart by any test. I wrote one, mutation-checked it, found it passed with the fix reverted, and deleted it rather than ship a test that implies coverage it does not have. It gains a real regression test in the follow-up PR, where the allowlist becomes configurable and the distinction becomes observable.Verification
jest— 698 pass / 0 fail (from 692; +6)karmaChromeHeadless — 1089 pass / 0 failtsc -p .clean,eslint src/ test/src/cleanqueryStringParser › does not resolve keys naming inherited Object.prototype members. The two vacuous tests were found this way and removed or re-labelled.Follow-ups
Part 1 of 4 for customer-configurable APV query params:
ALLOWED_QUERY_PARAMSwith a customer listSettingTemplaterow🤖 Generated with Claude Code