Sanitize PostHog consent URLs and replay metadata (#376)

This commit is contained in:
Ben Senescu 2026-07-09 22:23:23 -04:00 committed by GitHub
parent c11517908d
commit ea95eae368
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 107 additions and 14 deletions

View File

@ -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<string, unknown> = {
$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",
});
});
});

View File

@ -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<string, unknown>,
): Record<string, unknown> {
for (const [key, value] of Object.entries(properties)) {
if (typeof value !== "string") continue;
properties[key] = sanitizePostHogUrl(value);
}
return properties;
}

View File

@ -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<BrowserPostHogClient | null> {
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;