feat(apv): union the query-param allowlist with a customer-configured list - #1373
feat(apv): union the query-param allowlist with a customer-configured list#1373mattbodle wants to merge 7 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
Reviewed by Cursor Bugbot for commit b792672. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit 0217059. 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>
0217059 to
a78a3d3
Compare
SonarCloud S2871 (CRITICAL) — a bare Array.prototype.sort(). It failed the quality gate on new_reliability_rating for mParticle#1373 and mParticle#1374, and it is the same rule that was removed from this file in mParticle#1366; the custom-suffix sort reintroduced it. Deliberately NOT localeCompare, which is what Sonar's message suggests. This ordering feeds the page-view dedup key, so it has to be identical everywhere, and localeCompare varies by locale — two browsers would disagree about whether a page had changed. Names are validated to /^[a-z0-9_][a-z0-9_.-]{0,63}$/, so a plain code-unit comparison is total and stable across the whole input domain. Behaviour is unchanged: 768 jest and 1089 karma, same as before. 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>
… list
Adds the `autoLogPageViewQueryParams` feature flag. The dashboard sends a
comma-separated list of extra query param names; the SDK unions it with the
built-in ALLOWED_QUERY_PARAMS. Absent or empty behaves exactly as 2.80.x, which
is the kill switch.
effective allowlist = ALLOWED_QUERY_PARAMS ∪ customer additions
Built-ins keep their leading position, and that ordering is load-bearing rather
than cosmetic. pageKey walks the effective list, so preserving the built-in
prefix means a customer who opts in gets byte-identical keys for pages that use
none of their custom params. Reorder it and adding a single param would change
every key at once, firing one spurious page view per page on the first
navigation after rollout. There is a test stating exactly that property.
Captured params become an ordered pair list rather than a dictionary. pageKey
and describePage previously took their order from the module constant; with a
configurable list they would each need it threaded in. Capturing the order at
snapshot time means neither needs the allowlist at all, and capturedNames() is
deleted.
Validation runs twice on purpose. The dashboard validates for the customer's
benefit; the SDK re-validates because remote config is untrusted input — it
arrives over the network into a third-party embed on the customer's page, and
the SDK cannot assume anything checked it. Names must match
/^[a-z0-9_][a-z0-9_.-]{0,63}$/, may not be hostname/title/path (the core event
fields) or constructor/__proto__/prototype, and are capped at 25 entries.
Duplicates of built-ins are dropped rather than rejected — asking for something
you already have is not an error.
Two things found while writing the tests rather than after:
- The rejected-name warning echoed back whatever the customer typed, so
`password=hunter2` put the value in the console where session-replay tooling
would ship it off-domain. Rejected entries are now cut at the first
name-illegal character, since that character is why the entry was rejected and
anything after it is untrusted. `password=hunter2` logs as `password...`.
- A mis-shaped flag value threw inside logPageView, which would have taken every
page view with it rather than just ignoring a bad setting. effectiveAllowlist
now tolerates a raw string as well as the validated list.
The custom-value length cap applies to custom params only. Built-in behaviour is
deliberately untouched so nobody loses a long utm_content on upgrade; custom
params are where unbounded choice enters. Dropped rather than truncated —
truncating makes two different long values produce the same dedup key, which
silently swallows a real page view.
This also carries the regression test the hardening PR could not write: with a
hardcoded allowlist, `in` and hasOwnProperty are indistinguishable because no
built-in names a prototype member. A configured list makes it observable, and
reverting capturedNames to `in` now fails three tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tion
Five corrections to the configurable-allowlist commit. Two of them were bugs in
what it shipped, not in how it was described.
The rejected-name warning was dead in production. processFlags stored only
`parseQueryParamAllowlist(raw).allowed`, and readCustomQueryParams then re-parsed
that already-clean array — so `rejected` was always empty and the warning could
never fire. Its test passed only because it mocked getFeatureFlag with a raw
comma-separated string, which production never sends. Credit to Cursor Bugbot for
spotting it.
The fix is not to move the parse. parseQueryParamAllowlist now returns one value
with every part of its verdict, processFlags stores it whole, and the tracker
reports from what it was handed. There is exactly one validator, at the config
boundary, which is what AGENTS.md asks for — and the tracker test now hands the
tracker the shape production sends, so it can no longer pass for the wrong
reason.
The sanitiser leaked values. The claim that a rejected entry could not echo one
was wrong: `.`, `-` and `_` are all name-legal, so cutting at the first illegal
character does not cut at all for most inputs, and a leading `-` or `.` is
illegal only in first position, so the regex consumed the entry whole and the
truncation branch never ran. Measured against the old code:
"password=hunter2" -> "password..." safe
"token.hunter2 x" -> "token.hunter2..." leaked
"user.email.hunter2@x" -> "user.email.hunter2..." leaked
"-secret-value-abcdef" -> "-secret-value-abcdef" leaked whole
".hunter2" -> ".hunter2" leaked whole
This goes to Logger.warning, so it reaches the console, which session-replay
tooling ships off-domain — the exact scenario the function's own comment named.
Rejections are now reported as 1-based positions and never as text:
`ignoring invalid additional page view query parameters at positions 2, 4`. A
number cannot leak, and it also fixes the useless empty label a non-ASCII entry
produced, where every character is illegal and the legal prefix was "".
Then three smaller things:
- Custom additions are sorted rather than appended in config order. pageKey walks
the effective list, so in config order a customer who merely SWAPS two
co-occurring custom params changes the key of every page carrying both and
fires one spurious page view per such page — the failure the previous commit
message ruled out for built-ins while leaving it open for customs. Built-ins
keep their positions and customs do not exist in 2.80.x, so no key that exists
today moves.
- Entries past the 25 cap were dropped with no report of any kind: the cap was
checked at the top of the loop, before validation, so entry 26 was neither
accepted nor rejected. The check moved below the duplicate and blank filters,
so neither consumes headroom, and the losers are counted and warned about.
- `True` is a legal query param name. Every other flag in this SDK is compared
`=== 'True'`, so setting this one the same way silently configured a param
named `true`. true/false are now reserved.
Retractions from the previous commit message, which stated both as fact:
- "There is a test stating exactly that property" — the byte-identity test called
the NEW allowedQueryParams on both sides, so it compared new against new and
would still pass if the whole union were sorted. It is now asserted against a
literal, which is what byte-identical means. The built-in ORDER was genuinely
pinned, by 'should keep the built-ins first, in their existing order'.
- "A mis-shaped flag value threw inside logPageView" — processFlags makes that
unreachable. effectiveAllowlist no longer accepts a raw string at all; what it
does need to survive is the `null` getFeatureFlag returns for an absent flag,
which a default parameter does not catch, hence `|| []`.
The pair-list refactor was also described as forced. It was not: threading the
allowlist into pageKey's two call sites was a workable alternative. It was a
preference between two shapes.
getFeatureFlag's declared return type is widened to cover what processFlags
actually stores, which removes an `as unknown as` double cast in events.ts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SonarCloud S2871 (CRITICAL) — a bare Array.prototype.sort(). It failed the quality gate on new_reliability_rating for mParticle#1373 and mParticle#1374, and it is the same rule that was removed from this file in mParticle#1366; the custom-suffix sort reintroduced it. Deliberately NOT localeCompare, which is what Sonar's message suggests. This ordering feeds the page-view dedup key, so it has to be identical everywhere, and localeCompare varies by locale — two browsers would disagree about whether a page had changed. Names are validated to /^[a-z0-9_][a-z0-9_.-]{0,63}$/, so a plain code-unit comparison is total and stable across the whole input domain. Behaviour is unchanged: 768 jest and 1089 karma, same as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments were 48% of the added source lines. Most restated the code or duplicated reasoning that already lives in the PR body, which is where a reader looking for the rationale of a change should find it. pageViewTracker.ts: 154 comment lines -> 47. - RESERVED_QUERY_PARAMS: 15 lines of prose replaced by grouping the array, so the grouping carries the three reasons instead of a paragraph describing them. - IQueryParamAllowlist: 18 -> 6. The field is named rejectedPositions; it does not need nine lines to say it holds positions. - effectiveAllowlist: 31 -> 7, and reattached to its function. The Sonar fix had inserted byName between the comment and the declaration it documents, so the ordering rationale had silently become a comment about the comparator. - allowedQueryParams / pageKey / the value cap / readCustomQueryParams: trimmed to the part that is not recoverable from the code. store.ts: 7 -> 2. Kept, because none of it can be read off the code: why the comparator is not localeCompare, why rejections are positions and not names, why long custom values are dropped rather than truncated, and why the tracker must not re-validate. No behaviour change: 769 jest, 1089 karma, tsc and eslint clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
334262f to
b792672
Compare
|




