From a5a2eac19a4e70713b556e77a6a5cfafd774fd72 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 25 Feb 2026 14:38:01 +1100 Subject: [PATCH 1/4] feat(proxy): granular per-script privacy controls Replace binary `'proxy' | 'anonymize'` privacy mode with per-flag object model. Each script declares exactly what data it needs, anonymizing everything else. Privacy flags: `ip`, `userAgent`, `language`, `screen`, `timezone`, `hardware` - Untrusted ad networks (Meta, TikTok, X, Snapchat, Reddit): full anonymization - Analytics (GA): preserve UA/screen/timezone for reports, anonymize rest - Session recording (Clarity, Hotjar): preserve UA/screen/timezone for heatmaps - Trusted tools (PostHog, Segment, GTM): no anonymization (full fidelity) Users can override per-script defaults globally via `firstParty.privacy`. --- docs/content/docs/1.guides/2.first-party.md | 56 ++++- .../scripts/analytics/google-analytics.md | 4 +- docs/content/scripts/analytics/posthog.md | 2 +- docs/content/scripts/marketing/clarity.md | 4 +- docs/content/scripts/marketing/hotjar.md | 4 +- .../scripts/tracking/google-tag-manager.md | 3 +- docs/content/scripts/tracking/meta-pixel.md | 3 +- docs/content/scripts/tracking/reddit-pixel.md | 3 +- docs/content/scripts/tracking/segment.md | 3 +- .../scripts/tracking/snapchat-pixel.md | 3 +- docs/content/scripts/tracking/tiktok-pixel.md | 3 +- docs/content/scripts/tracking/x-pixel.md | 3 +- src/module.ts | 52 +++-- src/proxy-configs.ts | 32 +++ src/runtime/server/proxy-handler.ts | 135 ++++++------ src/runtime/server/utils/privacy.ts | 144 ++++++++++--- test/e2e/first-party.test.ts | 20 +- test/fixtures/first-party/nuxt.config.ts | 4 +- test/unit/proxy-configs.test.ts | 5 +- test/unit/proxy-privacy.test.ts | 195 +++++++++++++++--- 20 files changed, 493 insertions(+), 185 deletions(-) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 57dcf6bb..5132ce09 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -53,27 +53,69 @@ export default defineNuxtConfig({ }) ``` -### Privacy Modes +### Privacy Controls -First-party mode supports two privacy levels via the `privacy` option: +Each script in the registry declares its own privacy defaults based on what data it needs. Privacy is controlled by six flags: -| Mode | Description | +| Flag | What it does | |------|-------------| -| `'anonymize'` (default) | Anonymizes IP addresses to subnet level, generalizes screen resolution and hardware info to common buckets, normalizes User-Agent to browser family + major version. Analytics IDs are preserved so tracking still works. | -| `'proxy'` | Forwards requests as-is through your server. Strips sensitive headers (cookies, authorization) but doesn't modify analytics payloads. Privacy comes from third parties seeing your server's IP instead of the user's. | +| `ip` | Anonymizes IP addresses to subnet level in headers and payload params | +| `userAgent` | Normalizes User-Agent to browser family + major version (e.g. `Chrome/131.0`) | +| `language` | Normalizes Accept-Language to primary language tag | +| `screen` | Generalizes screen resolution, viewport, hardware concurrency, and device memory to common buckets | +| `timezone` | Generalizes timezone offset and IANA timezone names | +| `hardware` | Anonymizes canvas/webgl/audio fingerprints, plugin/font lists, browser versions, and device info | + +Sensitive headers (`cookie`, `authorization`) are **always** stripped regardless of privacy settings. + +#### Per-Script Defaults + +Scripts declare the privacy that makes sense for their use case: + +| Script | ip | userAgent | language | screen | timezone | hardware | Rationale | +|--------|:--:|:---------:|:--------:|:------:|:--------:|:--------:|-----------| +| Google Analytics | ✓ | - | ✓ | - | - | ✓ | UA/screen/timezone needed for device, time, and OS reports | +| Google Tag Manager | - | - | - | - | - | - | Container script loading — no user data in requests | +| Meta Pixel | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Untrusted ad network — full anonymization | +| TikTok Pixel | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Untrusted ad network — full anonymization | +| X/Twitter Pixel | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Untrusted ad network — full anonymization | +| Snapchat Pixel | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Untrusted ad network — full anonymization | +| Reddit Pixel | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | Untrusted ad network — full anonymization | +| Segment | - | - | - | - | - | - | Trusted data pipeline — full fidelity required | +| PostHog | - | - | - | - | - | - | Trusted, open-source — full fidelity required | +| Microsoft Clarity | ✓ | - | ✓ | - | - | ✓ | UA/screen/timezone needed for heatmaps and device filtering | +| Hotjar | ✓ | - | ✓ | - | - | ✓ | UA/screen/timezone needed for heatmaps and device filtering | + +✓ = anonymized, - = passed through + +#### Global Override + +Override all per-script defaults at once: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + scripts: { + firstParty: { + privacy: true, // Full anonymize for ALL scripts + } + } +}) +``` + +Or selectively override specific flags: ```ts [nuxt.config.ts] export default defineNuxtConfig({ scripts: { firstParty: { - privacy: 'proxy', // or 'anonymize' (default) + privacy: { ip: true }, // Anonymize IP for all scripts, rest uses per-script defaults } } }) ``` ::callout{type="info"} -In `anonymize` mode, fingerprinting data is **generalized** rather than stripped — analytics endpoints still receive valid data, just with reduced precision. For example, screen resolution `1440x900` becomes `1920x1080` (desktop bucket), and User-Agent is normalized to `Mozilla/5.0 (compatible; Chrome/131.0)`. +When a flag is active, data is **generalized** rather than stripped — analytics endpoints still receive valid data, just with reduced precision. For example, screen resolution `1440x900` becomes `1920x1080` (desktop bucket), and User-Agent is normalized to `Mozilla/5.0 (compatible; Chrome/131.0)`. :: ### Custom Paths diff --git a/docs/content/scripts/analytics/google-analytics.md b/docs/content/scripts/analytics/google-analytics.md index 2fb78c65..65840227 100644 --- a/docs/content/scripts/analytics/google-analytics.md +++ b/docs/content/scripts/analytics/google-analytics.md @@ -192,8 +192,8 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of third-party servers - Route collection requests (`/g/collect`) through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data (`sr`, `vp`, `ul`) to common buckets +- Anonymize IP addresses, language, and hardware fingerprints (canvas, webgl, browser versions) +- Preserve User-Agent, screen resolution, and timezone for accurate device, OS, and time-based reports ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/docs/content/scripts/analytics/posthog.md b/docs/content/scripts/analytics/posthog.md index 292a6e7b..d18d9235 100644 --- a/docs/content/scripts/analytics/posthog.md +++ b/docs/content/scripts/analytics/posthog.md @@ -161,7 +161,7 @@ export default defineNuxtConfig({ ## First-Party Proxy -When [first-party mode](/docs/guides/first-party) is enabled, PostHog requests are automatically proxied through your own server. This improves event capture reliability by avoiding ad blockers. +When [first-party mode](/docs/guides/first-party) is enabled, PostHog requests are automatically proxied through your own server. This improves event capture reliability by avoiding ad blockers. No privacy anonymization is applied — PostHog is a trusted, open-source tool that requires full fidelity data for GeoIP enrichment, feature flags, and session replay. No additional configuration is needed — the module automatically sets `apiHost` to route through your server's proxy endpoint: diff --git a/docs/content/scripts/marketing/clarity.md b/docs/content/scripts/marketing/clarity.md index 47dd80fc..713a97dc 100644 --- a/docs/content/scripts/marketing/clarity.md +++ b/docs/content/scripts/marketing/clarity.md @@ -132,8 +132,8 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `www.clarity.ms` - Route data/event collection (`d.clarity.ms`, `e.clarity.ms`) through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data to common buckets +- Anonymize IP addresses, language, and hardware fingerprints (canvas, webgl, browser versions) +- Preserve User-Agent, screen resolution, and timezone for accurate heatmaps and device filtering ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/docs/content/scripts/marketing/hotjar.md b/docs/content/scripts/marketing/hotjar.md index 9fe7ddeb..2c2c4b59 100644 --- a/docs/content/scripts/marketing/hotjar.md +++ b/docs/content/scripts/marketing/hotjar.md @@ -118,8 +118,8 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `static.hotjar.com` - Route configuration and data requests (`vars.hotjar.com`, `in.hotjar.com`) through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data to common buckets +- Anonymize IP addresses, language, and hardware fingerprints (canvas, webgl, browser versions) +- Preserve User-Agent, screen resolution, and timezone for accurate heatmaps and device filtering ::callout{type="info"} Hotjar uses WebSocket connections for session recording data. The proxy handles initial setup, but WebSocket connections go directly to Hotjar servers. diff --git a/docs/content/scripts/tracking/google-tag-manager.md b/docs/content/scripts/tracking/google-tag-manager.md index 480af32a..375e431a 100644 --- a/docs/content/scripts/tracking/google-tag-manager.md +++ b/docs/content/scripts/tracking/google-tag-manager.md @@ -265,8 +265,7 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `www.googletagmanager.com` - Route all GTM requests through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data to common buckets +- No privacy anonymization applied (container script loading only — no user data in these requests) ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/docs/content/scripts/tracking/meta-pixel.md b/docs/content/scripts/tracking/meta-pixel.md index 92775921..228e670a 100644 --- a/docs/content/scripts/tracking/meta-pixel.md +++ b/docs/content/scripts/tracking/meta-pixel.md @@ -154,8 +154,7 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `connect.facebook.net` - Route tracking requests (`/tr`) through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data to common buckets +- Full privacy anonymization — IP, User-Agent, language, screen, timezone, and hardware fingerprints are all anonymized (untrusted ad network) ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/docs/content/scripts/tracking/reddit-pixel.md b/docs/content/scripts/tracking/reddit-pixel.md index 13124d01..07f8346b 100644 --- a/docs/content/scripts/tracking/reddit-pixel.md +++ b/docs/content/scripts/tracking/reddit-pixel.md @@ -123,8 +123,7 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `alb.reddit.com` - Route tracking requests through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data to common buckets +- Full privacy anonymization — IP, User-Agent, language, screen, timezone, and hardware fingerprints are all anonymized (untrusted ad network) ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/docs/content/scripts/tracking/segment.md b/docs/content/scripts/tracking/segment.md index 79418d85..dee304db 100644 --- a/docs/content/scripts/tracking/segment.md +++ b/docs/content/scripts/tracking/segment.md @@ -125,8 +125,7 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `cdn.segment.com` - Route API requests (`api.segment.io`) through your server -- Anonymize user IP addresses to subnet level -- Normalize User-Agent and generalize device fingerprinting data to common buckets +- No privacy anonymization applied — Segment is a trusted data pipeline that requires full fidelity for downstream destinations ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/docs/content/scripts/tracking/snapchat-pixel.md b/docs/content/scripts/tracking/snapchat-pixel.md index cb678219..542f1e1a 100644 --- a/docs/content/scripts/tracking/snapchat-pixel.md +++ b/docs/content/scripts/tracking/snapchat-pixel.md @@ -181,8 +181,7 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `tr.snapchat.com` - Route tracking requests through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data (`d_os`, `d_bvs`, screen dimensions) to common buckets +- Full privacy anonymization — IP, User-Agent, language, screen, timezone, and hardware fingerprints are all anonymized (untrusted ad network) ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/docs/content/scripts/tracking/tiktok-pixel.md b/docs/content/scripts/tracking/tiktok-pixel.md index f8f5aa5d..dc02464e 100644 --- a/docs/content/scripts/tracking/tiktok-pixel.md +++ b/docs/content/scripts/tracking/tiktok-pixel.md @@ -140,8 +140,7 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `analytics.tiktok.com` - Route tracking requests through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data to common buckets +- Full privacy anonymization — IP, User-Agent, language, screen, timezone, and hardware fingerprints are all anonymized (untrusted ad network) ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/docs/content/scripts/tracking/x-pixel.md b/docs/content/scripts/tracking/x-pixel.md index cff33888..2960ddd8 100644 --- a/docs/content/scripts/tracking/x-pixel.md +++ b/docs/content/scripts/tracking/x-pixel.md @@ -144,8 +144,7 @@ This script supports [First-Party Mode](/docs/guides/first-party) which routes a When enabled globally via `scripts.firstParty: true`, this script will: - Load from your domain instead of `analytics.twitter.com` - Route tracking requests (`t.co`) through your server -- Anonymize user IP addresses to subnet level -- Generalize device fingerprinting data (`dv` combined device info) to common buckets +- Full privacy anonymization — IP, User-Agent, language, screen, timezone, and hardware fingerprints are all anonymized (untrusted ad network) ```ts [nuxt.config.ts] export default defineNuxtConfig({ diff --git a/src/module.ts b/src/module.ts index 92b62415..a6f33921 100644 --- a/src/module.ts +++ b/src/module.ts @@ -29,6 +29,7 @@ import type { import { NuxtScriptsCheckScripts } from './plugins/check-scripts' import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates' import { getAllProxyConfigs, getSWInterceptRules } from './proxy-configs' +import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' declare module '@nuxt/schema' { interface NuxtHooks { @@ -37,17 +38,21 @@ declare module '@nuxt/schema' { } /** - * Privacy mode for first-party proxy requests. + * Global privacy override for all first-party proxy requests. * - * - `'anonymize'` (default) - Prevents fingerprinting: anonymizes IP addresses to country-level, - * normalizes device info and canvas data. All other data passes through unchanged. + * By default (`undefined`), each script uses its own privacy controls declared in the registry. + * Setting this overrides all per-script defaults: * - * - `'proxy'` - Minimal modification: forwards headers and data, but strips sensitive - * auth/session headers (cookie, authorization) to prevent leaking credentials to - * third-party endpoints. Privacy comes from routing requests through your server - * (third parties see server IP, not user IP). + * - `true` - Full anonymize: anonymizes IP, normalizes User-Agent/language, + * generalizes screen/hardware/canvas/timezone data. + * + * - `false` - Passthrough: forwards headers and data, but strips sensitive + * auth/session headers (cookie, authorization). + * + * - `{ ip: false }` - Selective: override individual flags. Unset flags inherit + * from the per-script default. */ -export type FirstPartyPrivacy = 'proxy' | 'anonymize' +export type FirstPartyPrivacy = ProxyPrivacyInput export interface FirstPartyOptions { /** @@ -68,14 +73,16 @@ export interface FirstPartyOptions { */ collectPrefix?: string /** - * Privacy level for proxied requests. + * Global privacy override for all proxied scripts. * - * Controls what user information is forwarded to third-party analytics services. + * By default, each script uses its own privacy controls from the registry. + * Set this to override all scripts at once: * - * - `'anonymize'` - Prevents fingerprinting by anonymizing IPs and device info (default) - * - `'proxy'` - No modification, just routes through your server + * - `true` - Full anonymize for all scripts + * - `false` - Passthrough for all scripts (still strips sensitive auth headers) + * - `{ ip: false }` - Selective override (unset flags inherit per-script defaults) * - * @default 'anonymize' + * @default undefined */ privacy?: FirstPartyPrivacy } @@ -309,9 +316,9 @@ export default defineNuxtModule({ const firstPartyCollectPrefix = typeof config.firstParty === 'object' ? config.firstParty.collectPrefix || '/_proxy' : '/_proxy' - const firstPartyPrivacy = typeof config.firstParty === 'object' - ? config.firstParty.privacy ?? 'anonymize' - : 'anonymize' + const firstPartyPrivacy: ProxyPrivacyInput | undefined = typeof config.firstParty === 'object' + ? config.firstParty.privacy + : undefined const assetsPrefix = firstPartyPrefix || config.assets?.prefix || '/_scripts' // Process partytown shorthand - add partytown: true to specified registry scripts @@ -551,6 +558,7 @@ export default defineNuxtPlugin({ // Collect routes for all configured registry scripts that support proxying const neededRoutes: Record = {} + const routePrivacyOverrides: Record = {} const unsupportedScripts: string[] = [] for (const key of registryKeys) { // Find the registry script definition @@ -561,6 +569,10 @@ export default defineNuxtPlugin({ const proxyConfig = proxyConfigs[proxyKey] if (proxyConfig?.routes) { Object.assign(neededRoutes, proxyConfig.routes) + // Record per-script privacy for each route + for (const routePath of Object.keys(proxyConfig.routes)) { + routePrivacyOverrides[routePath] = proxyConfig.privacy + } } else { // Track scripts without proxy support @@ -611,9 +623,10 @@ export default defineNuxtPlugin({ // Server-side config for proxy privacy handling nuxt.options.runtimeConfig['nuxt-scripts-proxy'] = { routes: flatRoutes, - privacy: firstPartyPrivacy, + privacy: firstPartyPrivacy, // undefined = use per-script defaults, set = global override + routePrivacy: routePrivacyOverrides, // per-script privacy from registry rewrites: allRewrites, - } + } as any // Proxy handler is registered before modules:done for both privacy modes if (Object.keys(neededRoutes).length) { @@ -621,7 +634,8 @@ export default defineNuxtPlugin({ if (nuxt.options.dev) { const routeCount = Object.keys(neededRoutes).length const scriptsCount = registryKeys.length - logger.success(`First-party mode enabled for ${scriptsCount} script(s), ${routeCount} proxy route(s) configured (privacy: ${firstPartyPrivacy})`) + const privacyLabel = firstPartyPrivacy === undefined ? 'per-script' : typeof firstPartyPrivacy === 'boolean' ? (firstPartyPrivacy ? 'anonymize' : 'passthrough') : 'custom' + logger.success(`First-party mode enabled for ${scriptsCount} script(s), ${routeCount} proxy route(s) configured (privacy: ${privacyLabel})`) if (logger.level >= 4) { for (const [path, config] of Object.entries(neededRoutes)) { logger.debug(` ${path} → ${config.proxy}`) diff --git a/src/proxy-configs.ts b/src/proxy-configs.ts index bb7b5462..47a1fd35 100644 --- a/src/proxy-configs.ts +++ b/src/proxy-configs.ts @@ -1,4 +1,5 @@ import { rewriteScriptUrls, type ProxyRewrite } from './runtime/utils/pure' +import type { ProxyPrivacyInput } from './runtime/server/utils/privacy' export type { ProxyRewrite } @@ -11,6 +12,15 @@ export interface ProxyConfig { rewrite?: ProxyRewrite[] /** Nitro route rules to inject for proxying requests */ routes?: Record + /** + * Per-script privacy controls. Each script declares what it needs. + * - `true` (default) = full anonymize: IP, UA, language, screen, timezone, hardware fingerprints + * - `false` = passthrough (still strips sensitive auth headers) + * - `{ ip: false }` = selective (unset flags default to `false`) + * + * Users can override per-script defaults via `firstParty.privacy` in nuxt.config. + */ + privacy: ProxyPrivacyInput } /** @@ -19,6 +29,8 @@ export interface ProxyConfig { function buildProxyConfig(collectPrefix: string) { return { googleAnalytics: { + // GA4: screen/timezone/UA needed for device, time, and OS reports; rest anonymized safely + privacy: { ip: true, userAgent: false, language: true, screen: false, timezone: false, hardware: true }, rewrite: [ // Modern gtag.js uses www.google.com/g/collect { from: 'www.google.com/g/collect', to: `${collectPrefix}/ga/g/collect` }, @@ -50,6 +62,8 @@ function buildProxyConfig(collectPrefix: string) { }, googleTagManager: { + // GTM: container only, passes data through — downstream tags have their own privacy + privacy: { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, rewrite: [ { from: 'www.googletagmanager.com', to: `${collectPrefix}/gtm` }, ], @@ -59,6 +73,8 @@ function buildProxyConfig(collectPrefix: string) { }, metaPixel: { + // Meta: untrusted ad network — full anonymization + privacy: { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true }, rewrite: [ // SDK script loading { from: 'connect.facebook.net', to: `${collectPrefix}/meta` }, @@ -78,6 +94,8 @@ function buildProxyConfig(collectPrefix: string) { }, tiktokPixel: { + // TikTok: untrusted ad network — full anonymization + privacy: { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true }, rewrite: [ { from: 'analytics.tiktok.com', to: `${collectPrefix}/tiktok` }, ], @@ -87,6 +105,8 @@ function buildProxyConfig(collectPrefix: string) { }, segment: { + // Segment: trusted data pipeline — needs maximum fidelity for downstream destinations + privacy: { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, rewrite: [ { from: 'api.segment.io', to: `${collectPrefix}/segment` }, { from: 'cdn.segment.com', to: `${collectPrefix}/segment-cdn` }, @@ -98,6 +118,8 @@ function buildProxyConfig(collectPrefix: string) { }, xPixel: { + // X/Twitter: untrusted ad network — full anonymization + privacy: { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true }, rewrite: [ { from: 'analytics.twitter.com', to: `${collectPrefix}/x` }, { from: 't.co', to: `${collectPrefix}/x-t` }, @@ -109,6 +131,8 @@ function buildProxyConfig(collectPrefix: string) { }, snapchatPixel: { + // Snapchat: untrusted ad network — full anonymization + privacy: { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true }, rewrite: [ { from: 'tr.snapchat.com', to: `${collectPrefix}/snap` }, ], @@ -118,6 +142,8 @@ function buildProxyConfig(collectPrefix: string) { }, redditPixel: { + // Reddit: untrusted ad network — full anonymization + privacy: { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true }, rewrite: [ { from: 'alb.reddit.com', to: `${collectPrefix}/reddit` }, ], @@ -127,6 +153,8 @@ function buildProxyConfig(collectPrefix: string) { }, clarity: { + // Clarity: screen/UA/timezone needed for heatmaps and device filtering; rest anonymized + privacy: { ip: true, userAgent: false, language: true, screen: false, timezone: false, hardware: true }, rewrite: [ // Main clarity domain { from: 'www.clarity.ms', to: `${collectPrefix}/clarity` }, @@ -147,6 +175,8 @@ function buildProxyConfig(collectPrefix: string) { posthog: { // No rewrites needed - PostHog uses NPM mode, SDK URLs are set via api_host config + // PostHog: needs real IP for GeoIP enrichment + feature flag targeting + privacy: { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }, routes: { // US region [`${collectPrefix}/ph/static/**`]: { proxy: 'https://us-assets.i.posthog.com/static/**' }, @@ -158,6 +188,8 @@ function buildProxyConfig(collectPrefix: string) { }, hotjar: { + // Hotjar: screen/UA/timezone needed for heatmaps and device segmentation; rest anonymized + privacy: { ip: true, userAgent: false, language: true, screen: false, timezone: false, hardware: true }, rewrite: [ // Static assets { from: 'static.hotjar.com', to: `${collectPrefix}/hotjar` }, diff --git a/src/runtime/server/proxy-handler.ts b/src/runtime/server/proxy-handler.ts index 2df628af..5ca3550e 100644 --- a/src/runtime/server/proxy-handler.ts +++ b/src/runtime/server/proxy-handler.ts @@ -4,18 +4,22 @@ import { useStorage, useNitroApp } from 'nitropack/runtime' import { hash } from 'ohash' import { rewriteScriptUrls } from '../utils/pure' import { - FINGERPRINT_HEADERS, - IP_HEADERS, SENSITIVE_HEADERS, anonymizeIP, normalizeLanguage, normalizeUserAgent, stripPayloadFingerprinting, + resolvePrivacy, + mergePrivacy, } from './utils/privacy' +import type { ProxyPrivacyInput } from './utils/privacy' interface ProxyConfig { routes: Record - privacy: 'anonymize' | 'proxy' + /** Global user override — undefined means use per-script defaults */ + privacy?: ProxyPrivacyInput + /** Per-script privacy from registry (every route has an entry) */ + routePrivacy: Record rewrites?: Array<{ from: string, to: string }> /** Cache duration for JavaScript responses in seconds (default: 3600 = 1 hour) */ cacheTtl?: number @@ -28,8 +32,9 @@ interface ProxyConfig { */ function stripQueryFingerprinting( query: Record, + privacy?: import('./utils/privacy').ResolvedProxyPrivacy, ): string { - const stripped = stripPayloadFingerprinting(query) + const stripped = stripPayloadFingerprinting(query, privacy) const params = new URLSearchParams() for (const [key, value] of Object.entries(stripped)) { if (value !== undefined && value !== null) { @@ -46,7 +51,7 @@ function stripQueryFingerprinting( export default defineEventHandler(async (event) => { const config = useRuntimeConfig() const nitro = useNitroApp() - const proxyConfig = config['nuxt-scripts-proxy'] as ProxyConfig | undefined + const proxyConfig = config['nuxt-scripts-proxy'] as unknown as ProxyConfig | undefined if (!proxyConfig) { throw createError({ @@ -55,7 +60,7 @@ export default defineEventHandler(async (event) => { }) } - const { routes, privacy, cacheTtl = 3600, debug = import.meta.dev } = proxyConfig + const { routes, privacy: globalPrivacy, routePrivacy, cacheTtl = 3600, debug = import.meta.dev } = proxyConfig const path = event.path const log = debug ? (message: string, ...args: any[]) => { @@ -67,6 +72,7 @@ export default defineEventHandler(async (event) => { // Find matching route (sort by length descending to match longest/most specific first) let targetBase: string | undefined let matchedPrefix: string | undefined + let matchedRoutePattern: string | undefined const sortedRoutes = Object.entries(routes).sort((a, b) => b[0].length - a[0].length) for (const [routePattern, target] of sortedRoutes) { @@ -75,11 +81,19 @@ export default defineEventHandler(async (event) => { if (path.startsWith(prefix)) { targetBase = target.replace(/\/\*\*$/, '') matchedPrefix = prefix + matchedRoutePattern = routePattern log('[proxy] Matched:', prefix, '->', targetBase) break } } + // Resolve effective privacy: per-script is the base, global user override on top + const perScriptInput = matchedRoutePattern ? routePrivacy[matchedRoutePattern] : undefined + const perScriptResolved = resolvePrivacy(perScriptInput) + // Global override: when set by user, it overrides per-script field-by-field + const privacy = globalPrivacy !== undefined ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved + const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware + if (!targetBase || !matchedPrefix) { log('[proxy] No match for path:', path) throw createError({ @@ -97,11 +111,11 @@ export default defineEventHandler(async (event) => { } let targetUrl = targetBase + targetPath - // Strip fingerprinting from query string in anonymize mode - if (privacy === 'anonymize') { + // Strip fingerprinting from query string when any privacy flag is active + if (anyPrivacy) { const query = getQuery(event) if (Object.keys(query).length > 0) { - const strippedQuery = stripQueryFingerprinting(query) + const strippedQuery = stripQueryFingerprinting(query, privacy) // Replace query string in URL const basePath = targetUrl.split('?')[0] || targetUrl targetUrl = strippedQuery ? `${basePath}?${strippedQuery}` : basePath @@ -112,62 +126,61 @@ export default defineEventHandler(async (event) => { const originalHeaders = getHeaders(event) const headers: Record = {} - // Process headers based on privacy mode - if (privacy === 'proxy') { - // Proxy mode: forward headers but strip sensitive auth/session headers - // to prevent leaking credentials to third-party analytics endpoints - for (const [key, value] of Object.entries(originalHeaders)) { - if (!value) continue - if (SENSITIVE_HEADERS.includes(key.toLowerCase())) continue - headers[key] = value - } - } - else { - // Anonymize mode: preserve useful analytics, prevent fingerprinting - for (const [key, value] of Object.entries(originalHeaders)) { - if (!value) - continue + // Process headers based on per-flag privacy + for (const [key, value] of Object.entries(originalHeaders)) { + if (!value) continue + const lowerKey = key.toLowerCase() - const lowerKey = key.toLowerCase() + // SENSITIVE_HEADERS always stripped regardless of privacy flags + if (SENSITIVE_HEADERS.includes(lowerKey)) continue - // Skip IP-revealing headers entirely - if (IP_HEADERS.includes(lowerKey)) - continue + // Skip content-length when any privacy is active — body may be modified + if (anyPrivacy && lowerKey === 'content-length') continue - // Skip sensitive headers - if (SENSITIVE_HEADERS.includes(lowerKey)) - continue + // IP-revealing headers — controlled by ip flag + if (lowerKey === 'x-forwarded-for' || lowerKey === 'x-real-ip' || lowerKey === 'forwarded' + || lowerKey === 'cf-connecting-ip' || lowerKey === 'true-client-ip' + || lowerKey === 'x-client-ip' || lowerKey === 'x-cluster-client-ip') { + if (privacy.ip) continue // skip — we add anonymized version below + headers[key] = value + continue + } - // Skip content-length - we modify the body so fetch needs to recalculate - if (lowerKey === 'content-length') - continue + // User-Agent — controlled by userAgent flag + if (lowerKey === 'user-agent') { + headers[key] = privacy.userAgent ? normalizeUserAgent(value) : value + continue + } - // Normalize fingerprinting headers - if (lowerKey === 'user-agent') { - headers[key] = normalizeUserAgent(value) - } - else if (lowerKey === 'accept-language') { - headers[key] = normalizeLanguage(value) - } - else if (lowerKey === 'sec-ch-ua' || lowerKey === 'sec-ch-ua-full-version-list') { - // Normalize to major versions: "Chromium";v="143.0.7499.4" → "Chromium";v="143" - headers[key] = value.replace(/;v="(\d+)\.[^"]*"/g, ';v="$1"') - } - else if (FINGERPRINT_HEADERS.includes(lowerKey)) { - // Forward other Client Hints as-is (sec-ch-ua-platform, sec-ch-ua-mobile are low entropy) - headers[key] = value - } - else { - // Forward other headers (content-type, accept, referer, etc.) - headers[key] = value - } + // Accept-Language — controlled by language flag + if (lowerKey === 'accept-language') { + headers[key] = privacy.language ? normalizeLanguage(value) : value + continue + } + + // Client Hints (hardware flag) + if (lowerKey === 'sec-ch-ua' || lowerKey === 'sec-ch-ua-full-version-list') { + headers[key] = privacy.hardware + ? value.replace(/;v="(\d+)\.[^"]*"/g, ';v="$1"') + : value + continue } - // Add anonymized IP for country-level geo - const clientIP = getRequestIP(event, { xForwardedFor: true }) - if (clientIP) { + // Other client hints (sec-ch-ua-platform, sec-ch-ua-mobile are low entropy) — pass through + headers[key] = value + } + + // IP handling: add x-forwarded-for based on ip flag + const clientIP = getRequestIP(event, { xForwardedFor: true }) + if (clientIP) { + if (privacy.ip) { + // Anonymize IP for country-level geo headers['x-forwarded-for'] = anonymizeIP(clientIP) } + else { + // Forward real IP — needed for services like PostHog geolocation + headers['x-forwarded-for'] = clientIP + } } // Read and process request body if present @@ -180,10 +193,10 @@ export default defineEventHandler(async (event) => { if (method === 'POST' || method === 'PUT' || method === 'PATCH') { rawBody = await readBody(event) - if (privacy === 'anonymize' && rawBody) { + if (anyPrivacy && rawBody) { if (typeof rawBody === 'object') { // JSON body - strip fingerprinting recursively - body = stripPayloadFingerprinting(rawBody as Record) + body = stripPayloadFingerprinting(rawBody as Record, privacy) } else if (typeof rawBody === 'string') { // Try parsing as JSON first (sendBeacon often sends JSON with text/plain content-type) @@ -195,7 +208,7 @@ export default defineEventHandler(async (event) => { catch { /* not valid JSON */ } if (parsed && typeof parsed === 'object') { - body = stripPayloadFingerprinting(parsed as Record) + body = stripPayloadFingerprinting(parsed as Record, privacy) } else { body = rawBody @@ -208,7 +221,7 @@ export default defineEventHandler(async (event) => { params.forEach((value, key) => { obj[key] = value }) - const stripped = stripPayloadFingerprinting(obj) + const stripped = stripPayloadFingerprinting(obj, privacy) // Convert all values to strings — URLSearchParams coerces non-strings // to "[object Object]" which corrupts nested objects/arrays const stringified: Record = {} @@ -245,7 +258,7 @@ export default defineEventHandler(async (event) => { }, stripped: { headers, - query: privacy === 'anonymize' ? stripPayloadFingerprinting(originalQuery) : originalQuery, + query: anyPrivacy ? stripPayloadFingerprinting(originalQuery, privacy) : originalQuery, body: body ?? null, }, }) diff --git a/src/runtime/server/utils/privacy.ts b/src/runtime/server/utils/privacy.ts index 1b1d99d4..1a71dee1 100644 --- a/src/runtime/server/utils/privacy.ts +++ b/src/runtime/server/utils/privacy.ts @@ -1,3 +1,73 @@ +/** + * Granular privacy controls for the first-party proxy. + * Each flag controls both headers AND body/query params for its domain. + */ +export interface ProxyPrivacy { + /** Anonymize IP (headers + body). When false, real IP is forwarded via x-forwarded-for. */ + ip?: boolean + /** Normalize User-Agent (headers + body) */ + userAgent?: boolean + /** Normalize Accept-Language (headers + body) */ + language?: boolean + /** Generalize screen resolution, viewport, hardware concurrency, device memory */ + screen?: boolean + /** Generalize timezone offset and IANA timezone names */ + timezone?: boolean + /** Anonymize hardware fingerprints: canvas/webgl/audio, plugins/fonts, browser versions, device info */ + hardware?: boolean +} + +/** + * Privacy input: `true` = full anonymize, `false` = passthrough (still strips sensitive headers), + * or a `ProxyPrivacy` object for granular control (unset flags default to `false` — opt-in). + */ +export type ProxyPrivacyInput = boolean | ProxyPrivacy + +/** Resolved privacy with all flags explicitly set. */ +export type ResolvedProxyPrivacy = Required + +/** + * Normalize a privacy input to a fully-resolved object. + * Privacy is opt-in: unset object flags default to `false`. + * Each script in the registry explicitly sets all flags for its needs. + * - `true` → all flags true (full anonymize) + * - `false` / `undefined` → all flags false (passthrough) + * - `{ ip: true, hardware: true }` → only those active, rest off + */ +export function resolvePrivacy(input?: ProxyPrivacyInput): ResolvedProxyPrivacy { + if (input === true) return { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true } + if (input === false || input === undefined || input === null) return { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false } + return { + ip: input.ip ?? false, + userAgent: input.userAgent ?? false, + language: input.language ?? false, + screen: input.screen ?? false, + timezone: input.timezone ?? false, + hardware: input.hardware ?? false, + } +} + +/** + * Merge privacy settings: `override` fields take precedence over `base` field-by-field. + * When `override` is undefined, returns `base` unchanged. + * When `override` is a boolean, it fully replaces `base`. + * When `override` is an object, only explicitly-set fields override. + */ +export function mergePrivacy(base: ResolvedProxyPrivacy, override?: ProxyPrivacyInput): ResolvedProxyPrivacy { + if (override === undefined || override === null) return base + // Boolean fully replaces + if (typeof override === 'boolean') return resolvePrivacy(override) + // Object: only override fields that were explicitly set + return { + ip: override.ip !== undefined ? override.ip : base.ip, + userAgent: override.userAgent !== undefined ? override.userAgent : base.userAgent, + language: override.language !== undefined ? override.language : base.language, + screen: override.screen !== undefined ? override.screen : base.screen, + timezone: override.timezone !== undefined ? override.timezone : base.timezone, + hardware: override.hardware !== undefined ? override.hardware : base.hardware, + } +} + /** * Headers that reveal user IP address - stripped in proxy mode, * anonymized in anonymize mode. @@ -289,10 +359,15 @@ export function anonymizeDeviceInfo(value: string): string { * Recursively anonymize fingerprinting data in payload. * Fields are generalized or normalized rather than stripped, so endpoints * still receive valid data with reduced fingerprinting precision. + * + * When `privacy` is provided, only categories with their flag set to `true` are processed. + * Default (no arg) = all categories active, so existing callers work unchanged. */ export function stripPayloadFingerprinting( payload: Record, + privacy?: ResolvedProxyPrivacy, ): Record { + const p = privacy || { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true } const result: Record = {} // Pre-scan for screen width to enable paired height bucketing. @@ -309,29 +384,40 @@ export function stripPayloadFingerprinting( for (const [key, value] of Object.entries(payload)) { const lowerKey = key.toLowerCase() - const isLanguageParam = NORMALIZE_PARAMS.language.some(p => lowerKey === p.toLowerCase()) - const isUserAgentParam = NORMALIZE_PARAMS.userAgent.some(p => lowerKey === p.toLowerCase()) - - if ((isLanguageParam || isUserAgentParam) && typeof value === 'string') { - result[key] = isLanguageParam ? normalizeLanguage(value) : normalizeUserAgent(value) - continue - } - const matchesParam = (key: string, params: string[]) => { const lk = key.toLowerCase() - return params.some((p) => { - const lp = p.toLowerCase() + return params.some((pm) => { + const lp = pm.toLowerCase() return lk === lp || lk.startsWith(lp + '[') }) } - // Anonymize IP to subnet + // Language params — controlled by language flag + const isLanguageParam = NORMALIZE_PARAMS.language.some(pm => lowerKey === pm.toLowerCase()) + if (isLanguageParam && typeof value === 'string') { + result[key] = p.language ? normalizeLanguage(value) : value + continue + } + + // User-agent params — controlled by userAgent flag + const isUserAgentParam = NORMALIZE_PARAMS.userAgent.some(pm => lowerKey === pm.toLowerCase()) + if (isUserAgentParam && typeof value === 'string') { + result[key] = p.userAgent ? normalizeUserAgent(value) : value + continue + } + + // Anonymize IP to subnet — controlled by ip flag if (matchesParam(key, STRIP_PARAMS.ip) && typeof value === 'string') { - result[key] = anonymizeIP(value) + result[key] = p.ip ? anonymizeIP(value) : value continue } - // Generalize screen to common bucket (with paired width/height awareness) + + // Generalize screen to common bucket (with paired width/height awareness) — screen flag if (matchesParam(key, STRIP_PARAMS.screen)) { + if (!p.screen) { + result[key] = value + continue + } // Color depth and pixel ratio are low-entropy (2-4 distinct values) — keep as-is if (['sd', 'colordepth', 'pixelratio'].includes(lowerKey)) { result[key] = value @@ -346,39 +432,39 @@ export function stripPayloadFingerprinting( } continue } - // Generalize hardware to common bucket + // Generalize hardware to common bucket — screen flag (device capabilities) if (matchesParam(key, STRIP_PARAMS.hardware)) { - result[key] = generalizeHardware(value) + result[key] = p.screen ? generalizeHardware(value) : value continue } - // Generalize version strings to major version + // Generalize version strings to major version — hardware flag if (matchesParam(key, STRIP_PARAMS.version)) { - result[key] = generalizeVersion(value) + result[key] = p.hardware ? generalizeVersion(value) : value continue } - // Generalize browser version lists to major versions + // Generalize browser version lists to major versions — hardware flag if (matchesParam(key, STRIP_PARAMS.browserVersion)) { - result[key] = generalizeBrowserVersions(value) + result[key] = p.hardware ? generalizeBrowserVersions(value) : value continue } - // Generalize timezone + // Generalize timezone — timezone flag if (matchesParam(key, STRIP_PARAMS.location)) { - result[key] = generalizeTimezone(value) + result[key] = p.timezone ? generalizeTimezone(value) : value continue } - // Replace browser data lists with empty value + // Replace browser data lists with empty value — hardware flag if (matchesParam(key, STRIP_PARAMS.browserData)) { - result[key] = Array.isArray(value) ? [] : '' + result[key] = p.hardware ? (Array.isArray(value) ? [] : '') : value continue } - // Replace canvas/webgl/audio fingerprints with empty value + // Replace canvas/webgl/audio fingerprints with empty value — hardware flag if (matchesParam(key, STRIP_PARAMS.canvas)) { - result[key] = typeof value === 'number' ? 0 : typeof value === 'object' ? {} : '' + result[key] = p.hardware ? (typeof value === 'number' ? 0 : typeof value === 'object' ? {} : '') : value continue } - // Anonymize combined device info (parse and generalize components) + // Anonymize combined device info (parse and generalize components) — hardware flag if (matchesParam(key, STRIP_PARAMS.deviceInfo)) { - result[key] = typeof value === 'string' ? anonymizeDeviceInfo(value) : '' + result[key] = p.hardware ? (typeof value === 'string' ? anonymizeDeviceInfo(value) : '') : value continue } // Platform identifiers are low entropy — keep as-is @@ -390,12 +476,12 @@ export function stripPayloadFingerprinting( if (Array.isArray(value)) { result[key] = value.map(item => typeof item === 'object' && item !== null - ? stripPayloadFingerprinting(item as Record) + ? stripPayloadFingerprinting(item as Record, privacy) : item, ) } else if (typeof value === 'object' && value !== null) { - result[key] = stripPayloadFingerprinting(value as Record) + result[key] = stripPayloadFingerprinting(value as Record, privacy) } else { result[key] = value diff --git a/test/e2e/first-party.test.ts b/test/e2e/first-party.test.ts index e56744f1..02b03e5d 100644 --- a/test/e2e/first-party.test.ts +++ b/test/e2e/first-party.test.ts @@ -618,7 +618,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/') && (isAllowedDomain(c.targetUrl, 'google-analytics.com') || isAllowedDomain(c.targetUrl, 'analytics.google.com')) - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -652,7 +652,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/gtm') && isAllowedDomain(c.targetUrl, 'googletagmanager.com') - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -691,7 +691,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/meta') && (isAllowedDomain(c.targetUrl, 'facebook.com') || isAllowedDomain(c.targetUrl, 'facebook.net')) - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -728,7 +728,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/segment') && (isAllowedDomain(c.targetUrl, 'segment.io') || isAllowedDomain(c.targetUrl, 'segment.com')) - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -749,7 +749,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/x') && (isAllowedDomain(c.targetUrl, 'twitter.com') || isAllowedDomain(c.targetUrl, 't.co')) - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -785,7 +785,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/snap') && isAllowedDomain(c.targetUrl, 'snapchat.com') - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -830,7 +830,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/clarity') && isAllowedDomain(c.targetUrl, 'clarity.ms') - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -870,7 +870,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/hotjar') && isAllowedDomain(c.targetUrl, 'hotjar.com') - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -908,7 +908,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/tiktok') && isAllowedDomain(c.targetUrl, 'tiktok.com') - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) @@ -946,7 +946,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/reddit') && isAllowedDomain(c.targetUrl, 'reddit.com') - && c.privacy === 'anonymize', + && typeof c.privacy === 'object' && c.privacy !== null, ) expect(hasValidCapture).toBe(true) diff --git a/test/fixtures/first-party/nuxt.config.ts b/test/fixtures/first-party/nuxt.config.ts index 27ce0d06..4a10810b 100644 --- a/test/fixtures/first-party/nuxt.config.ts +++ b/test/fixtures/first-party/nuxt.config.ts @@ -8,9 +8,7 @@ export default defineNuxtConfig({ compatibilityDate: '2024-07-05', scripts: { - firstParty: { - privacy: 'anonymize', // Test with anonymize mode by default - }, + firstParty: true, // Uses per-script privacy defaults from registry // Wait for SW to be ready before loading scripts that need interception defaultScriptOptions: { trigger: { serviceWorker: true }, diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 7682f767..60cead93 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -174,9 +174,10 @@ describe('proxy configs', () => { const configs = getAllProxyConfigs('/_scripts/c') for (const [key, config] of Object.entries(configs)) { expect(config, `${key} should have routes`).toHaveProperty('routes') - expect(config, `${key} should have rewrite`).toHaveProperty('rewrite') - expect(Array.isArray(config.rewrite), `${key}.rewrite should be an array`).toBe(true) expect(typeof config.routes, `${key}.routes should be an object`).toBe('object') + if (config.rewrite) { + expect(Array.isArray(config.rewrite), `${key}.rewrite should be an array`).toBe(true) + } } }) }) diff --git a/test/unit/proxy-privacy.test.ts b/test/unit/proxy-privacy.test.ts index 712efd81..360e8cd5 100644 --- a/test/unit/proxy-privacy.test.ts +++ b/test/unit/proxy-privacy.test.ts @@ -5,6 +5,8 @@ import { ALLOWED_PARAMS, stripFingerprintingFromPayload, } from '../utils/proxy-privacy' +import { resolvePrivacy, mergePrivacy, stripPayloadFingerprinting } from '../../src/runtime/server/utils/privacy' +import type { ResolvedProxyPrivacy } from '../../src/runtime/server/utils/privacy' /** * Test fingerprinting data that analytics scripts commonly collect and send. @@ -68,7 +70,7 @@ const FINGERPRINT_PAYLOAD = { // X/Twitter Pixel - dv param contains concatenated fingerprinting data xPixel: { bci: '4', // Browser context indicator - dv: 'Australia/Melbourne&en-GB&Google Inc.&Linux x86_64&255&1280&720&24&24&1280&720&0&na', // Combined device fingerprint + dv: 'Australia/Melbourne&en-GB&Google Inc.&Linux x86_64&255&1280&720&24&24&1280&720&0&na', // Combined device hardware eci: '3', // Environment context indicator event: '{}', event_id: 'a944216c-54e2-4dbb-a338-144f32888929', @@ -85,7 +87,7 @@ const FINGERPRINT_PAYLOAD = { }, // Generic fingerprinting vectors - fingerprint: { + hardware: { // Hardware screen: { width: 2560, height: 1440, colorDepth: 24, pixelRatio: 2 }, viewport: { width: 1920, height: 1080 }, @@ -106,14 +108,14 @@ const FINGERPRINT_PAYLOAD = { doNotTrack: null, plugins: ['PDF Viewer', 'Chrome PDF Viewer', 'Chromium PDF Viewer'], - // Canvas fingerprint + // Canvas hardware canvas: 'a1b2c3d4e5f6g7h8i9j0', webgl: { vendor: 'Google Inc. (Apple)', renderer: 'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)', }, - // Audio fingerprint + // Audio hardware audioFingerprint: 124.04347527516074, // Font detection @@ -132,7 +134,7 @@ describe('proxy privacy - payload analysis', () => { describe('GA payload', () => { it('identifies fingerprinting params in GA payload', () => { const gaPayload = FINGERPRINT_PAYLOAD.ga - const fingerprintParams = Object.keys(gaPayload).filter((key) => { + const hardwareParams = Object.keys(gaPayload).filter((key) => { return STRIP_PARAMS.ip.includes(key) || STRIP_PARAMS.userId.includes(key) || STRIP_PARAMS.screen.includes(key) @@ -144,13 +146,13 @@ describe('proxy privacy - payload analysis', () => { || NORMALIZE_PARAMS.userAgent.includes(key) }) - console.warn('GA fingerprinting params found:', fingerprintParams) + console.warn('GA fingerprinting params found:', hardwareParams) console.warn('GA normalized params:', normalizedParams) - expect(fingerprintParams).toContain('uip') // IP - expect(fingerprintParams).toContain('cid') // Client ID - expect(fingerprintParams).toContain('uid') // User ID - expect(fingerprintParams).toContain('sr') // Screen resolution - expect(fingerprintParams).toContain('vp') // Viewport + expect(hardwareParams).toContain('uip') // IP + expect(hardwareParams).toContain('cid') // Client ID + expect(hardwareParams).toContain('uid') // User ID + expect(hardwareParams).toContain('sr') // Screen resolution + expect(hardwareParams).toContain('vp') // Viewport expect(normalizedParams).toContain('ua') // User agent (normalized, not stripped) }) @@ -183,47 +185,47 @@ describe('proxy privacy - payload analysis', () => { describe('Meta pixel payload', () => { it('identifies fingerprinting params in Meta payload', () => { const metaPayload = FINGERPRINT_PAYLOAD.meta - const fingerprintParams: string[] = [] + const hardwareParams: string[] = [] for (const key of Object.keys(metaPayload)) { - if (STRIP_PARAMS.ip.some(p => key.toLowerCase().includes(p.toLowerCase()))) fingerprintParams.push(key) - if (STRIP_PARAMS.userId.some(p => key.toLowerCase() === p.toLowerCase())) fingerprintParams.push(key) - if (STRIP_PARAMS.userData.some(p => key.toLowerCase() === p.toLowerCase())) fingerprintParams.push(key) - if (STRIP_PARAMS.browserData.some(p => key.toLowerCase().includes(p.toLowerCase()))) fingerprintParams.push(key) - if (STRIP_PARAMS.browserVersion.some(p => key.toLowerCase().includes(p.toLowerCase()))) fingerprintParams.push(key) + if (STRIP_PARAMS.ip.some(p => key.toLowerCase().includes(p.toLowerCase()))) hardwareParams.push(key) + if (STRIP_PARAMS.userId.some(p => key.toLowerCase() === p.toLowerCase())) hardwareParams.push(key) + if (STRIP_PARAMS.userData.some(p => key.toLowerCase() === p.toLowerCase())) hardwareParams.push(key) + if (STRIP_PARAMS.browserData.some(p => key.toLowerCase().includes(p.toLowerCase()))) hardwareParams.push(key) + if (STRIP_PARAMS.browserVersion.some(p => key.toLowerCase().includes(p.toLowerCase()))) hardwareParams.push(key) } - console.warn('Meta fingerprinting params found:', fingerprintParams) - expect(fingerprintParams).toContain('client_ip_address') - expect(fingerprintParams).toContain('external_id') - expect(fingerprintParams).toContain('ud') // User data - expect(fingerprintParams).toContain('fbp') // Browser ID - expect(fingerprintParams).toContain('fbc') // Click ID + console.warn('Meta fingerprinting params found:', hardwareParams) + expect(hardwareParams).toContain('client_ip_address') + expect(hardwareParams).toContain('external_id') + expect(hardwareParams).toContain('ud') // User data + expect(hardwareParams).toContain('fbp') // Browser ID + expect(hardwareParams).toContain('fbc') // Click ID }) }) describe('X/Twitter pixel payload', () => { it('identifies fingerprinting params in X pixel payload', () => { const xPayload = FINGERPRINT_PAYLOAD.xPixel - const fingerprintParams: string[] = [] + const hardwareParams: string[] = [] for (const key of Object.keys(xPayload)) { const lowerKey = key.toLowerCase() - if (STRIP_PARAMS.deviceInfo.some(p => lowerKey === p.toLowerCase())) fingerprintParams.push(key) - if (STRIP_PARAMS.userId.some(p => lowerKey === p.toLowerCase())) fingerprintParams.push(key) + if (STRIP_PARAMS.deviceInfo.some(p => lowerKey === p.toLowerCase())) hardwareParams.push(key) + if (STRIP_PARAMS.userId.some(p => lowerKey === p.toLowerCase())) hardwareParams.push(key) } - console.warn('X/Twitter fingerprinting params found:', fingerprintParams) - expect(fingerprintParams).toContain('dv') // Device info - contains timezone, screen, platform etc. + console.warn('X/Twitter fingerprinting params found:', hardwareParams) + expect(hardwareParams).toContain('dv') // Device info - contains timezone, screen, platform etc. // bci/eci are batch/event counters, not fingerprinting — no longer in deviceInfo - expect(fingerprintParams).toContain('pl_id') // Pixel/placement ID - expect(fingerprintParams).toContain('p_user_id') // User ID + expect(hardwareParams).toContain('pl_id') // Pixel/placement ID + expect(hardwareParams).toContain('p_user_id') // User ID }) }) - describe('generic fingerprint payload', () => { + describe('generic hardware payload', () => { it('identifies all fingerprinting vectors', () => { - const fp = FINGERPRINT_PAYLOAD.fingerprint + const fp = FINGERPRINT_PAYLOAD.hardware const vectors: string[] = [] // Check each category @@ -316,7 +318,7 @@ describe('stripFingerprintingFromPayload', () => { }) it('anonymizes fingerprinting vectors but keeps normalized values', () => { - const result = stripFingerprintingFromPayload(FINGERPRINT_PAYLOAD.fingerprint) + const result = stripFingerprintingFromPayload(FINGERPRINT_PAYLOAD.hardware) // Hardware generalized to common buckets expect(result.hardwareConcurrency).toBe(8) @@ -386,3 +388,130 @@ describe('stripFingerprintingFromPayload', () => { }) }) }) + +describe('resolvePrivacy', () => { + it('true → all flags true', () => { + expect(resolvePrivacy(true)).toEqual({ ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true }) + }) + + it('undefined → all flags false (opt-in)', () => { + expect(resolvePrivacy()).toEqual({ ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }) + }) + + it('false → all flags false', () => { + expect(resolvePrivacy(false)).toEqual({ ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }) + }) + + it('partial object → unset flags default to false (opt-in)', () => { + expect(resolvePrivacy({ ip: true })).toEqual({ ip: true, userAgent: false, language: false, screen: false, timezone: false, hardware: false }) + }) + + it('full object → uses provided values', () => { + expect(resolvePrivacy({ ip: true, userAgent: true, language: false, screen: true, timezone: false, hardware: true })) + .toEqual({ ip: true, userAgent: true, language: false, screen: true, timezone: false, hardware: true }) + }) +}) + +describe('mergePrivacy', () => { + const allTrue: ResolvedProxyPrivacy = { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true } + + it('undefined override → returns base', () => { + expect(mergePrivacy(allTrue)).toEqual(allTrue) + }) + + it('boolean override fully replaces', () => { + expect(mergePrivacy(allTrue, false)).toEqual({ ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }) + }) + + it('partial object overrides only specified fields', () => { + expect(mergePrivacy(allTrue, { ip: false })).toEqual({ ip: false, userAgent: true, language: true, screen: true, timezone: true, hardware: true }) + }) + + it('per-script + global override flow', () => { + // Ad pixel declares strict privacy + const metaBase = resolvePrivacy({ ip: true, userAgent: true, language: false, screen: true, timezone: true, hardware: true }) + expect(metaBase).toEqual({ ip: true, userAgent: true, language: false, screen: true, timezone: true, hardware: true }) + + // No global override → per-script used as-is + expect(mergePrivacy(metaBase, undefined)).toEqual(metaBase) + + // User sets global { ip: false } → overrides just ip + expect(mergePrivacy(metaBase, { ip: false })) + .toEqual({ ip: false, userAgent: true, language: false, screen: true, timezone: true, hardware: true }) + + // User sets global true → full anonymize overrides per-script + expect(mergePrivacy(metaBase, true)) + .toEqual({ ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true }) + + // User sets global false → passthrough overrides per-script + expect(mergePrivacy(metaBase, false)) + .toEqual({ ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false }) + }) +}) + +describe('selective privacy in stripPayloadFingerprinting', () => { + const testPayload = { + uip: '192.168.1.100', + ua: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120.0.0.0', + ul: 'en-US,en;q=0.9,fr;q=0.8', + sr: '2560x1440', + hardwareConcurrency: 8, + canvas: 'abc123', + timezone: 'America/New_York', + dt: 'Page Title', + } + + it('ip:false → IP passes through, everything else anonymized', () => { + const privacy: ResolvedProxyPrivacy = { ip: false, userAgent: true, language: true, screen: true, timezone: true, hardware: true } + const result = stripPayloadFingerprinting(testPayload, privacy) + expect(result.uip).toBe('192.168.1.100') // not anonymized + expect(result.ua).toBe('Mozilla/5.0 (compatible; Chrome/120.0)') // normalized + expect(result.sr).toBe('1920x1080') // generalized + }) + + it('screen:false → screen/hardware pass through, canvas/timezone still anonymized', () => { + const privacy: ResolvedProxyPrivacy = { ip: true, userAgent: true, language: true, screen: false, timezone: true, hardware: true } + const result = stripPayloadFingerprinting(testPayload, privacy) + expect(result.uip).toBe('192.168.1.0') // anonymized + expect(result.sr).toBe('2560x1440') // not generalized (screen flag off) + expect(result.hardwareConcurrency).toBe(8) // not bucketed (screen flag off) + expect(result.canvas).toBe('') // stripped (hardware flag on) + expect(result.timezone).toBe('UTC') // generalized (timezone flag on) + }) + + it('timezone:false → timezone passes through', () => { + const privacy: ResolvedProxyPrivacy = { ip: false, userAgent: false, language: false, screen: true, timezone: false, hardware: true } + const result = stripPayloadFingerprinting(testPayload, privacy) + expect(result.timezone).toBe('America/New_York') // not generalized (timezone flag off) + expect(result.sr).toBe('1920x1080') // generalized (screen flag on) + expect(result.canvas).toBe('') // stripped (hardware flag on) + }) + + it('hardware:false → canvas/versions pass through', () => { + const privacy: ResolvedProxyPrivacy = { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: false } + const result = stripPayloadFingerprinting(testPayload, privacy) + expect(result.uip).toBe('192.168.1.0') // anonymized (ip flag on) + expect(result.sr).toBe('1920x1080') // generalized (screen flag on) + expect(result.canvas).toBe('abc123') // not stripped (hardware flag off) + expect(result.timezone).toBe('UTC') // generalized (timezone flag on) + }) + + it('all false → everything passes through', () => { + const privacy: ResolvedProxyPrivacy = { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false } + const result = stripPayloadFingerprinting(testPayload, privacy) + expect(result.uip).toBe('192.168.1.100') + expect(result.ua).toBe(testPayload.ua) + expect(result.ul).toBe('en-US,en;q=0.9,fr;q=0.8') + expect(result.sr).toBe('2560x1440') + expect(result.canvas).toBe('abc123') + expect(result.timezone).toBe('America/New_York') + }) + + it('no privacy arg → defaults to all true (backward compat)', () => { + const result = stripPayloadFingerprinting(testPayload) + expect(result.uip).toBe('192.168.1.0') + expect(result.ua).toBe('Mozilla/5.0 (compatible; Chrome/120.0)') + expect(result.sr).toBe('1920x1080') + expect(result.timezone).toBe('UTC') + }) +}) From ac5f4933ddb4608463aeb1dedf9b367f1a62b433 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 25 Feb 2026 15:05:53 +1100 Subject: [PATCH 2/4] doc: sync --- docs/content/docs/1.guides/2.first-party.md | 2 +- docs/content/scripts/analytics/posthog.md | 2 +- src/runtime/server/proxy-handler.ts | 56 +++++++++++++-------- src/runtime/server/utils/privacy.ts | 12 ++++- test/e2e/first-party.test.ts | 20 ++++---- test/unit/proxy-configs.test.ts | 21 ++++++++ test/unit/proxy-privacy.test.ts | 7 --- 7 files changed, 79 insertions(+), 41 deletions(-) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 5132ce09..5c6ced23 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -115,7 +115,7 @@ export default defineNuxtConfig({ ``` ::callout{type="info"} -When a flag is active, data is **generalized** rather than stripped — analytics endpoints still receive valid data, just with reduced precision. For example, screen resolution `1440x900` becomes `1920x1080` (desktop bucket), and User-Agent is normalized to `Mozilla/5.0 (compatible; Chrome/131.0)`. +When a flag is active, data is either **generalized** (reduced precision) or **redacted** (emptied/zeroed) — analytics endpoints still receive valid data. For example, screen resolution `1440x900` becomes `1920x1080` (desktop bucket) and User-Agent is normalized to `Mozilla/5.0 (compatible; Chrome/131.0)`, while hardware fingerprints like canvas, WebGL, plugins, and fonts are zeroed or cleared. :: ### Custom Paths diff --git a/docs/content/scripts/analytics/posthog.md b/docs/content/scripts/analytics/posthog.md index d18d9235..54371770 100644 --- a/docs/content/scripts/analytics/posthog.md +++ b/docs/content/scripts/analytics/posthog.md @@ -161,7 +161,7 @@ export default defineNuxtConfig({ ## First-Party Proxy -When [first-party mode](/docs/guides/first-party) is enabled, PostHog requests are automatically proxied through your own server. This improves event capture reliability by avoiding ad blockers. No privacy anonymization is applied — PostHog is a trusted, open-source tool that requires full fidelity data for GeoIP enrichment, feature flags, and session replay. +When [first-party mode](/docs/guides/first-party) is enabled, PostHog requests are automatically proxied through your own server. This improves event capture reliability by avoiding ad blockers. No privacy anonymization is applied — PostHog is a trusted, open-source tool that requires full-fidelity data for GeoIP enrichment, feature flags, and session replay. No additional configuration is needed — the module automatically sets `apiHost` to route through your server's proxy endpoint: diff --git a/src/runtime/server/proxy-handler.ts b/src/runtime/server/proxy-handler.ts index 5ca3550e..64da1d07 100644 --- a/src/runtime/server/proxy-handler.ts +++ b/src/runtime/server/proxy-handler.ts @@ -87,14 +87,7 @@ export default defineEventHandler(async (event) => { } } - // Resolve effective privacy: per-script is the base, global user override on top - const perScriptInput = matchedRoutePattern ? routePrivacy[matchedRoutePattern] : undefined - const perScriptResolved = resolvePrivacy(perScriptInput) - // Global override: when set by user, it overrides per-script field-by-field - const privacy = globalPrivacy !== undefined ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved - const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware - - if (!targetBase || !matchedPrefix) { + if (!targetBase || !matchedPrefix || !matchedRoutePattern) { log('[proxy] No match for path:', path) throw createError({ statusCode: 404, @@ -103,6 +96,13 @@ export default defineEventHandler(async (event) => { }) } + // Resolve effective privacy: per-script is the base, global user override on top + const perScriptInput = routePrivacy[matchedRoutePattern] + const perScriptResolved = resolvePrivacy(perScriptInput) + // Global override: when set by user, it overrides per-script field-by-field + const privacy = globalPrivacy !== undefined ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved + const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware + // Build target URL with stripped query params let targetPath = path.slice(matchedPrefix.length) // Ensure path starts with / @@ -142,7 +142,8 @@ export default defineEventHandler(async (event) => { || lowerKey === 'cf-connecting-ip' || lowerKey === 'true-client-ip' || lowerKey === 'x-client-ip' || lowerKey === 'x-cluster-client-ip') { if (privacy.ip) continue // skip — we add anonymized version below - headers[key] = value + // Use lowercase key to avoid duplicate headers with mixed casing + headers[lowerKey] = value continue } @@ -160,28 +161,43 @@ export default defineEventHandler(async (event) => { // Client Hints (hardware flag) if (lowerKey === 'sec-ch-ua' || lowerKey === 'sec-ch-ua-full-version-list') { - headers[key] = privacy.hardware + headers[lowerKey] = privacy.hardware ? value.replace(/;v="(\d+)\.[^"]*"/g, ';v="$1"') : value continue } + // High-entropy client hints — strip when hardware flag active + if (lowerKey === 'sec-ch-ua-platform-version' || lowerKey === 'sec-ch-ua-arch' + || lowerKey === 'sec-ch-ua-model' || lowerKey === 'sec-ch-ua-bitness') { + if (privacy.hardware) continue // strip high-entropy hints + headers[lowerKey] = value + continue + } + // Other client hints (sec-ch-ua-platform, sec-ch-ua-mobile are low entropy) — pass through headers[key] = value } - // IP handling: add x-forwarded-for based on ip flag - const clientIP = getRequestIP(event, { xForwardedFor: true }) - if (clientIP) { - if (privacy.ip) { - // Anonymize IP for country-level geo - headers['x-forwarded-for'] = anonymizeIP(clientIP) - } - else { - // Forward real IP — needed for services like PostHog geolocation - headers['x-forwarded-for'] = clientIP + // IP handling: only set x-forwarded-for if not already copied from the header loop + if (!headers['x-forwarded-for']) { + const clientIP = getRequestIP(event, { xForwardedFor: true }) + if (clientIP) { + if (privacy.ip) { + headers['x-forwarded-for'] = anonymizeIP(clientIP) + } + else { + headers['x-forwarded-for'] = clientIP + } } } + else if (privacy.ip) { + // Anonymize each IP in the existing chain + headers['x-forwarded-for'] = headers['x-forwarded-for'] + .split(',') + .map(ip => anonymizeIP(ip.trim())) + .join(', ') + } // Read and process request body if present let body: string | Record | undefined diff --git a/src/runtime/server/utils/privacy.ts b/src/runtime/server/utils/privacy.ts index 1a71dee1..088e5da0 100644 --- a/src/runtime/server/utils/privacy.ts +++ b/src/runtime/server/utils/privacy.ts @@ -394,8 +394,16 @@ export function stripPayloadFingerprinting( // Language params — controlled by language flag const isLanguageParam = NORMALIZE_PARAMS.language.some(pm => lowerKey === pm.toLowerCase()) - if (isLanguageParam && typeof value === 'string') { - result[key] = p.language ? normalizeLanguage(value) : value + if (isLanguageParam) { + if (Array.isArray(value)) { + result[key] = p.language ? value.map(v => typeof v === 'string' ? normalizeLanguage(v) : v) : value + } + else if (typeof value === 'string') { + result[key] = p.language ? normalizeLanguage(value) : value + } + else { + result[key] = value + } continue } diff --git a/test/e2e/first-party.test.ts b/test/e2e/first-party.test.ts index 02b03e5d..6b384f22 100644 --- a/test/e2e/first-party.test.ts +++ b/test/e2e/first-party.test.ts @@ -618,7 +618,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/') && (isAllowedDomain(c.targetUrl, 'google-analytics.com') || isAllowedDomain(c.targetUrl, 'analytics.google.com')) - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -652,7 +652,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/gtm') && isAllowedDomain(c.targetUrl, 'googletagmanager.com') - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -691,7 +691,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/meta') && (isAllowedDomain(c.targetUrl, 'facebook.com') || isAllowedDomain(c.targetUrl, 'facebook.net')) - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -728,7 +728,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/segment') && (isAllowedDomain(c.targetUrl, 'segment.io') || isAllowedDomain(c.targetUrl, 'segment.com')) - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -749,7 +749,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/x') && (isAllowedDomain(c.targetUrl, 'twitter.com') || isAllowedDomain(c.targetUrl, 't.co')) - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -785,7 +785,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/snap') && isAllowedDomain(c.targetUrl, 'snapchat.com') - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -830,7 +830,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/clarity') && isAllowedDomain(c.targetUrl, 'clarity.ms') - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -870,7 +870,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/hotjar') && isAllowedDomain(c.targetUrl, 'hotjar.com') - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -908,7 +908,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/tiktok') && isAllowedDomain(c.targetUrl, 'tiktok.com') - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) @@ -946,7 +946,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/reddit') && isAllowedDomain(c.targetUrl, 'reddit.com') - && typeof c.privacy === 'object' && c.privacy !== null, + && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', ) expect(hasValidCapture).toBe(true) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 60cead93..0ecabc75 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -168,16 +168,37 @@ describe('proxy configs', () => { expect(configs).toHaveProperty('segment') expect(configs).toHaveProperty('clarity') expect(configs).toHaveProperty('hotjar') + expect(configs).toHaveProperty('xPixel') + expect(configs).toHaveProperty('snapchatPixel') + expect(configs).toHaveProperty('redditPixel') + expect(configs).toHaveProperty('posthog') }) it('all configs have valid structure', () => { const configs = getAllProxyConfigs('/_scripts/c') + const fullAnonymize = ['metaPixel', 'tiktokPixel', 'xPixel', 'snapchatPixel', 'redditPixel'] + const passthrough = ['segment', 'googleTagManager', 'posthog'] for (const [key, config] of Object.entries(configs)) { expect(config, `${key} should have routes`).toHaveProperty('routes') expect(typeof config.routes, `${key}.routes should be an object`).toBe('object') if (config.rewrite) { expect(Array.isArray(config.rewrite), `${key}.rewrite should be an array`).toBe(true) } + // Every config must declare a privacy object + expect(config, `${key} should have privacy`).toHaveProperty('privacy') + expect(typeof config.privacy, `${key}.privacy should be an object`).toBe('object') + + if (fullAnonymize.includes(key)) { + expect(config.privacy, `${key} should be fully anonymized`).toEqual({ + ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true, + }) + } + if (passthrough.includes(key)) { + // All flags should be false (no-op privacy) + for (const flag of Object.values(config.privacy)) { + expect(flag, `${key} privacy flags should be false`).toBe(false) + } + } } }) }) diff --git a/test/unit/proxy-privacy.test.ts b/test/unit/proxy-privacy.test.ts index 360e8cd5..ecf4dd50 100644 --- a/test/unit/proxy-privacy.test.ts +++ b/test/unit/proxy-privacy.test.ts @@ -146,8 +146,6 @@ describe('proxy privacy - payload analysis', () => { || NORMALIZE_PARAMS.userAgent.includes(key) }) - console.warn('GA fingerprinting params found:', hardwareParams) - console.warn('GA normalized params:', normalizedParams) expect(hardwareParams).toContain('uip') // IP expect(hardwareParams).toContain('cid') // Client ID expect(hardwareParams).toContain('uid') // User ID @@ -162,7 +160,6 @@ describe('proxy privacy - payload analysis', () => { return NORMALIZE_PARAMS.language.includes(key) }) - console.warn('GA params to normalize:', normalizeParams) expect(normalizeParams).toContain('ul') // Language }) @@ -174,7 +171,6 @@ describe('proxy privacy - payload analysis', () => { || ALLOWED_PARAMS.time.includes(key) }) - console.warn('GA safe params:', safeParams) expect(safeParams).toContain('dt') // Title expect(safeParams).toContain('dl') // Location expect(safeParams).toContain('dr') // Referrer @@ -195,7 +191,6 @@ describe('proxy privacy - payload analysis', () => { if (STRIP_PARAMS.browserVersion.some(p => key.toLowerCase().includes(p.toLowerCase()))) hardwareParams.push(key) } - console.warn('Meta fingerprinting params found:', hardwareParams) expect(hardwareParams).toContain('client_ip_address') expect(hardwareParams).toContain('external_id') expect(hardwareParams).toContain('ud') // User data @@ -215,7 +210,6 @@ describe('proxy privacy - payload analysis', () => { if (STRIP_PARAMS.userId.some(p => lowerKey === p.toLowerCase())) hardwareParams.push(key) } - console.warn('X/Twitter fingerprinting params found:', hardwareParams) expect(hardwareParams).toContain('dv') // Device info - contains timezone, screen, platform etc. // bci/eci are batch/event counters, not fingerprinting — no longer in deviceInfo expect(hardwareParams).toContain('pl_id') // Pixel/placement ID @@ -244,7 +238,6 @@ describe('proxy privacy - payload analysis', () => { if (fp.fonts) vectors.push('fonts') if (fp.connection) vectors.push('connection') - console.warn('All fingerprinting vectors:', vectors) expect(vectors.length).toBeGreaterThan(10) }) }) From 5321d6735fad89f416d6eccab3d9b4bd2a962afa Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 25 Feb 2026 17:49:25 +1100 Subject: [PATCH 3/4] chore: sync --- docs/content/docs/1.guides/2.first-party.md | 6 +- src/runtime/server/proxy-handler.ts | 8 ++- src/runtime/server/utils/privacy.ts | 13 ++-- test/e2e/first-party.test.ts | 69 ++++++++++++++------- test/unit/proxy-configs.test.ts | 9 ++- 5 files changed, 72 insertions(+), 33 deletions(-) diff --git a/docs/content/docs/1.guides/2.first-party.md b/docs/content/docs/1.guides/2.first-party.md index 5c6ced23..ed4a6dc6 100644 --- a/docs/content/docs/1.guides/2.first-party.md +++ b/docs/content/docs/1.guides/2.first-party.md @@ -60,7 +60,7 @@ Each script in the registry declares its own privacy defaults based on what data | Flag | What it does | |------|-------------| | `ip` | Anonymizes IP addresses to subnet level in headers and payload params | -| `userAgent` | Normalizes User-Agent to browser family + major version (e.g. `Chrome/131.0`) | +| `userAgent` | Normalizes User-Agent to browser family + major version (e.g. `Mozilla/5.0 (compatible; Chrome/131.0)`) | | `language` | Normalizes Accept-Language to primary language tag | | `screen` | Generalizes screen resolution, viewport, hardware concurrency, and device memory to common buckets | | `timezone` | Generalizes timezone offset and IANA timezone names | @@ -84,10 +84,10 @@ Scripts declare the privacy that makes sense for their use case: | Segment | - | - | - | - | - | - | Trusted data pipeline — full fidelity required | | PostHog | - | - | - | - | - | - | Trusted, open-source — full fidelity required | | Microsoft Clarity | ✓ | - | ✓ | - | - | ✓ | UA/screen/timezone needed for heatmaps and device filtering | -| Hotjar | ✓ | - | ✓ | - | - | ✓ | UA/screen/timezone needed for heatmaps and device filtering | - ✓ = anonymized, - = passed through +| Hotjar | ✓ | - | ✓ | - | - | ✓ | UA/screen/timezone needed for heatmaps and device filtering | + #### Global Override Override all per-script defaults at once: diff --git a/src/runtime/server/proxy-handler.ts b/src/runtime/server/proxy-handler.ts index 64da1d07..714ddea3 100644 --- a/src/runtime/server/proxy-handler.ts +++ b/src/runtime/server/proxy-handler.ts @@ -38,7 +38,7 @@ function stripQueryFingerprinting( const params = new URLSearchParams() for (const [key, value] of Object.entries(stripped)) { if (value !== undefined && value !== null) { - params.set(key, String(value)) + params.set(key, typeof value === 'object' ? JSON.stringify(value) : String(value)) } } return params.toString() @@ -97,8 +97,12 @@ export default defineEventHandler(async (event) => { } // Resolve effective privacy: per-script is the base, global user override on top + // Fail-closed: missing per-script entry → full anonymization (most restrictive) const perScriptInput = routePrivacy[matchedRoutePattern] - const perScriptResolved = resolvePrivacy(perScriptInput) + if (debug && perScriptInput === undefined) { + log('[proxy] WARNING: No privacy config for route', matchedRoutePattern, '— defaulting to full anonymization') + } + const perScriptResolved = resolvePrivacy(perScriptInput ?? true) // Global override: when set by user, it overrides per-script field-by-field const privacy = globalPrivacy !== undefined ? mergePrivacy(perScriptResolved, globalPrivacy) : perScriptResolved const anyPrivacy = privacy.ip || privacy.userAgent || privacy.language || privacy.screen || privacy.timezone || privacy.hardware diff --git a/src/runtime/server/utils/privacy.ts b/src/runtime/server/utils/privacy.ts index 088e5da0..9f5a2697 100644 --- a/src/runtime/server/utils/privacy.ts +++ b/src/runtime/server/utils/privacy.ts @@ -21,11 +21,16 @@ export interface ProxyPrivacy { * Privacy input: `true` = full anonymize, `false` = passthrough (still strips sensitive headers), * or a `ProxyPrivacy` object for granular control (unset flags default to `false` — opt-in). */ -export type ProxyPrivacyInput = boolean | ProxyPrivacy +export type ProxyPrivacyInput = boolean | ProxyPrivacy | null /** Resolved privacy with all flags explicitly set. */ export type ResolvedProxyPrivacy = Required +/** Full anonymization — all flags true. Used as fail-closed default. */ +const FULL_PRIVACY: ResolvedProxyPrivacy = { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true } +/** Passthrough — all flags false. */ +const NO_PRIVACY: ResolvedProxyPrivacy = { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false } + /** * Normalize a privacy input to a fully-resolved object. * Privacy is opt-in: unset object flags default to `false`. @@ -35,8 +40,8 @@ export type ResolvedProxyPrivacy = Required * - `{ ip: true, hardware: true }` → only those active, rest off */ export function resolvePrivacy(input?: ProxyPrivacyInput): ResolvedProxyPrivacy { - if (input === true) return { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true } - if (input === false || input === undefined || input === null) return { ip: false, userAgent: false, language: false, screen: false, timezone: false, hardware: false } + if (input === true) return { ...FULL_PRIVACY } + if (input === false || input === undefined || input === null) return { ...NO_PRIVACY } return { ip: input.ip ?? false, userAgent: input.userAgent ?? false, @@ -367,7 +372,7 @@ export function stripPayloadFingerprinting( payload: Record, privacy?: ResolvedProxyPrivacy, ): Record { - const p = privacy || { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: true } + const p = privacy || FULL_PRIVACY const result: Record = {} // Pre-scan for screen width to enable paired height bucketing. diff --git a/test/e2e/first-party.test.ts b/test/e2e/first-party.test.ts index 6b384f22..71f82a78 100644 --- a/test/e2e/first-party.test.ts +++ b/test/e2e/first-party.test.ts @@ -102,6 +102,15 @@ const _PRESERVED_USER_PARAMS = [ 'ud', 'user_data', 'userdata', 'email', 'phone', ] +/** Check that a capture has a fully-resolved privacy object with all six boolean flags. */ +function hasResolvedPrivacy(c: Record): boolean { + const p = c.privacy + return p && typeof p === 'object' + && typeof p.ip === 'boolean' && typeof p.userAgent === 'boolean' + && typeof p.language === 'boolean' && typeof p.screen === 'boolean' + && typeof p.timezone === 'boolean' && typeof p.hardware === 'boolean' +} + /** * Verify that fingerprinting parameters are anonymized (not forwarded as-is). * Checks that known fingerprinting params, when present, have been transformed @@ -114,10 +123,26 @@ function verifyFingerprintingAnonymized(capture: Record): string[] const strippedQuery = capture.stripped?.query || {} const strippedBody = capture.stripped?.body || {} - // Values considered already-anonymized (empty/zeroed) — not a leak even if unchanged - const isAnonymizedValue = (v: unknown) => - v === '' || v === 0 || (Array.isArray(v) && v.length === 0) - || (typeof v === 'object' && v !== null && !Array.isArray(v) && Object.keys(v).length === 0) + // Values considered already-anonymized — not a leak even if unchanged + const isAnonymizedValue = (v: unknown): boolean => { + if (v === '' || v === 0 || (Array.isArray(v) && v.length === 0)) return true + if (typeof v === 'object' && v !== null && !Array.isArray(v) && Object.keys(v).length === 0) return true + if (typeof v === 'string') { + // Screen bucket patterns (e.g. "1920x1080", "1280x720") + if (/^\d{3,4}x\d{3,4}$/.test(v)) return true + // Normalized UA patterns (e.g. "Mozilla/5.0 (compatible; Chrome/131.0)") + if (v.startsWith('Mozilla/5.0 (compatible')) return true + // Major-only version (e.g. "90", "131.0") + if (/^\d+(\.\d)?$/.test(v)) return true + // Timezone names (IANA zones or UTC) + if (v === 'UTC' || /^[A-Z][a-z]+\/[A-Z]/.test(v)) return true + } + if (typeof v === 'number') { + // Bucketed numeric values (common screen widths, heights, concurrency) + if ([320, 375, 414, 768, 1024, 1280, 1366, 1440, 1920, 2560, 3840].includes(v)) return true + } + return false + } for (const param of ANONYMIZED_FINGERPRINT_PARAMS) { // Only check params present in both original and stripped @@ -454,11 +479,9 @@ describe('first-party privacy stripping', () => { data: e.data, })) - // Write debug info - writeFileSync(join(fixtureDir, 'proxy-test.json'), JSON.stringify(response, null, 2)) - // Should return JS content (or at least not 404) if (typeof response === 'object' && response.error) { + writeFileSync(join(fixtureDir, 'proxy-test.json'), JSON.stringify(response, null, 2)) console.warn('[test] Proxy error:', response) } expect(typeof response).toBe('string') @@ -497,11 +520,13 @@ describe('first-party privacy stripping', () => { } }) - // Debug output - writeFileSync(join(fixtureDir, 'sw-status.json'), JSON.stringify({ - swStatus, - swLogs: swLogs.filter(l => l.includes('SW') || l.includes('service') || l.includes('worker')), - }, null, 2)) + // Debug output — only on failure + if (!swStatus.supported || !swStatus.registrations?.length) { + writeFileSync(join(fixtureDir, 'sw-status.json'), JSON.stringify({ + swStatus, + swLogs: swLogs.filter(l => l.includes('SW') || l.includes('service') || l.includes('worker')), + }, null, 2)) + } expect(swStatus.supported).toBe(true) expect(swStatus.registrations.length).toBeGreaterThan(0) @@ -618,7 +643,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/') && (isAllowedDomain(c.targetUrl, 'google-analytics.com') || isAllowedDomain(c.targetUrl, 'analytics.google.com')) - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -652,7 +677,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/gtm') && isAllowedDomain(c.targetUrl, 'googletagmanager.com') - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -691,7 +716,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/meta') && (isAllowedDomain(c.targetUrl, 'facebook.com') || isAllowedDomain(c.targetUrl, 'facebook.net')) - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -728,7 +753,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/segment') && (isAllowedDomain(c.targetUrl, 'segment.io') || isAllowedDomain(c.targetUrl, 'segment.com')) - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -749,7 +774,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/x') && (isAllowedDomain(c.targetUrl, 'twitter.com') || isAllowedDomain(c.targetUrl, 't.co')) - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -785,7 +810,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/snap') && isAllowedDomain(c.targetUrl, 'snapchat.com') - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -830,7 +855,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/clarity') && isAllowedDomain(c.targetUrl, 'clarity.ms') - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -870,7 +895,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/hotjar') && isAllowedDomain(c.targetUrl, 'hotjar.com') - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -908,7 +933,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/tiktok') && isAllowedDomain(c.targetUrl, 'tiktok.com') - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) @@ -946,7 +971,7 @@ describe('first-party privacy stripping', () => { const hasValidCapture = captures.some(c => c.path?.startsWith('/_proxy/reddit') && isAllowedDomain(c.targetUrl, 'reddit.com') - && c.privacy && typeof c.privacy === 'object' && typeof c.privacy.ip === 'boolean' && typeof c.privacy.userAgent === 'boolean' && typeof c.privacy.language === 'boolean' && typeof c.privacy.screen === 'boolean' && typeof c.privacy.timezone === 'boolean' && typeof c.privacy.hardware === 'boolean', + && hasResolvedPrivacy(c), ) expect(hasValidCapture).toBe(true) diff --git a/test/unit/proxy-configs.test.ts b/test/unit/proxy-configs.test.ts index 0ecabc75..50253e48 100644 --- a/test/unit/proxy-configs.test.ts +++ b/test/unit/proxy-configs.test.ts @@ -184,9 +184,14 @@ describe('proxy configs', () => { if (config.rewrite) { expect(Array.isArray(config.rewrite), `${key}.rewrite should be an array`).toBe(true) } - // Every config must declare a privacy object - expect(config, `${key} should have privacy`).toHaveProperty('privacy') + // Every config must declare a non-null privacy object with all six boolean flags + expect(config.privacy, `${key} should have privacy`).toBeDefined() + expect(config.privacy, `${key}.privacy should not be null`).not.toBeNull() expect(typeof config.privacy, `${key}.privacy should be an object`).toBe('object') + const privacyFlags = ['ip', 'userAgent', 'language', 'screen', 'timezone', 'hardware'] as const + for (const flag of privacyFlags) { + expect(typeof config.privacy[flag], `${key}.privacy.${flag} should be boolean`).toBe('boolean') + } if (fullAnonymize.includes(key)) { expect(config.privacy, `${key} should be fully anonymized`).toEqual({ From 958ab25aa38114a72e32fd618d78f08b6df9cc75 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 25 Feb 2026 17:52:01 +1100 Subject: [PATCH 4/4] chore: sync --- test/e2e/first-party.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/first-party.test.ts b/test/e2e/first-party.test.ts index 71f82a78..2b6c1cbd 100644 --- a/test/e2e/first-party.test.ts +++ b/test/e2e/first-party.test.ts @@ -133,7 +133,7 @@ function verifyFingerprintingAnonymized(capture: Record): string[] // Normalized UA patterns (e.g. "Mozilla/5.0 (compatible; Chrome/131.0)") if (v.startsWith('Mozilla/5.0 (compatible')) return true // Major-only version (e.g. "90", "131.0") - if (/^\d+(\.\d)?$/.test(v)) return true + if (/^\d+(?:\.\d)?$/.test(v)) return true // Timezone names (IANA zones or UTC) if (v === 'UTC' || /^[A-Z][a-z]+\/[A-Z]/.test(v)) return true }