Skip to content

feat(apv): union the query-param allowlist with a customer-configured list - #1373

Open
mattbodle wants to merge 7 commits into
mParticle:developmentfrom
mattbodle:feat/apv-configurable-query-params
Open

feat(apv): union the query-param allowlist with a customer-configured list#1373
mattbodle wants to merge 7 commits into
mParticle:developmentfrom
mattbodle:feat/apv-configurable-query-params

Conversation

@mattbodle

@mattbodle mattbodle commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Depends on #1375 — merge that first. This branch is stacked on it, so the diff shown here includes it until #1375 lands.

Summary

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.

effective allowlist = ALLOWED_QUERY_PARAMS ∪ sort(customer additions)

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.

// flags: { autoLogPageViewQueryParams: 'promo_code, affiliate_id' }
data: { hostname, title, path: '/deal', utm_source: 'google', promo_code: 'SAVE20' }

No new window.mParticle surface.

Built-ins keep their position; customs are sorted

pageKey walks 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.

Correction from the first revision, which said "There is a test stating exactly that property" about byte-identity. The test it referred to called the new allowedQueryParams on both sides, so it compared new against new: if the whole union were sorted, both sides move together and it still passes. It is now asserted against a literal ('/p?utm_source=google&gclid=abc'), which is what byte-identical means. The built-in ordering was genuinely pinned, by 'should keep the built-ins first, in their existing order'.

Ordered pairs instead of a dictionary

pageKey and describePage previously took their ordering from the module constant via capturedNames(). With a configurable list, each would need the allowlist threaded in. Capturing the order at snapshot time means neither needs it at all, and capturedNames() is deleted.

Correction: the first revision framed this as forced. It was not. Threading the allowlist into pageKey's two call sites was a workable alternative. This was a preference between two shapes, and the pair list won on the grounds that fewer functions need to know about the allowlist — not because the alternative did not work.

Validation runs once, at the config boundary

processFlags parses 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.

Rule Reason
/^[a-z0-9_][a-z0-9_.-]{0,63}$/ excludes &, =, %, whitespace — what would let a name distort the dedup key or an attribute name
not hostname / title / path the three core event fields
not constructor / __proto__ / prototype resolve to something truthy through the prototype chain
not true / false every other flag in this SDK is compared === 'True'. An operator who sets this one the same way sends the literal True, which is a legal param name — so it would silently pass validation and the SDK would start capturing ?true=.
max 25 entries mParticle caps attributes per event; 24 built-ins + 25 leaves headroom
duplicate of a built-in → dropped, not rejected asking for something you already have is not an error

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. processFlags stored only parseQueryParamAllowlist(raw).allowed, and readCustomQueryParams then re-parsed that already-clean array — so rejected was always [] and the warning could never fire. Measured:

processFlags stores: ["promo_code"]     (from 'promo_code, bad name')
tracker re-parse -> rejected: []
warning fires in production? false

The tracker tests passed only because they mocked getFeatureFlag to return a raw comma-separated string, which production never sends.

The fix is not to move the parse. parseQueryParamAllowlist now returns one value carrying every part of its verdict, processFlags stores it whole, and the tracker reports from what it was handed. There is exactly one validator, at one boundary — which is also what AGENTS.md asks 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:

entry logged label
password=hunter2 password... safe — the only case originally tested
token.hunter2 x token.hunter2... leaked
user.email.hunter2@x user.email.hunter2... leaked
-secret-value-abcdef -secret-value-abcdef leaked whole, no ellipsis
.hunter2 .hunter2 leaked whole

This goes to Logger.warningconsole, 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:

mParticle APV: ignoring invalid additional page view query parameters at positions 2, 4

