From d82033c2f691b986a7ba560a5566c737c52497fb Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Wed, 30 Sep 2026 01:04:41 +0400 Subject: [PATCH] fix: deny SDK-generated $session_entry_*/$initial_* campaign properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit posthog-js stamps $session_entry_ and $initial_ for every campaign param it sees in the query string — a hostile URL like /?utm_source=SECRET therefore leaked utm values inside otherwise sanitized events. Enumerate every derived variant in the denylist and add a prefix-level guard so custom_campaign_params and future variants are covered too. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/analytics/posthogModel.mjs | 54 ++++++++++++++++++++++----------- test/posthog-analytics.test.mjs | 14 +++++++++ 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/analytics/posthogModel.mjs b/src/analytics/posthogModel.mjs index 181ac8c..5f63186 100644 --- a/src/analytics/posthogModel.mjs +++ b/src/analytics/posthogModel.mjs @@ -127,6 +127,29 @@ export function resolveAnalyticsHost(configured, origin) { // SDK-assembled properties derived from URLs, query strings, referrers and // click/campaign ids — none of these may leave the browser. Applied by // property_denylist at capture assembly AND re-checked in before_send. +// Query-string/campaign parameter names the SDK lifts into campaign_params +// and $session_entry_/$initial_ variants. The SDK builds those derived names +// dynamically (`$session_entry_${param}`), so they are enumerated here. +const CAMPAIGN_PARAM_NAMES = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'campaign_params', + 'gclid', + 'fbclid', + 'msclkid', + 'ttclid', + 'twclid', + 'igshid', + 'li_fat_id', + 'mc_cid', + 'dclid', + 'wbraid', + 'gbraid', +]; + export const DENIED_EVENT_PROPERTIES = [ '$current_url', '$pathname', @@ -148,25 +171,21 @@ export const DENIED_EVENT_PROPERTIES = [ '$initial_referring_domain', '$initial_campaign_params', '$initial_referrer_info', - 'utm_source', - 'utm_medium', - 'utm_campaign', - 'utm_term', - 'utm_content', - 'campaign_params', - 'gclid', - 'fbclid', - 'msclkid', - 'ttclid', - 'twclid', - 'igshid', - 'li_fat_id', - 'mc_cid', - 'dclid', - 'wbraid', - 'gbraid', + '$initial_person_info', + ...CAMPAIGN_PARAM_NAMES, + // Derived session-entry/initial variants — e.g. $session_entry_utm_source. + ...CAMPAIGN_PARAM_NAMES.flatMap((p) => [ + `$session_entry_${p}`, + `$initial_${p}`, + ]), ]; +// Prefixes the SDK uses for session-entry and persisted "initial" URL, +// referrer and campaign properties. Denying at prefix level in +// before_send covers custom_campaign_params and any future variant the +// static denylist cannot enumerate. +const DENIED_PROPERTY_PREFIXES = ['$session_entry_', '$initial_']; + // posthog-js places these two transport-critical fields inside the event's // properties object before before_send runs. They are not caller-controlled: // token is the public project token already present in the browser bundle, @@ -184,6 +203,7 @@ export function scrubEventProperties(eventName, properties, pageviewProps) { const requiredSdk = new Set(REQUIRED_SDK_EVENT_PROPERTIES); const clean = {}; for (const [key, value] of Object.entries(properties || {})) { + if (DENIED_PROPERTY_PREFIXES.some((p) => key.startsWith(p))) continue; if (DENIED_EVENT_PROPERTIES.includes(key)) continue; if (!key.startsWith('$') && !allowedCustom.has(key) && !requiredSdk.has(key)) continue; clean[key] = value; diff --git a/test/posthog-analytics.test.mjs b/test/posthog-analytics.test.mjs index 5b74904..21916d6 100644 --- a/test/posthog-analytics.test.mjs +++ b/test/posthog-analytics.test.mjs @@ -237,6 +237,14 @@ test('before_send strips private extras but preserves SDK ingestion fields', () $referrer: 'https://ref.example/?q=2', utm_source: 'newsletter', gclid: 'abc', + // SDK-derived session-entry/initial campaign variants — generated as + // $session_entry_${param}, including for query params we never listed. + $session_entry_utm_source: 'DO_NOT_SEND', + $session_entry_utm_campaign: 'SECRET', + $session_entry_gclid: 'click-id', + $initial_utm_medium: 'cpc', + $initial_fbclid: 'fb-click', + $session_entry_custom_param: 'unlisted variant', $browser: 'Chrome', stray_key: 'must be dropped', }; @@ -244,6 +252,12 @@ test('before_send strips private extras but preserves SDK ingestion fields', () for (const key of DENIED_EVENT_PROPERTIES) { assert.ok(!(key in clean), `${key} survived scrubbing`); } + assert.equal(clean.$session_entry_utm_source, undefined, 'session-entry UTM leaked'); + assert.equal(clean.$session_entry_utm_campaign, undefined, 'session-entry campaign leaked'); + assert.equal(clean.$session_entry_gclid, undefined, 'session-entry click id leaked'); + assert.equal(clean.$initial_utm_medium, undefined, 'initial UTM leaked'); + assert.equal(clean.$initial_fbclid, undefined, 'initial click id leaked'); + assert.equal(clean.$session_entry_custom_param, undefined, 'unlisted session-entry variant leaked'); assert.equal(clean.stray_key, undefined, 'non-schema custom key survived'); assert.equal(clean.trigger, 'manual'); assert.equal(clean.token, 'phc_public_project_token', 'SDK project token was stripped');