/** * What posthog-js is allowed to put on the wire from a browser. * * The server sanitizer never sees this traffic. Session replay, `identify` and * every manual capture go straight from the browser to the ingest proxy, and * posthog-js attaches its own page metadata to all of them: `$current_url`, * `$referrer`, `$pathname`, and the same values again under `$set`/`$set_once` * as "initial" properties that then live on the person. * * `capture_pageview: false` does not help. It suppresses the automatic pageview * EVENT; it does not stop the URL metadata riding along on everything else. A * storefront URL carrying a discount code, a search term or an email from a * newsletter link — or an embedded admin URL carrying `host`, `id_token` and * `session` — would reach PostHog in full, against a product rule that says * URLs are reduced to origin + pathname everywhere. * * `before_send` is posthog-js's supported hook over every outgoing event, * `$snapshot` included. It is the only place this rule can be enforced in the * browser, so it is enforced here and nowhere else. * * Two properties this file must have, because it runs on every event on a * merchant's storefront: * * - it must never throw. A `before_send` that throws breaks capture entirely, * and on some paths breaks the page; * - it must FAIL CLOSED. An event that could not be cleaned is dropped, not * sent uncleaned. Losing one event is a rounding error; leaking one URL is * the thing this exists to prevent. * * SCOPE. It reduces URL-VALUED properties and the page address recorded in * rrweb's meta event. It deliberately does not rewrite hrefs inside the * captured DOM: the recording is already confined to our own modal subtree, so * that DOM is ours, and rewriting a page's links would change what the replay * shows rather than what it discloses. */ /** How deep to walk before giving up. Real events are 3–4 levels. */ const MAX_DEPTH = 6; /** * A URL-ish string reduced to origin + pathname. * * Also handles a bare path, because `$pathname` with a query on it is still a * query and the rule is about the value, not the property name. * * @param value - The candidate value * @returns The reduced value, or the input unchanged when it is not a URL */ export function reduceUrlValue(value: string): string { if (!value) return value; // A bare path: no scheme, but it can still carry a query or a fragment. if (value.startsWith('/')) { const cut = value.search(/[?#]/); return cut === -1 ? value : value.slice(0, cut); } // Anything with a scheme. Only http(s) is a page address; a data: or // javascript: value is a payload, and there is no reduced form of it worth // sending. if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) return value; try { const url = new URL(value); if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''; // `origin` drops any embedded credentials, which is why it is used rather // than reassembling from protocol + host. return `${url.origin}${url.pathname}`; } catch { return ''; } } /** * Reduce every URL-valued string reachable from this value. * * @param value - Anything from the event * @param depth - Remaining depth budget * @param seen - Objects already visited, so a cycle cannot loop */ function reduceDeep(value: unknown, depth: number, seen: WeakSet): unknown { if (typeof value === 'string') return reduceUrlValue(value); if (value === null || typeof value !== 'object') return value; if (depth <= 0) return value; if (seen.has(value as object)) return value; seen.add(value as object); if (Array.isArray(value)) { for (let i = 0; i < value.length; i += 1) { value[i] = reduceDeep(value[i], depth - 1, seen); } return value; } const record = value as Record; for (const key of Object.keys(record)) { record[key] = reduceDeep(record[key], depth - 1, seen); } return record; } /** The URL metadata posthog-js attaches, deleted when all else fails. */ const URL_PROPERTY_KEYS = [ '$current_url', '$referrer', '$pathname', '$host', '$initial_current_url', '$initial_referrer', '$initial_pathname', '$initial_host', ]; /** Remove the known URL properties outright. The last resort before dropping. */ function stripKnownUrlKeys(event: Record): void { for (const container of ['properties', '$set', '$set_once']) { const bag = event[container]; if (bag && typeof bag === 'object') { for (const key of URL_PROPERTY_KEYS) delete (bag as Record)[key]; } } } /** * posthog-js's `before_send` callback. * * @param event - The outgoing event, or null when something upstream dropped it * @returns The event with every URL reduced, or null to drop it */ export function sanitizeBrowserEvent(event: T): T | null { if (!event || typeof event !== 'object') return null; try { reduceDeep(event, MAX_DEPTH, new WeakSet()); return event; } catch { // Something in the shape defeated the walk. Do not send it as it is. try { stripKnownUrlKeys(event as Record); return event; } catch { return null; } } }