From f883451cc500fc26526e49967c9e8598ff6fb396 Mon Sep 17 00:00:00 2001 From: aliferous3 Date: Wed, 30 Sep 2026 00:49:30 +0400 Subject: [PATCH] feat: sanitized manual $pageview for PostHog Web Analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web Analytics needs $current_url/$pathname on $pageview events, but automatic capture stays off — the SDK would stamp raw location fields (query, hash, referrer) before the scrubber could clean them. - property_denylist still strips every URL/referrer-derived field; before_send re-attaches ONLY the sanitized set ($current_url, $pathname, $host, $referring_domain) recomputed from window.location at send time. - capturePageview() is the sole $pageview entry point — zero caller properties, pathname-only dedupe (StrictMode / hash / ?range= safe), and emits only on the public host or local dev. - Initial pageview fires after SDK init; useRoute's navigate/popstate cover client-side navigation and back/forward. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/analytics/posthog.ts | 53 ++++++++++++++- src/analytics/posthogModel.mjs | 87 +++++++++++++++++++++++- src/main.tsx | 8 ++- src/pages.ts | 3 + test/posthog-analytics.test.mjs | 115 +++++++++++++++++++++++++++++++- 5 files changed, 260 insertions(+), 6 deletions(-) diff --git a/src/analytics/posthog.ts b/src/analytics/posthog.ts index fa1c9e4..837fd48 100644 --- a/src/analytics/posthog.ts +++ b/src/analytics/posthog.ts @@ -24,17 +24,29 @@ export { DENIED_EVENT_PROPERTIES, EVENT_PROPERTIES, FEEDBACK_EVENT_TYPES, + PAGEVIEW_EVENT, + PAGEVIEW_PROPERTIES, POSTHOG_INIT_OPTIONS, PROXY_PATH, SYNC_FAIL_REASONS, SYNC_TRIGGERS, + isPublicAnalyticsHost, mapSyncFailReason, resolveAnalyticsHost, + sanitizePathname, + sanitizeReferringDomain, + sanitizedPageviewProps, scrubEventProperties, syncFailReasonFromHttp, validateAnalyticsEvent, } from './posthogModel.mjs'; +import { + isPublicAnalyticsHost, + sanitizePathname, + sanitizedPageviewProps, +} from './posthogModel.mjs'; + export type SyncTrigger = 'manual' | 'automatic'; export type SyncFailReason = 'network' | 'rate_limited' | 'revoked' | 'server' | 'unknown'; export type AnalyticsRange = '7D' | '30D' | '90D' | 'YTD' | '1Y' | 'ALL' | 'CUSTOM'; @@ -102,7 +114,19 @@ export async function initAnalytics(): Promise { property_denylist: [...DENIED_EVENT_PROPERTIES], before_send: (event: CaptureResult | null) => { if (!event || !event.properties || typeof event.event !== 'string') return event; - event.properties = scrubEventProperties(event.event, event.properties); + // For $pageview, sanitized standard properties are recomputed from + // the live location — denylist already removed the SDK's raw URL + // fields, so nothing caller-supplied can carry query/hash/referrer + // data into the event. + const pageviewProps = + event.event === '$pageview' + ? sanitizedPageviewProps(window.location, document.referrer) + : undefined; + event.properties = scrubEventProperties( + event.event, + event.properties, + pageviewProps, + ); return event; }, }); @@ -129,3 +153,30 @@ export function captureEvent( /* analytics is best-effort — never surface to the app */ } } + +// ── Sanitized manual $pageview ───────────────────────────────────────────── +// +// Automatic pageview capture stays OFF (capture_pageview: false) — the SDK +// would stamp raw URL/referrer fields. This is the ONLY way a $pageview is +// emitted: no caller properties, no raw location data. The sanitized +// $current_url/$pathname/$host/$referring_domain are recomputed inside +// before_send from the live location, so this function cannot be used to +// smuggle arbitrary values. + +// Dedupe consecutive views of the same sanitized pathname — protects the +// initial emit against React StrictMode remounts and keeps hash/query-only +// pushState entries (?range=, section anchors) from counting as pageviews. +let lastPageviewPath: string | null = null; + +export function capturePageview(): void { + if (!client || typeof window === 'undefined') return; + if (!isPublicAnalyticsHost(window.location.host)) return; + const pathname = sanitizePathname(window.location.pathname); + if (pathname === lastPageviewPath) return; + lastPageviewPath = pathname; + try { + client.capture('$pageview'); + } catch { + /* analytics is best-effort — never surface to the app */ + } +} diff --git a/src/analytics/posthogModel.mjs b/src/analytics/posthogModel.mjs index b3e083a..181ac8c 100644 --- a/src/analytics/posthogModel.mjs +++ b/src/analytics/posthogModel.mjs @@ -178,7 +178,7 @@ export const REQUIRED_SDK_EVENT_PROPERTIES = ['token', 'distinct_id']; // non-$ key outside the emitting event's own schema, while preserving the // transport-critical SDK fields above. This keeps the privacy allowlist // narrow without corrupting the event envelope that PostHog must ingest. -export function scrubEventProperties(eventName, properties) { +export function scrubEventProperties(eventName, properties, pageviewProps) { const schema = EVENT_PROPERTIES[eventName]; const allowedCustom = schema ? new Set(Object.keys(schema)) : new Set(); const requiredSdk = new Set(REQUIRED_SDK_EVENT_PROPERTIES); @@ -188,8 +188,93 @@ export function scrubEventProperties(eventName, properties) { if (!key.startsWith('$') && !allowedCustom.has(key) && !requiredSdk.has(key)) continue; clean[key] = value; } + // $pageview re-attaches ONLY the sanitized property set, recomputed from + // the live location by the facade — property_denylist has already removed + // the SDK's raw URL/referrer fields, and caller-supplied values can never + // reach this point. + if (eventName === PAGEVIEW_EVENT && pageviewProps) { + Object.assign(clean, pageviewProps); + } + return clean; +} + +// ── Sanitized page identity (manual $pageview) ───────────────────────────── +// +// Web Analytics needs $current_url/$pathname on $pageview events. Automatic +// capture stays OFF — the SDK would stamp the raw location (query, hash, +// referrer) before our scrubber could clean it. Instead the facade emits +// $pageview with NO caller properties; property_denylist strips every +// URL-derived field the SDK adds, and before_send re-attaches ONLY the +// sanitized set below, computed from window.location at send time. + +export const PAGEVIEW_EVENT = '$pageview'; + +// The only standard properties a $pageview may carry — all sanitized here. +export const PAGEVIEW_PROPERTIES = [ + '$current_url', + '$pathname', + '$host', + '$referring_domain', +]; + +// Canonical analytics pathname: strips query/hash implicitly (caller passes +// location.pathname), collapses trailing slashes to the canonical non-slash +// form used by canonicals/sitemap, and refuses anything that is not a plain +// path — a hostile or malformed value degrades to '/'. +export function sanitizePathname(pathname) { + if (typeof pathname !== 'string' || !pathname.startsWith('/')) return '/'; + if (/[?#]/.test(pathname)) return '/'; + const clean = pathname.replace(/\/+$/, '') || '/'; + // Only allow boring URL-path characters — percent-encoding, whitespace or + // anything exotic collapses to the root page. + if (!/^\/[A-Za-z0-9\-._~/]*$/.test(clean)) return '/'; return clean; } + +// Web Analytics pageviews are only meaningful for the public production +// surface. Internal/preview origins (e.g. *.vercel.app deployment URLs) +// must not create traffic records — only the canonical domain and local +// dev hosts emit. +export function isPublicAnalyticsHost(host) { + return ( + host === 'devledger.site' || + host === 'localhost' || + host === '127.0.0.1' || + host === '::1' || + host.startsWith('localhost:') || + host.startsWith('127.0.0.1:') + ); +} + +// Referrer reduced to its registrable hostname only — never a URL, path or +// query. Self-referrals and anything unparseable become ''. +export function sanitizeReferringDomain(referrer, ownHost) { + if (typeof referrer !== 'string' || !referrer) return ''; + try { + const host = new URL(referrer).host; + if (!host || host === ownHost) return ''; + if (!/^[A-Za-z0-9.-]+$/.test(host)) return ''; + return host; + } catch { + return ''; + } +} + +// The complete sanitized $pageview property set, derived from a +// location-like {origin, pathname, host} and a referrer string. This is the +// ONLY place those values are built — tests pin the exact shape. +export function sanitizedPageviewProps(locationLike, referrer) { + const pathname = sanitizePathname(locationLike?.pathname); + const host = typeof locationLike?.host === 'string' ? locationLike.host : ''; + const origin = typeof locationLike?.origin === 'string' ? locationLike.origin : ''; + return { + $current_url: origin + pathname, + $pathname: pathname, + $host: host, + $referring_domain: sanitizeReferringDomain(referrer, host), + }; +} + // ── SDK initialization options ───────────────────────────────────────────── // Every automatic collector explicitly OFF — custom allowlisted events only. diff --git a/src/main.tsx b/src/main.tsx index 436a263..cfd549c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,7 +16,7 @@ import "@fontsource-variable/newsreader/opsz.css"; import "@fontsource-variable/newsreader/opsz-italic.css"; import "./index.css"; import App from "./App"; -import { initAnalytics, captureEvent } from "./analytics/posthog"; +import { initAnalytics, captureEvent, capturePageview } from "./analytics/posthog"; import { consumeLoginPending } from "./analytics/loginFunnel"; import { SecurityPrivacyPage } from "./security/SecurityPrivacyPage"; import { PrivacyPolicyPage } from "./legal/PrivacyPolicyPage"; @@ -170,8 +170,10 @@ function Root() { // PostHog product analytics — initialized once at boot. Lazy-loaded and // no-ops without a configured token; sends only allowlisted custom events -// through the same-origin /rly proxy. -initAnalytics(); +// through the same-origin /rly proxy. The initial sanitized $pageview +// emits once the SDK is ready; client-side route changes emit from +// useRoute, deduped on pathname. +void initAnalytics().then(() => capturePageview()).catch(() => {}); createRoot(document.getElementById("root")!).render( diff --git a/src/pages.ts b/src/pages.ts index fab7dd4..c4f938f 100644 --- a/src/pages.ts +++ b/src/pages.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; +import { capturePageview } from './analytics/posthog.ts'; /* Canonical three-page registry — the entire authenticated app is exactly OVERVIEW / ACTIVITY / CODE. Header nav, routing and the right rail all @@ -35,6 +36,7 @@ export function useRoute(): { page: PageId; navigate: (page: PageId) => void } { useEffect(() => { const onPop = () => { const next = pageFromPath(window.location.pathname); + capturePageview(); setPage(next); // Section-rail hash jumps land on their anchor; everything else tops. const el = window.location.hash @@ -57,6 +59,7 @@ export function useRoute(): { page: PageId; navigate: (page: PageId) => void } { history.pushState({ page: next }, '', pathForPage(next) + window.location.search); window.scrollTo(0, 0); setPage(next); + capturePageview(); }, [page], ); diff --git a/test/posthog-analytics.test.mjs b/test/posthog-analytics.test.mjs index 3b50848..5b74904 100644 --- a/test/posthog-analytics.test.mjs +++ b/test/posthog-analytics.test.mjs @@ -7,6 +7,11 @@ import { fileURLToPath } from 'node:url'; import { ANALYTICS_RANGES, DENIED_EVENT_PROPERTIES, + PAGEVIEW_PROPERTIES, + isPublicAnalyticsHost, + sanitizePathname, + sanitizeReferringDomain, + sanitizedPageviewProps, EVENT_PROPERTIES, FEEDBACK_EVENT_TYPES, POSTHOG_INIT_OPTIONS, @@ -272,7 +277,7 @@ test('init wires api_host, property_denylist and before_send from the model', () assert.match(facade, /api_host: apiHost/); assert.match(facade, /property_denylist: \[\.\.\.DENIED_EVENT_PROPERTIES\]/); assert.match(facade, /before_send:/); - assert.match(facade, /scrubEventProperties\(event\.event, event\.properties\)/); + assert.match(facade, /scrubEventProperties\([\s\S]*?event\.event[\s\S]*?event\.properties/); assert.match(facade, /\.\.\.\s*\(?\s*POSTHOG_INIT_OPTIONS/); assert.match(facade, /import\.meta\.env\.VITE_POSTHOG_PROJECT_TOKEN/); }); @@ -390,6 +395,114 @@ test('captureEvent validates before sending and never throws without a client', assert.doesNotMatch(facade, /sessionRecording|startSessionRecording/i); }); +/* ── Sanitized manual $pageview ── */ + +test('$pageview is not a caller-emittable custom event', () => { + assert.ok(!('$pageview' in EVENT_PROPERTIES), '$pageview must not join the custom allowlist'); + assert.equal(validateAnalyticsEvent('$pageview', {}), null); + assert.equal(validateAnalyticsEvent('$pageview', { $current_url: 'x' }), null); + const facade = src('src/analytics/posthog.ts'); + assert.match(facade, /client\.capture\('\$pageview'\)/, 'pageview emits with zero caller properties'); + assert.match(facade, /export function capturePageview\(\)/, 'dedicated pageview entry point'); +}); + +test('sanitizePathname strips query, fragment and trailing slashes', () => { + assert.equal(sanitizePathname('/blog/foo'), '/blog/foo'); + assert.equal(sanitizePathname('/blog/foo/'), '/blog/foo'); + assert.equal(sanitizePathname('/blog/'), '/blog'); + assert.equal(sanitizePathname('/'), '/'); + assert.equal(sanitizePathname('/overview'), '/overview'); + // pathname input never carries ?/# — but hostile input is refused anyway + assert.equal(sanitizePathname('/blog/foo?token=SECRET'), '/'); + assert.equal(sanitizePathname('/x#frag'), '/'); + assert.equal(sanitizePathname('/bad path'), '/'); + assert.equal(sanitizePathname('/%2e%2e/'), '/'); + assert.equal(sanitizePathname('https://evil.example/x'), '/'); + assert.equal(sanitizePathname(''), '/'); + assert.equal(sanitizePathname(undefined), '/'); +}); + +test('sanitizedPageviewProps produces the exact allowed shape', () => { + const loc = { + origin: 'https://devledger.site', + host: 'devledger.site', + pathname: '/blog/what-is-code-churn', + }; + assert.deepEqual(sanitizedPageviewProps(loc, ''), { + $current_url: 'https://devledger.site/blog/what-is-code-churn', + $pathname: '/blog/what-is-code-churn', + $host: 'devledger.site', + $referring_domain: '', + }); + // A hostile referrer reduces to a bare domain, never a URL. + const withRef = sanitizedPageviewProps(loc, 'https://google.com/a/path?user=x&campaign=y'); + assert.equal(withRef.$referring_domain, 'google.com'); + const keys = Object.keys(withRef).sort(); + assert.deepEqual(keys, [...PAGEVIEW_PROPERTIES].sort()); +}); + +test('referrer never leaks path/query — self-referral yields empty domain', () => { + assert.equal(sanitizeReferringDomain('https://github.com/Aliferous3/dev-ledger', 'devledger.site'), 'github.com'); + assert.equal(sanitizeReferringDomain('https://devledger.site/blog', 'devledger.site'), ''); + assert.equal(sanitizeReferringDomain('not a url', 'devledger.site'), ''); + assert.equal(sanitizeReferringDomain('', 'devledger.site'), ''); +}); + +test('internal origins never emit pageviews', () => { + assert.equal(isPublicAnalyticsHost('devledger.site'), true); + assert.equal(isPublicAnalyticsHost('dev-ledger-site.vercel.app'), false); + assert.equal(isPublicAnalyticsHost('dev-ledger-abc123.vercel.app'), false); + assert.equal(isPublicAnalyticsHost('localhost:4173'), true); + assert.equal(isPublicAnalyticsHost('localhost'), true); + assert.equal(isPublicAnalyticsHost('127.0.0.1:5173'), true); +}); + +test('before_send $pageview path re-attaches only sanitized props after denylist', () => { + // Simulates the pipeline: denylist already removed the SDK's raw fields; + // before_send merges caller leftovers through the scrubber and attaches + // the recomputed sanitized set. + const dirty = { + token: 'phc_x', + distinct_id: 'anon', + $session_id: 's', + stray: 'dropped', + }; + const clean = scrubEventProperties('$pageview', dirty, { + $current_url: 'https://devledger.site/security', + $pathname: '/security', + $host: 'devledger.site', + $referring_domain: 'google.com', + }); + assert.deepEqual(clean, { + token: 'phc_x', + distinct_id: 'anon', + $session_id: 's', + $current_url: 'https://devledger.site/security', + $pathname: '/security', + $host: 'devledger.site', + $referring_domain: 'google.com', + }); +}); + +test('facade dedupes pageviews and gates on the public host', () => { + const facade = src('src/analytics/posthog.ts'); + assert.match(facade, /lastPageviewPath/); + assert.match(facade, /pathname === lastPageviewPath/); + assert.match(facade, /isPublicAnalyticsHost\(window\.location\.host\)/); + assert.match(facade, /sanitizedPageviewProps\(window\.location, document\.referrer\)/); + assert.doesNotMatch(facade, /location\.href/, 'never reads location.href'); +}); + +test('pageview wiring: boot emit once, route changes and popstate covered', () => { + const main = src('src/main.tsx'); + assert.match(main, /initAnalytics\(\)\.then\(\(\) => capturePageview\(\)\)/); + const pages = src('src/pages.ts'); + // navigate + popstate each emit — dedup in capturePageview collapses + // same-path repeats (StrictMode, hash/range-only history entries). + assert.match(pages, /capturePageview\(\)/); + assert.ok((pages.match(/capturePageview\(\)/g) || []).length >= 2); +}); + test('the built artifact would not contact posthog hosts directly', () => { // No posthog host literal anywhere in src/ — the only allowed reference // is the proxy destination in vercel.json.