/** * 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 shopper's 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. * * `capture_pageview: false` does not help. It stops the automatic *pageview * event*; it does not stop the URL metadata riding along on everything else. So * a storefront URL carrying a discount code, a search term or a newsletter * link's email, 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 the supported hook that sees every outgoing event, including * `$snapshot`. This is the one place that can enforce the rule in the browser. */ import { describe, it, expect } from 'vitest'; import { sanitizeBrowserEvent, reduceUrlValue } from './browserSanitize'; describe('reducing one value', () => { it('keeps origin and pathname and drops everything after', () => { expect(reduceUrlValue('https://shop.example.com/products/tee?discount=SUMMER&q=blue#frag')).toBe( 'https://shop.example.com/products/tee' ); }); it('drops credentials embedded in the authority', () => { expect(reduceUrlValue('https://user:secret@shop.example.com/a')).toBe('https://shop.example.com/a'); }); it('reduces an embedded admin URL, which is where the tokens are', () => { expect( reduceUrlValue( 'https://admin.shopify.com/store/demo/apps/aisthetix?host=YWRtaW4&id_token=eyJhbGciOi&session=abc' ) ).toBe('https://admin.shopify.com/store/demo/apps/aisthetix'); }); it('strips a query from a bare pathname too', () => { // `$pathname` is normally just a path, but a path with a query is still a // query, and the rule is about the value not the property name. expect(reduceUrlValue('/products/tee?utm_source=email#x')).toBe('/products/tee'); }); it('leaves values that are not URLs alone', () => { expect(reduceUrlValue('$direct')).toBe('$direct'); expect(reduceUrlValue('button_block')).toBe('button_block'); expect(reduceUrlValue('')).toBe(''); }); it('refuses a non-http scheme rather than passing it through', () => { // A data: or javascript: URL is not a page address; it is a payload. expect(reduceUrlValue('data:text/html;base64,PHNjcmlwdD4=')).toBe(''); expect(reduceUrlValue('javascript:alert(1)')).toBe(''); }); }); describe('sanitizing a whole outgoing event', () => { it('reduces the URL metadata on an ordinary capture', () => { const event = sanitizeBrowserEvent({ event: 'tryon_modal_opened', properties: { $current_url: 'https://shop.example.com/products/tee?discount=SUMMER#f', $referrer: 'https://mail.example.com/inbox?to=shopper%40example.com', $pathname: '/products/tee?discount=SUMMER', productId: '555', }, }); const properties = event!.properties as Record; expect(properties.$current_url).toBe('https://shop.example.com/products/tee'); expect(properties.$referrer).toBe('https://mail.example.com/inbox'); expect(properties.$pathname).toBe('/products/tee'); expect(properties.productId, 'ordinary properties are untouched').toBe('555'); }); it('reduces the "initial" copies under $set and $set_once', () => { // These are the ones that survive on the person, so a leak here outlives // the event that carried it. const event = sanitizeBrowserEvent({ event: '$identify', properties: { $current_url: 'https://a.example.com/x?y=1' }, $set: { $initial_current_url: 'https://a.example.com/x?y=1' }, $set_once: { $initial_referrer: 'https://b.example.com/z?token=abc' }, }); expect((event!.$set as Record).$initial_current_url).toBe('https://a.example.com/x'); expect((event!.$set_once as Record).$initial_referrer).toBe('https://b.example.com/z'); }); it('reduces the page address recorded inside a replay snapshot', () => { // rrweb's meta event carries the page href. It is the same URL, in a // different envelope, and it is the one a replay of the modal would leak. const event = sanitizeBrowserEvent({ event: '$snapshot', properties: { $session_id: 's1', $current_url: 'https://shop.example.com/p?utm=x', $snapshot_data: [ { type: 4, data: { href: 'https://shop.example.com/p?utm=x&email=a%40b.com', width: 400 } }, { type: 3, data: { source: 2 } }, ], }, }); const properties = event!.properties as Record; const snapshot = properties.$snapshot_data as Array<{ type: number; data: Record }>; expect(properties.$current_url).toBe('https://shop.example.com/p'); expect(snapshot[0].data.href).toBe('https://shop.example.com/p'); expect(snapshot[0].data.width, 'the rest of the meta event is untouched').toBe(400); expect(snapshot[1].data.source, 'and so is the recording payload').toBe(2); }); it('never throws, whatever it is handed', () => { expect(() => sanitizeBrowserEvent(null)).not.toThrow(); expect(() => sanitizeBrowserEvent(undefined as never)).not.toThrow(); expect(() => sanitizeBrowserEvent({ event: 'x' } as never)).not.toThrow(); expect(() => sanitizeBrowserEvent({ event: 'x', properties: null } as never)).not.toThrow(); }); it('survives a self-referential event without hanging', () => { // posthog-js does not produce one, but a `before_send` that can be made to // loop is a page that freezes, and a frozen page is a worse outcome than a // missing event. const properties: Record = { $current_url: 'https://a.example.com/x?y=1' }; properties.self = properties; expect(() => sanitizeBrowserEvent({ event: 'x', properties })).not.toThrow(); }); it('drops the event rather than passing an unsanitizable one through', () => { // Fail closed: an event we could not clean is an event we do not send. const hostile = { event: 'x', get properties(): never { throw new Error('nope'); }, }; expect(sanitizeBrowserEvent(hostile as never)).toBeNull(); }); });