diff --git a/src/client/lib/posthog-sanitize.test.ts b/src/client/lib/posthog-sanitize.test.ts new file mode 100644 index 0000000..a5c46ea --- /dev/null +++ b/src/client/lib/posthog-sanitize.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + sanitizePostHogProperties, + sanitizePostHogUrl, +} from "@/client/lib/posthog-sanitize"; + +const consentUrl = + "https://app.example.test/oauth-consent?response_type=code&client_id=test-client&redirect_uri=https%3A%2F%2Fclient.example%2Fcallback&scope=openid&state=state-secret&code_challenge=pkce-challenge&code_challenge_method=S256&resource=https%3A%2F%2Fapp.example.test%2Fmcp&future_parameter=future-secret"; + +describe("sanitizePostHogUrl", () => { + it("removes the complete query from OAuth consent URLs", () => { + expect(sanitizePostHogUrl(consentUrl)).toBe( + "https://app.example.test/oauth-consent", + ); + }); + + it("preserves ordinary query data while removing email", () => { + expect( + sanitizePostHogUrl( + "https://app.example.test/onboarding?step=2&email=user%40example.test", + ), + ).toBe("https://app.example.test/onboarding?step=2"); + }); + + it("leaves malformed URLs unchanged", () => { + expect(sanitizePostHogUrl("not a URL")).toBe("not a URL"); + }); +}); + +describe("sanitizePostHogProperties", () => { + it("sanitizes consent URLs on custom events and session properties", () => { + const properties: Record = { + $current_url: consentUrl, + $session_entry_url: consentUrl, + $referrer: consentUrl, + event_detail: "kept", + }; + + expect(sanitizePostHogProperties(properties)).toEqual({ + $current_url: "https://app.example.test/oauth-consent", + $session_entry_url: "https://app.example.test/oauth-consent", + $referrer: "https://app.example.test/oauth-consent", + event_detail: "kept", + }); + }); +}); diff --git a/src/client/lib/posthog-sanitize.ts b/src/client/lib/posthog-sanitize.ts new file mode 100644 index 0000000..572756f --- /dev/null +++ b/src/client/lib/posthog-sanitize.ts @@ -0,0 +1,44 @@ +const OAUTH_CONSENT_PATH = "/oauth-consent"; + +export const POSTHOG_PERSONAL_DATA_QUERY_PARAMETERS = [ + "email", + "response_type", + "client_id", + "redirect_uri", + "scope", + "state", + "code_challenge", + "code_challenge_method", + "resource", +] as const; + +export function sanitizePostHogUrl(value: string): string { + try { + const url = new URL(value); + + // The consent route carries the complete OAuth authorization request. Keep + // only the route identity in analytics so current URLs, session-entry URLs, + // referrers, and replay metadata cannot expose present or future params. + if (url.pathname === OAUTH_CONSENT_PATH) { + url.search = ""; + return url.toString(); + } + + // Preserve the existing email redaction for URLs outside the consent flow. + url.searchParams.delete("email"); + return url.toString(); + } catch { + return value; + } +} + +export function sanitizePostHogProperties( + properties: Record, +): Record { + for (const [key, value] of Object.entries(properties)) { + if (typeof value !== "string") continue; + properties[key] = sanitizePostHogUrl(value); + } + + return properties; +} diff --git a/src/client/lib/posthog.ts b/src/client/lib/posthog.ts index 077758d..2acff78 100644 --- a/src/client/lib/posthog.ts +++ b/src/client/lib/posthog.ts @@ -1,4 +1,9 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode"; +import { + POSTHOG_PERSONAL_DATA_QUERY_PARAMETERS, + sanitizePostHogProperties, + sanitizePostHogUrl, +} from "@/client/lib/posthog-sanitize"; // Type-only import: extracts the type at compile time without bundling posthog-js // oxlint-disable-next-line typescript/consistent-type-imports -- import() type avoids eagerly bundling posthog-js @@ -75,25 +80,23 @@ function getBrowserPostHogClient(): Promise { return event; }, capture_pageview: "history_change", + mask_personal_data_properties: true, + custom_personal_data_properties: [ + ...POSTHOG_PERSONAL_DATA_QUERY_PARAMETERS, + ], respect_dnt: true, session_recording: { maskAllInputs: true, maskTextSelector: "[data-ph-mask], .ph-mask", + maskCapturedNetworkRequestFn(request) { + return { + ...request, + name: sanitizePostHogUrl(request.name), + }; + }, }, - sanitize_properties(properties, event) { - if (event === "$pageview" || event === "$pageleave") { - const url: unknown = properties["$current_url"]; - if (typeof url === "string") { - try { - const parsed = new URL(url); - parsed.searchParams.delete("email"); - properties["$current_url"] = parsed.toString(); - } catch { - // leave as-is if URL parsing fails - } - } - } - return properties; + sanitize_properties(properties) { + return sanitizePostHogProperties(properties); }, }); browserPostHogInitialized = true;