A number cannot leak. It also fixes the useless empty label a non-ASCII entry produced (émail reported 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 return at 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:

mParticle APV: ignoring 15 additional page view query parameters beyond the limit of 25

The value cap applies to custom params only

Built-in behaviour is untouched, so no existing customer loses a long utm_content on 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.

effectiveAllowlist and a mis-shaped flag

Correction: the first revision said "A mis-shaped flag value threw inside logPageView". processFlags makes that unreachable — every path into SDKConfig.flags goes through it. effectiveAllowlist no longer accepts a raw string at all.

What it does still need to survive is the null that getFeatureFlag returns when the flag is absent, which is the common case and which a default parameter does not catch (defaults fire only for undefined). Hence || [], with a test for null specifically.

An array is trusted as pre-validated, which is what lets the tests inject hostile names directly and prove the own-property check in allowedQueryParams is a real backstop rather than dead code sitting behind validation.

getFeatureFlag's declared type

getFeatureFlag was declared boolean | string while IFeatureFlags already held a non-scalar for this flag, which forced an as unknown as double cast in events.ts. The declared return type (in helpers.ts and SDKHelpersApi) is widened to cover what processFlags actually stores, and the double cast is gone.

This carries #1375's missing test

#1375 switched capturedNames() from in to 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:

Mutation-checked — each behaviour deliberately broken, and the named test(s) that catch it:

mutation test(s) that fail
processFlags stores 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)
tracker re-parses the list it was handed 5 tests in PageViewTracker › configured additional query params, incl. should warn with positions for what it rejected, never text
parser records the entry text instead of the position 26 tests, incl. all five should report only a position for … cases
.sort() dropped from the custom suffix #effectiveAllowlist › should append extras sorted, whatever order they were given in
whole union sorted, built-ins included #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.
cap checked at the top of the loop again should count the entries dropped past the cap, should not let duplicates consume cap headroom, should warn about entries dropped past the cap
true/false removed from the reserved list the four should reject the boolean-looking value … cases
|| [] removed from effectiveAllowlist #effectiveAllowlist › should tolerate null

One 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 old rejectedLabel did return it whole, but only when called directly — it was unreachable through parseQueryParamAllowlist. Noted for accuracy; the other five were real.

Follow-ups

Part 2 of 4:

  1. fix: do not resolve query-param keys that name inherited prototype members #1375 — hardening
  2. this PR — flag + union
  3. mPServer: SettingTemplate row
  4. mPServer: Advanced Settings UI field

Then #1374, which removes code, state, nonce and 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

…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:33
@cursor

cursor Bot commented Aug 26, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes APV attribution and SPA dedup behavior for configured params, and parses untrusted remote config; mitigations include validation at the config boundary and reserved-name checks, but misconfiguration could still alter which URL data reaches events.

Overview
Adds the autoLogPageViewQueryParams remote flag so customers can extend auto page view (APV) capture beyond the built-in query param list. processFlags parses the comma-separated config once into an IQueryParamAllowlist (accepted names, rejected positions, over-limit count); landing logPageView and PageViewTracker union those names with ALLOWED_QUERY_PARAMS when building page view attributes and dedup keys.

allowedQueryParams now returns ordered ICapturedParam[] (custom suffix sorted; built-in prefix unchanged so existing dedup keys stay stable when custom params are absent). Custom param values are capped at 512 chars (dropped, not truncated); invalid config entries trigger position-only warnings at tracker init, not re-parsed text in logs.

queryStringParser / hasOwnProp harden lookups against prototype pollution when the allowlist is customer-controlled.

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit 0217059. Configure here.

Comment thread src/store.ts Outdated
…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>
@mattbodle
mattbodle force-pushed the feat/apv-configurable-query-params branch from 0217059 to a78a3d3 Compare August 26, 2026 22:20
mattbodle added a commit to mattbodle/mparticle-web-sdk that referenced this pull request Aug 26, 2026
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>
mattbodle and others added 5 commits August 27, 2026 13:22
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>
@mattbodle
mattbodle force-pushed the feat/apv-configurable-query-params branch from 334262f to b792672 Compare August 27, 2026 03:26
@sonarqubecloud

Copy link
Copy Markdown

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