Skip to content

fix: do not resolve query-param keys that name inherited prototype members - #1372

Closed
mattbodle wants to merge 1 commit into
mParticle:developmentfrom
mattbodle:hardening/query-param-lookup
Closed

fix: do not resolve query-param keys that name inherited prototype members#1372
mattbodle wants to merge 1 commit into
mParticle:developmentfrom
mattbodle:hardening/query-param-lookup

Conversation

@mattbodle

Copy link
Copy Markdown
Contributor

Summary

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. There is a test for that.

Reachability, stated plainly

Not reachable in production today. Both callers pass hardcoded key lists — integrationCapture's click IDs and 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. That is the next PR, which is why this one goes first and on its own.

Object.prototype is 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:

  1. queryStringParser returns that same map directly when no keys are passed (if (isEmpty(keys)) return lowerCaseUrlParams), so the null prototype would escape to callers.
  2. integrationCapture.ts:289 calls .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's capturedNames() from in to the same own-property check.

That half has no test in this PR, deliberately and with an explanation. capturedNames() only ever tests names drawn from ALLOWED_QUERY_PARAMS; with a hardcoded list containing no prototype member name, in and hasOwnProperty cannot 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

  • jest698 pass / 0 fail (from 692; +6)
  • karma ChromeHeadless — 1089 pass / 0 fail
  • tsc -p . clean, eslint src/ test/src/ clean
  • Mutation-checked: removing the guard fails queryStringParser › 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:

  1. this PR — hardening
  2. flag + union of ALLOWED_QUERY_PARAMS with a customer list
  3. mPServer: SettingTemplate row
  4. mPServer: Advanced Settings UI field

🤖 Generated with Claude Code

…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>
@mattbodle
mattbodle requested a review from a team as a code owner August 26, 2026 08:20
@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

PR Summary

Low Risk
Localized query-param hygiene and dedup key logic; no auth or payment paths, and current callers use hardcoded key lists.

Overview
Hardens query-string parsing and auto page-view dedup so caller-supplied key names cannot pick up inherited Object.prototype members (e.g. constructor) as if they came from the URL.

Adds shared hasOwnProp in utils and uses it in queryStringParser before copying requested keys—real params like ?constructor=legitimate still work. pageViewTracker’s capturedNames switches from in to the same own-property check ahead of configurable allowlists.

New Jest cases cover false prototype-key resolution, legitimate same-name params, and plain-object results for callers that use .hasOwnProperty() on the parser output.

Reviewed by Cursor Bugbot for commit 98e2e9a. Bugbot is set up for automated code reviews on this repo. Configure here.

@sonarqubecloud

Copy link
Copy Markdown

@mattbodle

Copy link
Copy Markdown
Contributor Author

Superseded by #1375 — identical commit, branch renamed from hardening/ to fix/ so it passes the semantic branch-name check. Closing to avoid two PRs for one change.

@mattbodle mattbodle closed this Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant