fix: do not resolve query-param keys that name inherited prototype members - #1375
fix: do not resolve query-param keys that name inherited prototype members#1375mattbodle wants to merge 3 commits 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 SummaryMedium Risk Overview
Jest coverage adds cases for prototype-named keys, mixed-case key lists, fallback survival, and that filtered results remain plain objects callable with Reviewed by Cursor Bugbot for commit d17c69f. Bugbot is set up for automated code reviews on this repo. Configure here. |
…erty
The own-property guard added for caller-supplied keys left the same bug class
in place one function down, where it needs no configuration to reach.
queryStringParserFallback builds its param map from the URL, then iterates it
with `params.hasOwnProperty(key)`. `?hasOwnProperty=1` gives that map an own
property shadowing the inherited method with the string `'1'`, and the next
iteration invokes it:
queryStringParser('https://x.com?hasOwnProperty=1&foo=bar', ['foo'])
// before: TypeError: params.hasOwnProperty is not a function
// after: { foo: 'bar' }
That throws out of queryStringParser, through allowedQueryParams and
currentPage(), and out of IntegrationCapture.capture() — on the init path, from
a URL alone. Strictly more reachable than the caller-supplied-key case this
branch started with, which needs a configurable allowlist to become input at
all. The fallback is live code for browsers without URL/URLSearchParams and the
spec already exercises it, so it gets the same hasOwnProp helper and two tests
in the existing `without URLSearchParams` block.
Also corrects three things in the tests and comments rather than the code:
- The six-key list in the prototype-member test is 5/6 inert, and its comment
claimed otherwise. queryStringParser lowercases the key before the lookup, so
`toString`, `valueOf` and `hasOwnProperty` resolve to undefined, and
`__proto__` hits the prototype setter rather than creating an own property.
Only `constructor` exercises the guard. The list is kept as documentation of
the name class, with a comment that says which name does the work and notes
that the lowercasing is therefore load-bearing.
- The integrationCapture citation was wrong. `getClickIdsAsIntegrationAttributes`
calls .hasOwnProperty() on a fresh spread literal, which is a plain object
either way. The caller that matters is `applyProcessors`, which receives the
return value of `getQueryParams()` — and so of queryStringParser — directly.
- Restores the pre-existing capturedNames comment wrapping. Reflowing it moved
lines past 80 characters and added four lines of unrelated diff.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The added comments were ~65% of the added source lines, and most of them restated what the code already says or duplicated prose that belongs in the PR body. utils.ts: 15 comment lines -> 4. Kept only what is not recoverable from the code — why hasOwnProp exists at all, that callers supply their own key list, and the crash the fallback guard prevents. utils.spec.ts: split the six-key prototype test in two rather than explaining in 14 lines which of the six keys does the work. One test covers the key that exercises the guard; the other covers the names held inert by the lowercased lookup, and says so in three lines. The distinction is now structural instead of narrated. No behaviour change: 701 jest (from 700, one test split into two), 1089 karma. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
| const name = key.toLowerCase(); | ||
|
|
||
| // Callers pass their own key list, so `name` may not be from this URL. | ||
| if (!hasOwnProp(lowerCaseUrlParams, name)) { |
There was a problem hiding this comment.
Can we invert this guard to make it more readable? So that rather than return if it's negative we just assign if possitive?



Summary
Two instances of the same bug class in
queryStringParser, one latent and one reachable from a URL alone.1. Caller-supplied keys resolve through the prototype chain.
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.2.
queryStringParserFallbackthrows on a URL param namedhasOwnProperty. The fallback's returnedforEachcallsparams.hasOwnProperty(key), whereparams' keys come straight off the URL.?hasOwnProperty=1gives that map an own property shadowing the inherited method with the string'1', and the next iteration invokes it.That throws out of
queryStringParser, throughallowedQueryParamsandcurrentPage(), and out ofIntegrationCapture.capture()— on the init path, from a URL alone, with no configuration required. It is therefore strictly more reachable than (1), which needs a configurable key list to become input at all. The fallback is live code for browsers withoutURL/URLSearchParams, and the spec already exercises it (describe('without URLSearchParams')), which had no prototype-name coverage before this PR.Both are fixed with one
hasOwnProphelper.Reachability, stated plainly
integrationCapture's click IDs andpageViewTracker'sALLOWED_QUERY_PARAMS— and neither contains a prototype member name. It becomes reachable input the moment either list can be extended from configuration, which is the next PR.?hasOwnProperty=.Object.prototypeis not polluted by any of this, before or after. I verified. (1) is a data-hygiene and dedup-integrity bug and (2) is a crash — neither is a prototype-pollution vulnerability, and I would rather label them accurately than escalate.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:336—applyProcessors— calls.hasOwnProperty()on itsclickIdsargument, andgetQueryParams()passes the return value ofqueryStringParserstraight in. A null-prototype object throws there.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.
capturedNames()only ever tests names drawn fromALLOWED_QUERY_PARAMS; with a hardcoded list containing no prototype member name,inandhasOwnPropertyare unobservable through the public helpers.Test-quality corrections
The six-key prototype-member test (
['foo', 'constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty']) is 5/6 inert, and its comment claimed otherwise.queryStringParserlowercases the key before the lookup, so:constructorObject.prototype.constructor(truthy)__proto__Object.prototype, but assigning it back hits the setter rather than creating an own propertytoString/valueOf/hasOwnPropertytostring/valueof/hasownproperty→undefinedThe list is kept as documentation of the name class, with a comment that says which name does the work — and notes that the
.toLowerCase()is therefore load-bearing for the other five, which was previously undocumented.Verification
Measured, not estimated:
jest— 700 pass / 0 fail, up from 694 onorigin/development(+6: 4 from the original commit, 2 from the fallback fix)karmaChromeHeadless — 1089 pass / 0 fail, 10 skippedtsc -p . --noEmitclean,eslint src/ test/src/cleanMutation-checked — each fix deliberately broken, and the named test that catches it:
params.hasOwnProperty(key)without URLSearchParams › survives a query param named after a prototype method(and› still returns a param named after a prototype method when asked for it)queryStringParserwith URLSearchParams › does not resolve keys naming inherited Object.prototype membersname in Object.prototype)with URLSearchParams › still resolves a real query param sharing a prototype member nameFollow-ups, not fixed here
Pre-existing full-harvest bug on
development, unrelated to this PR.src/mp-instance.ts:~1546-1553enables integration capture for any truthycaptureIntegrationSpecificIds.V2value that is not'none', but only assignscaptureModefor'all'and'roktonly'. An unrecognised value leavescaptureModeundefined→getActiveIntegrationMapping()returns{}→queryStringParser(href, [])takes the no-keys branch → every query param on the page is captured and forwarded. Verified structurally, not fixed here; it wants its own PR and its own risk assessment.Unsafe
obj.hasOwnProperty(...)invocation elsewhere.src/utils.ts:47,:453,:502andsrc/integrationCapture.ts:289/:315/:336all invokehasOwnPropertyas a method on an object whose keys may come from user data. A user attribute literally namedhasOwnPropertythrows atutils.ts:453. Same class as (2) above; a follow-up ticket rather than scope creep on this stack.Stack
Part 1 of 4 for customer-configurable APV query params:
ALLOWED_QUERY_PARAMSwith a customer listSettingTemplaterow🤖 Generated with Claude Code