From 72780c9a72727a06c9c7027515c091c91d1029ae Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 16 Aug 2026 10:50:42 +0200 Subject: [PATCH 1/4] feat(analytics): first-touch attribution + consent_choice event (#4520) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capture first-touch attribution (referrer, landing path, utm params) once per session in sessionStorage — no cookies, no persistent id - attach the captured attribution to the signup payload when present - emit a consent_choice event on analytics consent accept (decline cannot emit while opted out — documented in tests) - boot-time plugin registered before the router so the true landing URL is captured Claude-Session: https://claude.ai/code/session_015AXhHayqcLntuU3AbX7No8 --- src/lib/helpers/attribution.js | 127 +++++++++++++ .../helpers/tests/attribution.unit.tests.js | 170 ++++++++++++++++++ src/lib/plugins/attribution.js | 21 +++ src/lib/plugins/index.js | 2 + .../plugins/tests/attribution.unit.tests.js | 23 +++ src/main.js | 1 + src/modules/auth/stores/auth.store.js | 8 +- .../auth/tests/auth.store.unit.tests.js | 42 +++++ .../legal/composables/useCookieConsent.js | 13 ++ .../tests/useCookieConsent.unit.tests.js | 7 + 10 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 src/lib/helpers/attribution.js create mode 100644 src/lib/helpers/tests/attribution.unit.tests.js create mode 100644 src/lib/plugins/attribution.js create mode 100644 src/lib/plugins/tests/attribution.unit.tests.js diff --git a/src/lib/helpers/attribution.js b/src/lib/helpers/attribution.js new file mode 100644 index 000000000..18bc3e452 --- /dev/null +++ b/src/lib/helpers/attribution.js @@ -0,0 +1,127 @@ +/** + * attribution.js + * ============== + * Write-once first-touch attribution capture (issue #4520). + * + * Captures referrer / landing path / UTM params from the very first page a + * visitor lands on, so signup() can send them along with the payload. Uses + * sessionStorage ONLY — never cookies, never localStorage, no persistent + * identifier. First-touch wins: once a record exists for the session, later + * navigations never overwrite it. + */ + +/** sessionStorage key used to persist the first-touch attribution record. */ +export const ATTRIBUTION_SS_KEY = 'attribution_v1'; + +/** Max length enforced on `referrer` / `landingPath`. */ +const URL_FIELD_MAX_LENGTH = 2048; +/** Max length enforced on each individual UTM field. */ +const UTM_FIELD_MAX_LENGTH = 256; + +/** Maps URL query param names to their camelCase wire field name. */ +const UTM_PARAM_MAP = { + utm_source: 'utmSource', + utm_medium: 'utmMedium', + utm_campaign: 'utmCampaign', + utm_term: 'utmTerm', + utm_content: 'utmContent', +}; + +/** + * @desc Returns true when running in a browser environment with sessionStorage available. + * @returns {boolean} + */ +function isBrowser() { + return typeof window !== 'undefined' && typeof sessionStorage !== 'undefined'; +} + +/** + * @desc Trim a value and cap it to maxLength. Non-string / empty-after-trim input yields undefined. + * @param {*} value - Raw value to normalise. + * @param {number} maxLength - Maximum length to keep. + * @returns {string|undefined} + */ +function trimAndCap(value, maxLength) { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, maxLength); +} + +/** + * @desc Determine whether a referrer URL shares the current page's origin. + * @param {string} referrer - `document.referrer` value. + * @returns {boolean} + */ +function isSameOrigin(referrer) { + try { + return new URL(referrer).origin === window.location.origin; + } catch { + return false; + } +} + +/** + * @desc Build the first-touch attribution record from the current document/location. + * @returns {object|null} The record, or null when there is nothing to capture. + */ +function buildAttribution() { + const record = {}; + + const referrer = document.referrer; + if (referrer && !isSameOrigin(referrer)) { + const capped = trimAndCap(referrer, URL_FIELD_MAX_LENGTH); + if (capped) record.referrer = capped; + } + + const landingPath = trimAndCap(`${window.location.pathname}${window.location.search}`, URL_FIELD_MAX_LENGTH); + if (landingPath) record.landingPath = landingPath; + + const params = new URLSearchParams(window.location.search); + Object.entries(UTM_PARAM_MAP).forEach(([queryKey, field]) => { + const capped = trimAndCap(params.get(queryKey), UTM_FIELD_MAX_LENGTH); + if (capped) record[field] = capped; + }); + + return Object.keys(record).length > 0 ? record : null; +} + +/** + * @desc Capture first-touch attribution (referrer, landing path, UTM params) into + * sessionStorage. Write-once: if a record already exists for this session, does + * nothing. Never uses cookies or localStorage, never stores a persistent identifier. + * Safe no-op when storage/window is unavailable (private mode, SSR, unit tests). + * @returns {void} + */ +export function captureFirstTouch() { + if (!isBrowser()) return; + try { + if (sessionStorage.getItem(ATTRIBUTION_SS_KEY) !== null) return; + const record = buildAttribution(); + if (record) sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify(record)); + } catch { + // sessionStorage unavailable (private mode / sandboxed) — silent no-op + } +} + +/** + * @desc Returns the stored first-touch attribution record. + * @returns {object|null} The record, or null when absent, unavailable, or malformed. + */ +export function getAttribution() { + if (!isBrowser()) return null; + try { + const raw = sessionStorage.getItem(ATTRIBUTION_SS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== 'object') return null; + return parsed; + } catch { + return null; + } +} + +/** + * Exports. + */ +export default { captureFirstTouch, getAttribution }; diff --git a/src/lib/helpers/tests/attribution.unit.tests.js b/src/lib/helpers/tests/attribution.unit.tests.js new file mode 100644 index 000000000..9c5014913 --- /dev/null +++ b/src/lib/helpers/tests/attribution.unit.tests.js @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { captureFirstTouch, getAttribution, ATTRIBUTION_SS_KEY } from '../attribution'; + +/** + * Swaps window.location for a plain object with the given overrides, mirroring + * the pattern used in billing.store.unit.tests.js. Returns a restore function. + * @param {object} overrides - Fields to override on window.location. + * @returns {() => void} Restores the original window.location. + */ +const mockLocation = (overrides) => { + const originalLocation = window.location; + delete window.location; + window.location = { + ...originalLocation, + origin: 'https://app.example.com', + pathname: '/', + search: '', + ...overrides, + }; + return () => { window.location = originalLocation; }; +}; + +/** + * Sets document.referrer for a single test. + * @param {string} value - Referrer URL to set. + * @returns {void} + */ +const setReferrer = (value) => { + Object.defineProperty(document, 'referrer', { value, configurable: true }); +}; + +describe('attribution helper', () => { + beforeEach(() => { + sessionStorage.clear(); + setReferrer(''); + mockLocation({}); + }); + + describe('captureFirstTouch', () => { + it('captures landingPath from pathname + search', () => { + mockLocation({ pathname: '/pricing', search: '?plan=pro' }); + captureFirstTouch(); + expect(getAttribution()).toEqual({ landingPath: '/pricing?plan=pro' }); + }); + + it('captures referrer when cross-origin', () => { + mockLocation({ origin: 'https://app.example.com', pathname: '/', search: '' }); + setReferrer('https://google.com/search?q=test'); + captureFirstTouch(); + expect(getAttribution()).toMatchObject({ referrer: 'https://google.com/search?q=test' }); + }); + + it('omits referrer when same-origin', () => { + mockLocation({ origin: 'https://app.example.com', pathname: '/dashboard', search: '' }); + setReferrer('https://app.example.com/somewhere-else'); + captureFirstTouch(); + expect(getAttribution()).not.toHaveProperty('referrer'); + }); + + it('omits referrer when empty', () => { + setReferrer(''); + captureFirstTouch(); + expect(getAttribution()).not.toHaveProperty('referrer'); + }); + + it('does not throw on a malformed referrer, and captures it as-is (not same-origin)', () => { + setReferrer('not-a-valid-url'); + expect(() => captureFirstTouch()).not.toThrow(); + expect(getAttribution()).toMatchObject({ referrer: 'not-a-valid-url' }); + }); + + it('parses utm_* query params into camelCase fields', () => { + mockLocation({ + pathname: '/landing', + search: '?utm_source=newsletter&utm_medium=email&utm_campaign=launch&utm_term=vue&utm_content=banner', + }); + captureFirstTouch(); + expect(getAttribution()).toMatchObject({ + utmSource: 'newsletter', + utmMedium: 'email', + utmCampaign: 'launch', + utmTerm: 'vue', + utmContent: 'banner', + }); + }); + + it('omits utm fields absent from the query string', () => { + mockLocation({ pathname: '/landing', search: '?utm_source=newsletter' }); + captureFirstTouch(); + const record = getAttribution(); + expect(record.utmSource).toBe('newsletter'); + expect(record).not.toHaveProperty('utmMedium'); + expect(record).not.toHaveProperty('utmCampaign'); + }); + + it('trims and caps referrer / landingPath at 2048 chars', () => { + const longSuffix = 'a'.repeat(3000); + mockLocation({ pathname: `/${longSuffix}`, search: '' }); + setReferrer(`https://google.com/${longSuffix}`); + captureFirstTouch(); + const record = getAttribution(); + expect(record.landingPath.length).toBe(2048); + expect(record.referrer.length).toBe(2048); + }); + + it('trims and caps utm fields at 256 chars', () => { + const longUtm = 'b'.repeat(500); + mockLocation({ pathname: '/landing', search: `?utm_source=${longUtm}` }); + captureFirstTouch(); + expect(getAttribution().utmSource.length).toBe(256); + }); + + it('is write-once: a second call does not overwrite the first record', () => { + mockLocation({ pathname: '/first-page', search: '' }); + captureFirstTouch(); + mockLocation({ pathname: '/second-page', search: '' }); + captureFirstTouch(); + expect(getAttribution()).toEqual({ landingPath: '/first-page' }); + }); + + it('is a silent no-op when sessionStorage.getItem throws (private mode)', () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(() => captureFirstTouch()).not.toThrow(); + getItemSpy.mockRestore(); + }); + + it('is a silent no-op when sessionStorage.setItem throws (private mode / quota)', () => { + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + mockLocation({ pathname: '/pricing', search: '' }); + expect(() => captureFirstTouch()).not.toThrow(); + setItemSpy.mockRestore(); + }); + + it('is a no-op when window is unavailable (SSR)', () => { + const originalWindow = globalThis.window; + globalThis.window = undefined; + expect(() => captureFirstTouch()).not.toThrow(); + globalThis.window = originalWindow; + }); + }); + + describe('getAttribution', () => { + it('returns null when nothing was captured', () => { + expect(getAttribution()).toBe(null); + }); + + it('returns the stored record', () => { + sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify({ landingPath: '/x' })); + expect(getAttribution()).toEqual({ landingPath: '/x' }); + }); + + it('returns null on malformed JSON (no throw)', () => { + sessionStorage.setItem(ATTRIBUTION_SS_KEY, '{not-json'); + expect(() => getAttribution()).not.toThrow(); + expect(getAttribution()).toBe(null); + }); + + it('returns null when sessionStorage.getItem throws (private mode)', () => { + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(getAttribution()).toBe(null); + getItemSpy.mockRestore(); + }); + }); +}); diff --git a/src/lib/plugins/attribution.js b/src/lib/plugins/attribution.js new file mode 100644 index 000000000..a69a0d574 --- /dev/null +++ b/src/lib/plugins/attribution.js @@ -0,0 +1,21 @@ +/** + * Module dependencies. + */ +import { captureFirstTouch } from '../helpers/attribution'; + +/** + * Plugin setup. + */ +export default { + /** + * Captures write-once first-touch attribution (referrer, landing path, UTM + * params) as early as possible in the boot sequence — registered before the + * router plugin so the true landing URL is captured before any router + * redirect can run. Safe no-op when storage/window is unavailable (SSR, + * private mode, unit tests). + * @returns {void} + */ + install() { + captureFirstTouch(); + }, +}; diff --git a/src/lib/plugins/index.js b/src/lib/plugins/index.js index 776c8a5cb..0331b123e 100644 --- a/src/lib/plugins/index.js +++ b/src/lib/plugins/index.js @@ -3,6 +3,7 @@ */ import vuetify from './vuetify'; import posthog from './posthog'; +import attribution from './attribution'; import dayjs from './dayjs'; import images from './images'; import aos from './aos'; @@ -13,6 +14,7 @@ import lodash from './lodash'; * Exports. */ export default { + attribution, vuetify, posthog, dayjs, diff --git a/src/lib/plugins/tests/attribution.unit.tests.js b/src/lib/plugins/tests/attribution.unit.tests.js new file mode 100644 index 000000000..c939ae09b --- /dev/null +++ b/src/lib/plugins/tests/attribution.unit.tests.js @@ -0,0 +1,23 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockCaptureFirstTouch = vi.fn(); +vi.mock('../../helpers/attribution', () => ({ + captureFirstTouch: (...args) => mockCaptureFirstTouch(...args), +})); + +import attributionPlugin from '../attribution'; + +describe('attribution plugin', () => { + beforeEach(() => { + mockCaptureFirstTouch.mockClear(); + }); + + it('has an install method', () => { + expect(typeof attributionPlugin.install).toBe('function'); + }); + + it('calls captureFirstTouch on install', () => { + attributionPlugin.install(); + expect(mockCaptureFirstTouch).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/main.js b/src/main.js index 58a57fdba..cab581a51 100644 --- a/src/main.js +++ b/src/main.js @@ -23,6 +23,7 @@ app.config.globalProperties.config = config; app.config.globalProperties.routes = routes; app + .use(plugins.attribution) .use(head) .use(pinia) .use(appRouter) diff --git a/src/modules/auth/stores/auth.store.js b/src/modules/auth/stores/auth.store.js index c8bc75824..05ce885c2 100644 --- a/src/modules/auth/stores/auth.store.js +++ b/src/modules/auth/stores/auth.store.js @@ -8,6 +8,7 @@ import { useCoreStore } from '../../core/stores/core.store'; import { useBillingStore } from '../../billing/stores/billing.store'; import { updateAbilities } from '../../../lib/helpers/ability'; import { capture, identify, reset as analyticsReset } from '../../../lib/helpers/analytics'; +import { getAttribution } from '../../../lib/helpers/attribution'; /** * @desc Deduce firstName and lastName from an email address. @@ -248,7 +249,8 @@ export const useAuthStore = defineStore('auth', { }, /** - * @desc Sign up a new user and update auth state. + * @desc Sign up a new user and update auth state. When a first-touch attribution + * record was captured for this session, it is attached to the payload (#4520). * @param {Object} params - Signup payload (email, password, firstName, lastName) * @returns {Promise} Signup response data containing user, and optionally organization or organizationSetupRequired */ @@ -265,6 +267,10 @@ export const useAuthStore = defineStore('auth', { if (deduced.lastName) { payload.lastName = deduced.lastName; } } + // Include first-touch attribution when captured for this session — omit entirely when absent (#4520). + const attribution = getAttribution(); + if (attribution) { payload.attribution = attribution; } + const signupUrl = `${api}/${config.api.endPoints.auth}/signup${inviteToken ? `?inviteToken=${encodeURIComponent(inviteToken)}` : ''}`; try { diff --git a/src/modules/auth/tests/auth.store.unit.tests.js b/src/modules/auth/tests/auth.store.unit.tests.js index ecf039672..8c36192ee 100644 --- a/src/modules/auth/tests/auth.store.unit.tests.js +++ b/src/modules/auth/tests/auth.store.unit.tests.js @@ -37,6 +37,12 @@ vi.mock('../../../lib/helpers/analytics', () => ({ reset: (...args) => mockReset(...args), })); +// Mock attribution helper +const mockGetAttribution = vi.fn(() => null); +vi.mock('../../../lib/helpers/attribution', () => ({ + getAttribution: (...args) => mockGetAttribution(...args), +})); + describe('Auth Store', () => { beforeEach(() => { setActivePinia(createPinia()); @@ -49,6 +55,7 @@ describe('Auth Store', () => { mockCapture.mockClear(); mockIdentify.mockClear(); mockReset.mockClear(); + mockGetAttribution.mockReset().mockReturnValue(null); }); it('should initialize with default state', () => { @@ -470,6 +477,41 @@ describe('Auth Store', () => { }); describe('signup', () => { + it('includes attribution in the POST payload when captured for this session (#4520)', async () => { + const authStore = useAuthStore(); + const attribution = { landingPath: '/pricing', utmSource: 'newsletter' }; + mockGetAttribution.mockReturnValue(attribution); + const mockResponse = { + data: { + user: { id: '456', email: 'new@test.com', roles: ['user'] }, + tokenExpiresIn: Date.now() + 3600000, + }, + }; + + axios.post.mockResolvedValueOnce(mockResponse); + await authStore.signup({ email: 'new@test.com', password: 'password123' }); + + const body = axios.post.mock.calls[0][1]; + expect(body.attribution).toEqual(attribution); + }); + + it('omits attribution from the POST payload when none was captured (#4520)', async () => { + const authStore = useAuthStore(); + mockGetAttribution.mockReturnValue(null); + const mockResponse = { + data: { + user: { id: '456', email: 'new@test.com', roles: ['user'] }, + tokenExpiresIn: Date.now() + 3600000, + }, + }; + + axios.post.mockResolvedValueOnce(mockResponse); + await authStore.signup({ email: 'new@test.com', password: 'password123' }); + + const body = axios.post.mock.calls[0][1]; + expect(body).not.toHaveProperty('attribution'); + }); + it('should signup successfully and update store', async () => { const authStore = useAuthStore(); const mockResponse = { diff --git a/src/modules/legal/composables/useCookieConsent.js b/src/modules/legal/composables/useCookieConsent.js index e2253929f..4b7343054 100644 --- a/src/modules/legal/composables/useCookieConsent.js +++ b/src/modules/legal/composables/useCookieConsent.js @@ -113,12 +113,25 @@ export function useCookieConsent() { ph.set_config({ persistence: 'localStorage+cookie' }); ph.opt_in_capturing(); ph.capture('consent_given', { analytics: true }); + // Anonymous consent-decision event (#4520). Fired AFTER opt_in_capturing() + // so it goes through the same gate as consent_given above — it therefore + // carries the standard opted-in capture context, not a memory-only / + // fully anonymous one (see the reject() comment below for why the + // decline branch cannot mirror this). + ph.capture('consent_choice', { accepted: true }); } }; /** * Reject optional analytics cookies. * Persists the rejection to localStorage, updates singleton refs, and opts PostHog out. + * + * `consent_choice` is intentionally NOT emitted here (#4520 open question): + * PostHog is initialized with `opt_out_capturing_by_default: true`, so + * `posthog.capture()` is a no-op until `opt_in_capturing()` runs — and + * opting in first (even briefly, just to fire one event) persists a + * cookie/localStorage consent flag, which both weakens consent gating and + * violates the "cookieless" requirement for this event. * @returns {void} */ const reject = () => { diff --git a/src/modules/legal/tests/useCookieConsent.unit.tests.js b/src/modules/legal/tests/useCookieConsent.unit.tests.js index 54c7b1e05..adec3dbb4 100644 --- a/src/modules/legal/tests/useCookieConsent.unit.tests.js +++ b/src/modules/legal/tests/useCookieConsent.unit.tests.js @@ -105,6 +105,7 @@ describe('useCookieConsent — actions', () => { expect(posthog.set_config).toHaveBeenCalledWith({ persistence: 'localStorage+cookie' }); expect(posthog.opt_in_capturing).toHaveBeenCalledOnce(); expect(posthog.capture).toHaveBeenCalledWith('consent_given', { analytics: true }); + expect(posthog.capture).toHaveBeenCalledWith('consent_choice', { accepted: true }); }); it('reject: writes LS, sets consent, calls posthog opt_out + reset, does NOT call set_config', () => { @@ -120,6 +121,12 @@ describe('useCookieConsent — actions', () => { expect(posthog.set_config).not.toHaveBeenCalled(); }); + it('reject: does NOT emit consent_choice (blocked by opt_out_capturing_by_default, #4520 open question)', () => { + const { api, posthog } = mountComposable(); + api.reject(); + expect(posthog.capture).not.toHaveBeenCalled(); + }); + it('reopenSettings: flips consentNeeded to true without touching posthog or LS', () => { const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); localStorage.setItem( From 23406e6c486951645fa229df5551f55bd47514df Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 16 Aug 2026 11:30:00 +0200 Subject: [PATCH 2/4] fix(analytics): sanitize stored attribution at read time + restore location mock in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getAttribution() now whitelists the 7 wire keys, drops non-strings, re-applies trim + length caps — a tampered sessionStorage record can no longer 422-block signup at the strict backend schema - attribution tests restore the real jsdom Location in afterEach (was leaking a plain-object mock to subsequent test files) Claude-Session: https://claude.ai/code/session_015AXhHayqcLntuU3AbX7No8 --- src/lib/helpers/attribution.js | 30 +++++++++++++- .../helpers/tests/attribution.unit.tests.js | 41 ++++++++++++++++--- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/src/lib/helpers/attribution.js b/src/lib/helpers/attribution.js index 18bc3e452..06f5bc2f1 100644 --- a/src/lib/helpers/attribution.js +++ b/src/lib/helpers/attribution.js @@ -27,6 +27,17 @@ const UTM_PARAM_MAP = { utm_content: 'utmContent', }; +/** Whitelist of the 7 known wire keys, mapped to their max length. Any other key is dropped. */ +const KNOWN_FIELDS = { + referrer: URL_FIELD_MAX_LENGTH, + landingPath: URL_FIELD_MAX_LENGTH, + utmSource: UTM_FIELD_MAX_LENGTH, + utmMedium: UTM_FIELD_MAX_LENGTH, + utmCampaign: UTM_FIELD_MAX_LENGTH, + utmTerm: UTM_FIELD_MAX_LENGTH, + utmContent: UTM_FIELD_MAX_LENGTH, +}; + /** * @desc Returns true when running in a browser environment with sessionStorage available. * @returns {boolean} @@ -86,6 +97,23 @@ function buildAttribution() { return Object.keys(record).length > 0 ? record : null; } +/** + * @desc Sanitize a raw parsed attribution record read back from sessionStorage: whitelist + * to the 7 known wire keys, drop any other key, drop non-string values, re-apply trim + + * length caps. Guards against a tampered/extension-injected key reaching the strict Zod + * signup endpoint (an unexpected key would 422 the whole payload). + * @param {object} parsed - Raw parsed JSON object. + * @returns {object|null} Sanitized record, or null when nothing valid remains. + */ +function sanitizeAttribution(parsed) { + const record = {}; + Object.entries(KNOWN_FIELDS).forEach(([key, maxLength]) => { + const capped = trimAndCap(parsed[key], maxLength); + if (capped) record[key] = capped; + }); + return Object.keys(record).length > 0 ? record : null; +} + /** * @desc Capture first-touch attribution (referrer, landing path, UTM params) into * sessionStorage. Write-once: if a record already exists for this session, does @@ -115,7 +143,7 @@ export function getAttribution() { if (!raw) return null; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') return null; - return parsed; + return sanitizeAttribution(parsed); } catch { return null; } diff --git a/src/lib/helpers/tests/attribution.unit.tests.js b/src/lib/helpers/tests/attribution.unit.tests.js index 9c5014913..b6bea1027 100644 --- a/src/lib/helpers/tests/attribution.unit.tests.js +++ b/src/lib/helpers/tests/attribution.unit.tests.js @@ -1,23 +1,30 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { captureFirstTouch, getAttribution, ATTRIBUTION_SS_KEY } from '../attribution'; +/** + * Real jsdom Location, captured once at module load, before any mock is installed. + * afterEach always restores to this — never to a previous mock — so the plain-object + * mock never leaks into subsequent test files (mockLocation can be called more than + * once per test, e.g. once in beforeEach and again in the test body). + */ +const realLocation = window.location; + /** * Swaps window.location for a plain object with the given overrides, mirroring - * the pattern used in billing.store.unit.tests.js. Returns a restore function. + * the pattern used in billing.store.unit.tests.js. * @param {object} overrides - Fields to override on window.location. - * @returns {() => void} Restores the original window.location. + * @returns {() => void} Restores the real window.location. */ const mockLocation = (overrides) => { - const originalLocation = window.location; delete window.location; window.location = { - ...originalLocation, + ...realLocation, origin: 'https://app.example.com', pathname: '/', search: '', ...overrides, }; - return () => { window.location = originalLocation; }; + return () => { window.location = realLocation; }; }; /** @@ -36,6 +43,10 @@ describe('attribution helper', () => { mockLocation({}); }); + afterEach(() => { + window.location = realLocation; + }); + describe('captureFirstTouch', () => { it('captures landingPath from pathname + search', () => { mockLocation({ pathname: '/pricing', search: '?plan=pro' }); @@ -166,5 +177,23 @@ describe('attribution helper', () => { expect(getAttribution()).toBe(null); getItemSpy.mockRestore(); }); + + it('drops unknown keys and non-string values, keeping valid whitelisted fields (tampered record)', () => { + sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify({ + landingPath: '/pricing', + utmSource: 'newsletter', + utmMedium: 12345, + injectedByExtension: 'malicious-value', + })); + expect(getAttribution()).toEqual({ landingPath: '/pricing', utmSource: 'newsletter' }); + }); + + it('returns null when the record has no valid whitelisted string fields (fully-bogus record)', () => { + sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify({ + injectedByExtension: 'malicious-value', + somethingElse: 42, + })); + expect(getAttribution()).toBe(null); + }); }); }); From e794f9fa9057e9ce65b45cb9436fd33ad0905a6f Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Sun, 16 Aug 2026 11:37:50 +0200 Subject: [PATCH 3/4] fix(analytics): strip credential-carrying query params from landingPath A landing URL like /signup?inviteToken=... was duplicating the token into stored attribution (flagged independently by two reviewers). Params whose key matches token/secret/password/code/key are removed before capture; utm params are unaffected. Claude-Session: https://claude.ai/code/session_015AXhHayqcLntuU3AbX7No8 --- src/lib/helpers/attribution.js | 20 ++++++++++++++++++- .../helpers/tests/attribution.unit.tests.js | 9 +++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/lib/helpers/attribution.js b/src/lib/helpers/attribution.js index 06f5bc2f1..1bf452531 100644 --- a/src/lib/helpers/attribution.js +++ b/src/lib/helpers/attribution.js @@ -72,6 +72,24 @@ function isSameOrigin(referrer) { } } +/** Query keys whose values are credentials/single-use tokens — never persisted in landingPath. */ +const SENSITIVE_QUERY_KEY_PATTERN = /token|secret|password|code|key/i; + +/** + * @desc Rebuild a search string with credential-carrying params removed, so a landing + * URL like `/signup?inviteToken=...` never duplicates the token into stored attribution. + * @param {string} search - `window.location.search` value. + * @returns {string} Sanitized search string ('' or '?...'). + */ +function stripSensitiveParams(search) { + const params = new URLSearchParams(search); + [...params.keys()].forEach((key) => { + if (SENSITIVE_QUERY_KEY_PATTERN.test(key)) params.delete(key); + }); + const rebuilt = params.toString(); + return rebuilt ? `?${rebuilt}` : ''; +} + /** * @desc Build the first-touch attribution record from the current document/location. * @returns {object|null} The record, or null when there is nothing to capture. @@ -85,7 +103,7 @@ function buildAttribution() { if (capped) record.referrer = capped; } - const landingPath = trimAndCap(`${window.location.pathname}${window.location.search}`, URL_FIELD_MAX_LENGTH); + const landingPath = trimAndCap(`${window.location.pathname}${stripSensitiveParams(window.location.search)}`, URL_FIELD_MAX_LENGTH); if (landingPath) record.landingPath = landingPath; const params = new URLSearchParams(window.location.search); diff --git a/src/lib/helpers/tests/attribution.unit.tests.js b/src/lib/helpers/tests/attribution.unit.tests.js index b6bea1027..7ff874286 100644 --- a/src/lib/helpers/tests/attribution.unit.tests.js +++ b/src/lib/helpers/tests/attribution.unit.tests.js @@ -54,6 +54,15 @@ describe('attribution helper', () => { expect(getAttribution()).toEqual({ landingPath: '/pricing?plan=pro' }); }); + it('strips credential-carrying query params from landingPath, keeps the rest', () => { + mockLocation({ pathname: '/signup', search: '?inviteToken=secret123&utm_source=news&promoCode=X1' }); + captureFirstTouch(); + const record = getAttribution(); + expect(record.landingPath).toBe('/signup?utm_source=news'); + expect(record.landingPath).not.toContain('secret123'); + expect(record.utmSource).toBe('news'); + }); + it('captures referrer when cross-origin', () => { mockLocation({ origin: 'https://app.example.com', pathname: '/', search: '' }); setReferrer('https://google.com/search?q=test'); From 1a82c13e527f9986c3a83d5412fc6d3f4be12c99 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Wed, 19 Aug 2026 18:22:06 +0200 Subject: [PATCH 4/4] fix(attribution): harden sessionStorage guard and sanitize referrer query params isBrowser() accessed sessionStorage outside its callers' try/catch, so a throwing global getter (sandboxed iframe / storage partitioning policy) would surface as an uncaught exception instead of a silent no-op. Wrap the access in isBrowser() itself. The cross-origin referrer was stored as-is: a partner URL like `https://x.example/reset?token=...` would persist its raw query string in sessionStorage and later ride along in the signup payload, even though landingPath already strips the same sensitive params. Apply the existing stripSensitiveParams filter to the parsed referrer too, and drop referrers that fail to parse instead of storing them unchanged. Claude-Session: https://claude.ai/code/session_015AXhHayqcLntuU3AbX7No8 --- src/lib/helpers/attribution.js | 28 ++++++++++++- .../helpers/tests/attribution.unit.tests.js | 42 ++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/lib/helpers/attribution.js b/src/lib/helpers/attribution.js index 1bf452531..06434ecf1 100644 --- a/src/lib/helpers/attribution.js +++ b/src/lib/helpers/attribution.js @@ -40,10 +40,17 @@ const KNOWN_FIELDS = { /** * @desc Returns true when running in a browser environment with sessionStorage available. + * Guards against a throwing `sessionStorage` getter (e.g. sandboxed iframes / storage + * partitioning policies) so callers never see an uncaught exception before their own try/catch. * @returns {boolean} */ function isBrowser() { - return typeof window !== 'undefined' && typeof sessionStorage !== 'undefined'; + if (typeof window === 'undefined') return false; + try { + return typeof sessionStorage !== 'undefined'; + } catch { + return false; + } } /** @@ -90,6 +97,23 @@ function stripSensitiveParams(search) { return rebuilt ? `?${rebuilt}` : ''; } +/** + * @desc Sanitize a cross-origin referrer for storage: parse it, strip credential-carrying + * query params (same rule as landingPath), and cap the result. A cross-origin referrer's + * own query string can carry `token`/`code`/reset values just like the landing URL does, so + * it needs the same filtering. Malformed referrers are dropped rather than stored raw. + * @param {string} referrer - `document.referrer` value (already confirmed cross-origin). + * @returns {string|undefined} + */ +function sanitizeReferrer(referrer) { + try { + const url = new URL(referrer); + return trimAndCap(`${url.origin}${url.pathname}${stripSensitiveParams(url.search)}`, URL_FIELD_MAX_LENGTH); + } catch { + return undefined; + } +} + /** * @desc Build the first-touch attribution record from the current document/location. * @returns {object|null} The record, or null when there is nothing to capture. @@ -99,7 +123,7 @@ function buildAttribution() { const referrer = document.referrer; if (referrer && !isSameOrigin(referrer)) { - const capped = trimAndCap(referrer, URL_FIELD_MAX_LENGTH); + const capped = sanitizeReferrer(referrer); if (capped) record.referrer = capped; } diff --git a/src/lib/helpers/tests/attribution.unit.tests.js b/src/lib/helpers/tests/attribution.unit.tests.js index 7ff874286..bbd5c2e22 100644 --- a/src/lib/helpers/tests/attribution.unit.tests.js +++ b/src/lib/helpers/tests/attribution.unit.tests.js @@ -83,10 +83,19 @@ describe('attribution helper', () => { expect(getAttribution()).not.toHaveProperty('referrer'); }); - it('does not throw on a malformed referrer, and captures it as-is (not same-origin)', () => { + it('does not throw on a malformed referrer, and drops it rather than storing unparseable input', () => { setReferrer('not-a-valid-url'); expect(() => captureFirstTouch()).not.toThrow(); - expect(getAttribution()).toMatchObject({ referrer: 'not-a-valid-url' }); + expect(getAttribution()).not.toHaveProperty('referrer'); + }); + + it('strips credential-carrying query params from a cross-origin referrer, keeps the rest', () => { + mockLocation({ origin: 'https://app.example.com', pathname: '/', search: '' }); + setReferrer('https://partner.example.com/reset?token=secret123&utm_source=partner'); + captureFirstTouch(); + const record = getAttribution(); + expect(record.referrer).toBe('https://partner.example.com/reset?utm_source=partner'); + expect(record.referrer).not.toContain('secret123'); }); it('parses utm_* query params into camelCase fields', () => { @@ -161,6 +170,20 @@ describe('attribution helper', () => { expect(() => captureFirstTouch()).not.toThrow(); globalThis.window = originalWindow; }); + + it('is a silent no-op when the sessionStorage getter itself throws (e.g. sandboxed iframe / storage partitioning)', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + get() { throw new Error('SecurityError'); }, + }); + try { + expect(() => captureFirstTouch()).not.toThrow(); + } finally { + if (original) Object.defineProperty(globalThis, 'sessionStorage', original); + else delete globalThis.sessionStorage; + } + }); }); describe('getAttribution', () => { @@ -187,6 +210,21 @@ describe('attribution helper', () => { getItemSpy.mockRestore(); }); + it('returns null when the sessionStorage getter itself throws (e.g. sandboxed iframe / storage partitioning)', () => { + const original = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage'); + Object.defineProperty(globalThis, 'sessionStorage', { + configurable: true, + get() { throw new Error('SecurityError'); }, + }); + try { + expect(() => getAttribution()).not.toThrow(); + expect(getAttribution()).toBe(null); + } finally { + if (original) Object.defineProperty(globalThis, 'sessionStorage', original); + else delete globalThis.sessionStorage; + } + }); + it('drops unknown keys and non-string values, keeping valid whitelisted fields (tampered record)', () => { sessionStorage.setItem(ATTRIBUTION_SS_KEY, JSON.stringify({ landingPath: '/pricing',