From dd1a47ecfa445acd46c7ab01f4a427a4ae578862 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 6 Sep 2026 12:49:32 +0100 Subject: [PATCH] fix(web): scrub invite tokens before analytics --- apps/web/app/PostHogPageView.test.ts | 22 ++++++++++++++++++++++ apps/web/app/providers.tsx | 28 ++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/apps/web/app/PostHogPageView.test.ts b/apps/web/app/PostHogPageView.test.ts index 4e866ac..4c3cb84 100644 --- a/apps/web/app/PostHogPageView.test.ts +++ b/apps/web/app/PostHogPageView.test.ts @@ -1,5 +1,6 @@ import { readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' +import { redactInvitePaths } from './providers' const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url) const providerPath = new URL('./providers.tsx', import.meta.url) @@ -18,7 +19,28 @@ describe('PostHog invite privacy boundary', () => { expect(source).toContain('capture_pageview: false') expect(source).toContain('capture_pageleave: false') expect(source).toContain('get_current_url: redactInviteUrl') + expect(source).toContain('before_send: redactInvitePathsBeforeSend') expect(source).toContain("url.pathname.startsWith('/join/')") expect(source).toContain('/join/[invite]') }) + + it('removes an invite code from every serialized URL or path property', () => { + const inviteCode = 'A4K9BT2Q' + const event = { + event: 'join_page_viewed', + properties: { + $current_url: `https://sauci.app/join/${inviteCode}`, + $pathname: `/join/${inviteCode}`, + $referrer: `https://sauci.app/join/${inviteCode}?source=message`, + $session_entry_url: `https://sauci.app/join/${inviteCode}`, + nested: { previous_path: `/join/${inviteCode}` }, + }, + } + + const serialized = JSON.stringify(redactInvitePaths(event)) + + expect(serialized).not.toContain(inviteCode) + expect(serialized).toContain('/join/[invite]') + expect(serialized).toContain('join_page_viewed') + }) }) diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 52529a2..c601846 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -1,9 +1,12 @@ 'use client' -import posthog from 'posthog-js' -import { PostHogProvider as PHProvider } from 'posthog-js/react' +import posthog from 'posthog-js' +import { PostHogProvider as PHProvider } from 'posthog-js/react' +import type { CaptureResult } from 'posthog-js' import { useEffect } from 'react' +const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g + function redactInviteUrl(defaultUrl: string): string { const url = new URL(defaultUrl) @@ -14,6 +17,26 @@ function redactInviteUrl(defaultUrl: string): string { return defaultUrl } +export function redactInvitePaths(value: T): T { + if (typeof value === 'string') { + return value.replace(INVITE_PATH_PATTERN, '/join/[invite]') as T + } + + if (Array.isArray(value)) { + return value.map(redactInvitePaths) as T + } + + if (value && typeof value === 'object' && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)) { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactInvitePaths(entry)])) as T + } + + return value +} + +export function redactInvitePathsBeforeSend(event: CaptureResult | null): CaptureResult | null { + return event ? redactInvitePaths(event) : null +} + export function PostHogProvider({ children }: { children: React.ReactNode }) { useEffect(() => { if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) { @@ -23,6 +46,7 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { capture_pageview: false, capture_pageleave: false, get_current_url: redactInviteUrl, + before_send: redactInvitePathsBeforeSend, }) } }, [])