Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 50 additions & 16 deletions frontend/lib/posthog-privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@ export function sanitizeRoute(input: unknown): string {
type EventInput = {
event?: unknown;
properties?: unknown;
timestamp?: unknown;
uuid?: unknown;
};

type SanitizedEvent = {
event: "$pageview" | "cta activated" | "$web_vitals";
properties: Record<string, string | number>;
properties: Record<string, boolean | number | string>;
timestamp?: Date;
uuid?: string;
};

function getRawPath(properties: Record<string, unknown>): unknown {
Expand All @@ -65,29 +69,66 @@ function getRawPath(properties: Record<string, unknown>): unknown {
}

function createCommonProperties(properties: Record<string, unknown>) {
const token = properties.token;
if (
typeof token !== "string" ||
!PROJECT_TOKEN_PATTERN.test(token) ||
properties.$cookieless_mode !== true ||
properties.$process_person_profile !== false
) {
return null;
}

const path = sanitizeRoute(getRawPath(properties));
return { schema_version: 1, site: "codeswhat", surface: "marketing", path } as Record<
string,
string | number
>;
const common: Record<string, boolean | number | string> = {
token,
$cookieless_mode: true,
$process_person_profile: false,
schema_version: 1,
site: "codeswhat",
surface: "marketing",
path,
};
if (properties.distinct_id === "$posthog_cookieless") {
common.distinct_id = "$posthog_cookieless";
}
return common;
}

function createSanitizedEvent(
input: EventInput,
event: SanitizedEvent["event"],
properties: SanitizedEvent["properties"],
): SanitizedEvent {
const result: SanitizedEvent = { event, properties };
if (typeof input.uuid === "string") result.uuid = input.uuid;
if (input.timestamp instanceof Date && Number.isFinite(input.timestamp.getTime())) {
result.timestamp = input.timestamp;
}
return result;
}

export function sanitizeEvent(input: unknown): SanitizedEvent | null {
if (!input || typeof input !== "object") return null;
const { event, properties } = input as EventInput;
const eventInput = input as EventInput;
const { event, properties } = eventInput;
if (typeof event !== "string" || !properties || typeof properties !== "object") return null;

const values = properties as Record<string, unknown>;
const common = createCommonProperties(values);
if (common === null) return null;
if (event === "$pageview") {
return { event, properties: { ...common, $current_url: `${PRODUCTION_ORIGIN}${common.path}` } };
return createSanitizedEvent(eventInput, event, {
...common,
$current_url: `${PRODUCTION_ORIGIN}${common.path}`,
});
}

if (event === "cta activated") {
const ctaId = typeof values.cta_id === "string" ? values.cta_id : "";
const placement = typeof values.placement === "string" ? values.placement : "";
return allowedCtaIds.has(ctaId) && allowedPlacements.has(placement)
? { event, properties: { ...common, cta_id: ctaId, placement } }
? createSanitizedEvent(eventInput, event, { ...common, cta_id: ctaId, placement })
: null;
}

Expand All @@ -99,15 +140,8 @@ export function sanitizeEvent(input: unknown): SanitizedEvent | null {
vitalProperties[key] = value;
}
}
if (Object.keys(vitalProperties).length === 0) {
const metricName = typeof values.metric_name === "string" ? values.metric_name : "";
const value = typeof values.value === "number" ? values.value : Number.NaN;
if (Number.isFinite(value) && ["CLS", "FCP", "INP", "LCP"].includes(metricName)) {
vitalProperties[`$web_vitals_${metricName}_value`] = value;
}
}
return Object.keys(vitalProperties).length > 0
? { event, properties: { ...common, ...vitalProperties } }
? createSanitizedEvent(eventInput, event, { ...common, ...vitalProperties })
: null;
}

Expand Down
4 changes: 3 additions & 1 deletion frontend/test/posthog-browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { test } from "node:test";

const baseUrl = process.env.POSTHOG_BROWSER_BASE_URL;

test("production browser response keeps PostHog on the exact proxy", { skip: !baseUrl }, async () => {
test("production browser response keeps PostHog on the exact proxy", {
skip: !baseUrl,
}, async () => {
const response = await fetch(`${baseUrl}/?query-secret=should-not-leave`);
const body = await response.text();
const csp = response.headers.get("content-security-policy") ?? "";
Expand Down
6 changes: 3 additions & 3 deletions frontend/test/posthog-source.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ test("privacy posture disables persistence, recording, autocapture, and automati
"NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN",
"NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_POSTHOG_UI_HOST",
'capture_pageview: false',
'autocapture: false',
'disable_session_recording: true',
"capture_pageview: false",
"autocapture: false",
"disable_session_recording: true",
'persistence: "memory"',
'cookieless_mode: "always"',
"advanced_disable_flags: true",
Expand Down
95 changes: 91 additions & 4 deletions frontend/test/posthog.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { test } from "node:test";
import {
ALLOWED_CTA_IDS,
Expand All @@ -8,6 +9,14 @@ import {
sanitizeRoute,
} from "../lib/posthog-privacy.ts";

const require = createRequire(import.meta.url);
const { PostHog } = require("../node_modules/posthog-js/lib/src/posthog-core.js") as {
PostHog: new () => {
config: { before_send?: (input: unknown) => unknown };
_runBeforeSend: (input: unknown) => unknown;
};
};

test("route sanitization only returns the finite public route manifest", () => {
assert.deepEqual(ALLOWED_ROUTES, ["/"]);
assert.equal(sanitizeRoute("/?utm_source=secret#private"), "/");
Expand All @@ -32,12 +41,19 @@ test("pageview events keep only the sanitized pathname", () => {
properties: {
path: "/?secret=1#fragment",
$current_url: "https://codeswhat.com/?secret=1#fragment",
token: "secret",
token: "phc_public-token_123",
distinct_id: "$posthog_cookieless",
$cookieless_mode: true,
$process_person_profile: false,
},
}),
{
event: "$pageview",
properties: {
token: "phc_public-token_123",
distinct_id: "$posthog_cookieless",
$cookieless_mode: true,
$process_person_profile: false,
schema_version: 1,
site: "codeswhat",
surface: "marketing",
Expand All @@ -53,11 +69,21 @@ test("CTA events are limited to the initial GitHub placements", () => {
assert.deepEqual(
sanitizeEvent({
event: "cta activated",
properties: { path: "/", cta_id: "github_org", placement: "hero", token: "secret" },
properties: {
token: "phc_public-token_123",
$cookieless_mode: true,
$process_person_profile: false,
path: "/",
cta_id: "github_org",
placement: "hero",
},
}),
{
event: "cta activated",
properties: {
token: "phc_public-token_123",
$cookieless_mode: true,
$process_person_profile: false,
schema_version: 1,
site: "codeswhat",
surface: "marketing",
Expand All @@ -81,16 +107,23 @@ test("web vitals events keep only metric data", () => {
sanitizeEvent({
event: "$web_vitals",
properties: {
token: "phc_public-token_123",
$cookieless_mode: true,
$process_person_profile: false,
path: "/?private=1",
metric_name: "LCP",
value: 123.4,
$web_vitals_LCP_value: 123.4,
$web_vitals_CLS_value: -1,
$web_vitals_LCP_event: { attribution: "private" },
rating: "good",
$current_url: "https://codeswhat.com/?private=1",
},
}),
{
event: "$web_vitals",
properties: {
token: "phc_public-token_123",
$cookieless_mode: true,
$process_person_profile: false,
schema_version: 1,
site: "codeswhat",
surface: "marketing",
Expand All @@ -99,8 +132,62 @@ test("web vitals events keep only metric data", () => {
},
},
);
assert.equal(
sanitizeEvent({
event: "$web_vitals",
properties: {
token: "phc_public-token_123",
$cookieless_mode: true,
$process_person_profile: false,
path: "/",
metric_name: "LCP",
value: 123.4,
},
}),
null,
);
});

test("unknown events fail closed", () => {
assert.equal(sanitizeEvent({ event: "$identify", properties: {} }), null);
});

test("the pinned PostHog before_send pipeline keeps the required cookieless envelope", () => {
const timestamp = new Date("2026-08-14T12:00:00.000Z");
const input = {
uuid: "0189f47a-2f44-7dcb-bf7b-3bf1b8bd5d61",
timestamp,
event: "$pageview",
properties: {
token: "phc_public-token_123",
distinct_id: "$posthog_cookieless",
$cookieless_mode: true,
$process_person_profile: false,
path: "/?secret=1#fragment",
$set: { email: "private@example.com" },
$set_once: { referrer: "private" },
},
$set: { email: "private@example.com" },
$set_once: { referrer: "private" },
};

const client = new PostHog();
client.config.before_send = sanitizeEvent;

assert.deepEqual(client._runBeforeSend(input), {
uuid: input.uuid,
timestamp,
event: "$pageview",
properties: {
token: "phc_public-token_123",
distinct_id: "$posthog_cookieless",
$cookieless_mode: true,
$process_person_profile: false,
schema_version: 1,
site: "codeswhat",
surface: "marketing",
path: "/",
$current_url: "https://codeswhat.com/",
},
});
});
Loading