Summary
Adds the
autoLogPageViewQueryParamsfeature flag. The dashboard sends a comma-separated list of extra query param names; the SDK unions it with the built-inALLOWED_QUERY_PARAMS.Absent or empty behaves exactly as 2.80.x. That is the kill switch, and it means rolling this back is clearing a text field rather than cutting an SDK release — the whole reason it lives in config and not in the constant.
No new
window.mParticlesurface.Built-ins keep their position; customs are sorted
pageKeywalks the effective list, so preserving the built-in prefix means a customer who opts in gets byte-identical dedup keys for pages that use none of their custom params. Reorder that prefix and adding a single param changes every key at once, firing one spurious page view per page on the first navigation after rollout.The custom suffix is sorted, for the same reason one step further in. Appended in config order, the suffix depends on the sequence the customer typed rather than the set — so a customer who merely swaps two co-occurring custom params in the Advanced Settings field changes the key of every page carrying both, and each of those fires one spurious view. Sorting makes the suffix depend on the set. Built-ins keep their positions, and custom params do not exist in 2.80.x, so no key that exists today moves.
Ordered pairs instead of a dictionary
pageKeyanddescribePagepreviously took their ordering from the module constant viacapturedNames(). With a configurable list, each would need the allowlist threaded in. Capturing the order at snapshot time means neither needs it at all, andcapturedNames()is deleted.Validation runs once, at the config boundary
processFlagsparses and validates the customer's string as it arrives, and stores the whole verdict — the accepted names, the positions it rejected, and how many entries it dropped past the cap. Nothing downstream re-validates.That is a change from the first revision of this branch, and it fixes a real bug (below).
The SDK validates at all — rather than trusting the dashboard — because remote config is untrusted input: it arrives over the network into a third-party embed running on the customer's page, and the SDK cannot assume anything checked it.
/^[a-z0-9_][a-z0-9_.-]{0,63}$/&,=,%, whitespace — what would let a name distort the dedup key or an attribute namehostname/title/pathconstructor/__proto__/prototypetrue/false=== 'True'. An operator who sets this one the same way sends the literalTrue, which is a legal param name — so it would silently pass validation and the SDK would start capturing?true=.Two bugs from the first revision of this branch
1. The rejected-name warning was dead in production
Found by Cursor Bugbot, and it was right.
processFlagsstored onlyparseQueryParamAllowlist(raw).allowed, andreadCustomQueryParamsthen re-parsed that already-clean array — sorejectedwas always[]and the warning could never fire. Measured:The tracker tests passed only because they mocked
getFeatureFlagto return a raw comma-separated string, which production never sends.The fix is not to move the parse.
parseQueryParamAllowlistnow returns one value carrying every part of its verdict,processFlagsstores it whole, and the tracker reports from what it was handed. There is exactly one validator, at one boundary — which is also whatAGENTS.mdasks for ("only validate at system boundaries"). And the tracker test now hands the tracker the shape production sends, so it cannot pass for the wrong reason again.2. The sanitiser leaked values
The first revision claimed the rejected-entry label could not echo a value. That was false.
.,-and_are all in the legal name class, so cutting at "the first illegal character" does not cut at all for most inputs; and a leading-or.is illegal only in first position, so/^[a-z0-9_.-]*/consumed the entry whole and the truncation branch never ran. Measured against the old code:password=hunter2password...token.hunter2 xtoken.hunter2...user.email.hunter2@xuser.email.hunter2...-secret-value-abcdef-secret-value-abcdef.hunter2.hunter2This goes to
Logger.warning→console, which session-replay tooling ships off-domain — the exact scenario the function's own comment named.Rejections are now reported as 1-based positions and never as text:
A number cannot leak. It also fixes the useless empty label a non-ASCII entry produced (
émailreported an empty name, because every character is illegal and the legal prefix is""). Positions count every comma-separated slot including blanks, so a reported position lines up with what the customer typed.Entries past the cap are now reported
The cap check used to
returnat the top of the loop, before validation — so entry 26 and beyond were neither accepted nor reported, and a customer who pasted 40 names got 25 with no indication that 15 vanished. The check moved below the duplicate and blank filters, so neither consumes headroom, and the losers are counted:The value cap applies to custom params only
Built-in behaviour is untouched, so no existing customer loses a long
utm_contenton upgrade. Custom params are where unbounded choice enters: a param holding a base64 blob would bloat every APV event and every dedup key.Dropped, not truncated. Truncating makes two different long values produce the same key, which silently swallows a real page view.
No aggregate payload bound exists. 25 custom params × 512 bytes ≈ 12.8 KB per APV event on top of the built-ins, in the worst case. There is no cap on the total, only per-value. I have deliberately not added one — a global bound needs a number someone owns, and the per-value cap plus the 25-entry cap is a reasonable first fence — but it should be a known number rather than a surprise.
effectiveAllowlistand a mis-shaped flagWhat it does still need to survive is the
nullthatgetFeatureFlagreturns when the flag is absent, which is the common case and which a default parameter does not catch (defaults fire only forundefined). Hence|| [], with a test fornullspecifically.An array is trusted as pre-validated, which is what lets the tests inject hostile names directly and prove the own-property check in
allowedQueryParamsis a real backstop rather than dead code sitting behind validation.getFeatureFlag's declared typegetFeatureFlagwas declaredboolean | stringwhileIFeatureFlagsalready held a non-scalar for this flag, which forced anas unknown asdouble cast inevents.ts. The declared return type (inhelpers.tsandSDKHelpersApi) is widened to cover whatprocessFlagsactually stores, and the double cast is gone.This carries #1375's missing test
#1375 switched
capturedNames()frominto an own-property check and shipped without a test: with a hardcoded allowlist the two are unobservable through the public helpers, because no built-in names a prototype member. A configured list makes it observable without touching module state. Reverting that line now fails three tests here.Verification
Measured on this branch's tip:
jest— 768 pass / 0 fail, up from 700 on fix: do not resolve query-param keys that name inherited prototype members #1375 (+68)karmaChromeHeadless — 1089 pass / 0 fail, 10 skipped, after a freshnpm run build && npm run build:test-bundletsc -p . --noEmitclean,eslint src/ test/src/cleanMutation-checked — each behaviour deliberately broken, and the named test(s) that catch it:
processFlagsstores only.allowed(the Bugbot bug, restored)Store #processFlags should return default featureFlags…,…should return featureFlags if featureFlags are passed in,Store #processConfig should process feature flags(karma)PageViewTracker › configured additional query params, incl.should warn with positions for what it rejected, never textshould report only a position for …cases.sort()dropped from the custom suffix#effectiveAllowlist › should append extras sorted, whatever order they were given in#pageKey › should be this exact key with no custom params configured(+6 others). The old byte-identity test passed under this mutation, which is why it was replaced.should count the entries dropped past the cap,should not let duplicates consume cap headroom,should warn about entries dropped past the captrue/falseremoved from the reserved listshould reject the boolean-looking value …cases|| []removed fromeffectiveAllowlist#effectiveAllowlist › should tolerate nullOne of Becca's reported leak cases,
api-key-abc123def, turned out not to be a leak: it is a legal param name (-is in the body class), so the parser accepts it and never labels it. The oldrejectedLabeldid return it whole, but only when called directly — it was unreachable throughparseQueryParamAllowlist. Noted for accuracy; the other five were real.Follow-ups
Part 2 of 4:
SettingTemplaterowThen #1374, which removes
code,state,nonceand the other OAuth/OIDC params from the built-in list. That only stops being a capability loss once this PR lands.🤖 Generated with Claude Code