From 15c7894becd9ddd7a3748069f06035d0ef7cba51 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 6 Sep 2026 20:54:19 +0100 Subject: [PATCH] fix(web): redact invite replay requests --- apps/web/app/PostHogPageView.test.ts | 32 +++++++++++++++++++++++++++- apps/web/app/providers.tsx | 16 +++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/apps/web/app/PostHogPageView.test.ts b/apps/web/app/PostHogPageView.test.ts index 4c3cb84..94ec24d 100644 --- a/apps/web/app/PostHogPageView.test.ts +++ b/apps/web/app/PostHogPageView.test.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises' import { describe, expect, it } from 'vitest' -import { redactInvitePaths } from './providers' +import type { CapturedNetworkRequest } from 'posthog-js' +import { redactInviteNetworkRequest, redactInvitePaths } from './providers' const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url) const providerPath = new URL('./providers.tsx', import.meta.url) @@ -20,6 +21,7 @@ describe('PostHog invite privacy boundary', () => { expect(source).toContain('capture_pageleave: false') expect(source).toContain('get_current_url: redactInviteUrl') expect(source).toContain('before_send: redactInvitePathsBeforeSend') + expect(source).toContain('maskCapturedNetworkRequestFn: redactInviteNetworkRequest') expect(source).toContain("url.pathname.startsWith('/join/')") expect(source).toContain('/join/[invite]') }) @@ -43,4 +45,32 @@ describe('PostHog invite privacy boundary', () => { expect(serialized).toContain('/join/[invite]') expect(serialized).toContain('join_page_viewed') }) + + it('redacts invite codes from replay network request URLs, headers, and bodies without mutating the capture', () => { + const inviteCode = 'A4K9BT2Q' + const request = { + name: `https://sauci.app/join/${inviteCode}`, + entryType: 'resource', + startTime: 1, + duration: 2, + requestHeaders: { + referer: `https://sauci.app/join/${inviteCode}`, + }, + requestBody: JSON.stringify({ invite_path: `/join/${inviteCode}` }), + responseHeaders: { + location: `https://sauci.app/join/${inviteCode}`, + }, + responseBody: JSON.stringify({ referrer: `https://sauci.app/join/${inviteCode}` }), + } as CapturedNetworkRequest + + const redacted = redactInviteNetworkRequest(request) + const serialized = JSON.stringify(redacted) + + expect(redacted).not.toBe(request) + expect(redacted.requestHeaders).not.toBe(request.requestHeaders) + expect(redacted.responseHeaders).not.toBe(request.responseHeaders) + expect(serialized).not.toContain(inviteCode) + expect(serialized).toContain('/join/[invite]') + expect(JSON.stringify(request)).toContain(inviteCode) + }) }) diff --git a/apps/web/app/providers.tsx b/apps/web/app/providers.tsx index 1347779..8c43243 100644 --- a/apps/web/app/providers.tsx +++ b/apps/web/app/providers.tsx @@ -2,7 +2,7 @@ import posthog from 'posthog-js' import { PostHogProvider as PHProvider } from 'posthog-js/react' -import type { CaptureResult } from 'posthog-js' +import type { CapturedNetworkRequest, CaptureResult } from 'posthog-js' import { useEffect } from 'react' import { usePathname } from 'next/navigation' @@ -38,6 +38,17 @@ export function redactInvitePathsBeforeSend(event: CaptureResult | null): Captur return event ? redactInvitePaths(event) : null } +export function redactInviteNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest { + return { + ...request, + name: redactInvitePaths(request.name), + requestHeaders: request.requestHeaders ? redactInvitePaths({ ...request.requestHeaders }) : request.requestHeaders, + requestBody: redactInvitePaths(request.requestBody), + responseHeaders: request.responseHeaders ? redactInvitePaths({ ...request.responseHeaders }) : request.responseHeaders, + responseBody: redactInvitePaths(request.responseBody), + } +} + export function PostHogProvider({ children }: { children: React.ReactNode }) { const pathname = usePathname() @@ -51,6 +62,9 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { capture_pageleave: false, get_current_url: redactInviteUrl, before_send: redactInvitePathsBeforeSend, + session_recording: { + maskCapturedNetworkRequestFn: redactInviteNetworkRequest, + }, // This must be part of initialization, not only the navigation effect // below, so an initially loaded invite page is protected before its DOM // can be considered for capture or recording.