Sanitize PostHog consent URLs and replay metadata (#376)
This commit is contained in:
parent
c11517908d
commit
ea95eae368
46
src/client/lib/posthog-sanitize.test.ts
Normal file
46
src/client/lib/posthog-sanitize.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
44
src/client/lib/posthog-sanitize.ts
Normal file
44
src/client/lib/posthog-sanitize.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,4 +1,9 @@
|
|||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
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
|
// 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
|
// 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;
|
return event;
|
||||||
},
|
},
|
||||||
capture_pageview: "history_change",
|
capture_pageview: "history_change",
|
||||||
|
mask_personal_data_properties: true,
|
||||||
|
custom_personal_data_properties: [
|
||||||
|
...POSTHOG_PERSONAL_DATA_QUERY_PARAMETERS,
|
||||||
|
],
|
||||||
respect_dnt: true,
|
respect_dnt: true,
|
||||||
session_recording: {
|
session_recording: {
|
||||||
maskAllInputs: true,
|
maskAllInputs: true,
|
||||||
maskTextSelector: "[data-ph-mask], .ph-mask",
|
maskTextSelector: "[data-ph-mask], .ph-mask",
|
||||||
|
maskCapturedNetworkRequestFn(request) {
|
||||||
|
return {
|
||||||
|
...request,
|
||||||
|
name: sanitizePostHogUrl(request.name),
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
sanitize_properties(properties, event) {
|
sanitize_properties(properties) {
|
||||||
if (event === "$pageview" || event === "$pageleave") {
|
return sanitizePostHogProperties(properties);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
browserPostHogInitialized = true;
|
browserPostHogInitialized = true;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user