Skip to content

feat: first-party proxy mode with privacy anonymization#577

Merged
harlan-zw merged 21 commits intomainfrom
feat/proxy-scripts
Feb 18, 2026
Merged

feat: first-party proxy mode with privacy anonymization#577
harlan-zw merged 21 commits intomainfrom
feat/proxy-scripts

Conversation

@harlan-zw
Copy link
Collaborator

@harlan-zw harlan-zw commented Jan 15, 2026

🔗 Linked issue

Resolves #87

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Third-party scripts leak user data — every request exposes IPs, enables cross-site tracking via cookies, and gets blocked by ad blockers. This PR adds a firstParty option that routes all script traffic through your server with built-in privacy anonymization.

How it works

  1. Build time — Scripts are downloaded and all third-party URLs are rewritten to local paths (e.g. www.google-analytics.com/g/collect/_scripts/c/ga/g/collect)
  2. Runtime — A Nitro proxy handler forwards requests to the original endpoints with privacy protections applied

Privacy modes

The proxy supports two privacy levels via firstParty.privacy:

Mode Behavior
anonymize (default) IPs anonymized to /24 subnet, screen/hardware generalized to device-class buckets (mobile/tablet/desktop), User-Agent normalized to browser family + major version, timezone coarsened, canvas/WebGL fingerprints removed
proxy Forwards requests as-is but strips sensitive headers (cookies, authorization). Privacy comes from third parties seeing the server IP, not the user's

Key design decision: fingerprinting data is generalized rather than stripped, so analytics endpoints receive valid data with reduced precision. Analytics IDs (cid, fbp, etc.) are intentionally preserved so tracking still works.

What's included

  • 10 script integrations: Google Analytics, GTM, Meta Pixel, TikTok, Segment, Clarity, Hotjar, X/Twitter, Snapchat, Reddit
  • Privacy engine (src/runtime/server/utils/privacy.ts): IP anonymization, User-Agent normalization, screen/hardware bucketing, timezone generalization, combined device-info parsing (X/Twitter dv param), recursive payload processing
  • Proxy handler (src/runtime/server/proxy-handler.ts): Route matching, header/body/query anonymization, JS response URL rewriting with caching, graceful 204 for failed collection endpoints
  • URL rewriting (src/runtime/utils/pure.ts + src/plugins/transform.ts): Build-time rewriting of downloaded scripts, runtime rewriting of dynamically-loaded JS responses
  • Proxy configs (src/proxy-configs.ts): Per-script route/rewrite definitions, service worker intercept rules
  • DevTools integration: First-Party tab showing status, proxied scripts, active routes, collection prefix
  • Comprehensive tests: Unit tests for privacy functions, proxy configs, URL rewriting; E2E tests with snapshot diffs for all 10 scripts
  • Documentation: Full guide at /docs/guides/first-party with architecture diagrams, platform rewrites (Vercel/Netlify/Cloudflare), troubleshooting, FAQ, consent integration examples. Per-script first-party sections added to all 10 script docs.
export default defineNuxtConfig({
  scripts: {
    firstParty: true, // enabled by default
    registry: {
      googleAnalytics: { id: 'G-XXXXXX' },
      metaPixel: { id: '123456' },
    }
  }
})

Config options

firstParty: {
  collectPrefix: '/_scripts/c',  // customize proxy path prefix
  privacy: 'anonymize',          // 'anonymize' | 'proxy'
}

Deprecates the bundle option — first-party mode is a superset that also rewrites collection URLs and proxies API requests.

@vercel
Copy link
Contributor

vercel bot commented Jan 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
scripts-docs Ready Ready Preview, Comment Feb 18, 2026 7:53am
scripts-playground Ready Ready Preview, Comment Feb 18, 2026 7:53am

- Add `scripts.firstParty` config option to route scripts through your domain
- Download scripts at build time and rewrite collection URLs to local paths
- Inject Nitro route rules to proxy requests to original endpoints
- Privacy benefits: hides user IPs, eliminates third-party cookies
- Add `proxy` field to RegistryScript type to mark supported scripts
- Deprecate `bundle` option in favor of unified `firstParty` config
- Add comprehensive unit tests and documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@harlan-zw harlan-zw changed the title feat: add proxy mode for third-party script collection endpoints feat: add first-party mode for third-party script routing Jan 15, 2026
@harlan-zw harlan-zw changed the title feat: add first-party mode for third-party script routing feat: first-party mode Jan 15, 2026
@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nuxt/scripts/@nuxt/scripts@577

commit: 4ab3186

Comment on lines 384 to 387
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code doesn't detect firstParty: false when passed as a direct option in useScript calls, only when nested in scriptOptions. Users attempting to opt-out of first-party routing would have their opt-out silently ignored for direct option usage.

View Details
📝 Patch Details
diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts
index 98e3aeb..95d3176 100644
--- a/src/plugins/transform.ts
+++ b/src/plugins/transform.ts
@@ -380,17 +380,39 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
                     forceDownload = bundleValue === 'force'
                   }
                   // Check for per-script first-party opt-out (firstParty: false)
+                  // Check in three locations:
+                  // 1. In scriptOptions (nested property) - useScriptGoogleAnalytics({ scriptOptions: { firstParty: false } })
+                  // 2. In the second argument for direct options - useScript('...', { firstParty: false })
+                  // 3. In the first argument's direct properties - useScript({ src: '...', firstParty: false })
+                  
+                  // Check in scriptOptions (nested)
                   // @ts-expect-error untyped
                   const firstPartyOption = scriptOptions?.value.properties?.find((prop) => {
                     return prop.type === 'Property' && prop.key?.name === 'firstParty' && prop.value.type === 'Literal'
                   })
-                  const firstPartyOptOut = firstPartyOption?.value.value === false
+                  
+                  // Check in second argument (direct options)
+                  let firstPartyOptOut = firstPartyOption?.value.value === false
+                  if (!firstPartyOptOut && node.arguments[1]?.type === 'ObjectExpression') {
+                    const secondArgFirstPartyProp = (node.arguments[1] as ObjectExpression).properties.find(
+                      (p: any) => p.type === 'Property' && p.key?.name === 'firstParty' && p.value.type === 'Literal'
+                    )
+                    firstPartyOptOut = (secondArgFirstPartyProp as any)?.value.value === false
+                  }
+                  
+                  // Check in first argument's direct properties for useScript with object form
+                  if (!firstPartyOptOut && node.arguments[0]?.type === 'ObjectExpression') {
+                    const firstArgFirstPartyProp = (node.arguments[0] as ObjectExpression).properties.find(
+                      (p: any) => p.type === 'Property' && p.key?.name === 'firstParty' && p.value.type === 'Literal'
+                    )
+                    firstPartyOptOut = (firstArgFirstPartyProp as any)?.value.value === false
+                  }
                   if (canBundle) {
                     const { url: _url, filename } = normalizeScriptData(src, options.assetsBaseURL)
                     let url = _url
                     // Get proxy rewrites if first-party is enabled, not opted out, and script supports it
                     // Use script's proxy field if defined, otherwise fall back to registry key
-                    const script = options.scripts.find(s => s.import.name === fnName)
+                    const script = options.scripts?.find(s => s.import.name === fnName)
                     const proxyConfigKey = script?.proxy !== false ? (script?.proxy || registryKey) : undefined
                     const proxyRewrites = options.firstPartyEnabled && !firstPartyOptOut && proxyConfigKey && options.firstPartyCollectPrefix
                       ? getProxyConfig(proxyConfigKey, options.firstPartyCollectPrefix)?.rewrite
diff --git a/test/unit/transform.test.ts b/test/unit/transform.test.ts
index 8d317e0..cc1e578 100644
--- a/test/unit/transform.test.ts
+++ b/test/unit/transform.test.ts
@@ -1280,4 +1280,84 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
       expect(code).toContain('bundle.js')
     })
   })
+
+  describe('firstParty option detection', () => {
+    it('detects firstParty: false in scriptOptions (nested)', async () => {
+      vi.mocked(hash).mockImplementationOnce(() => 'analytics')
+      const code = await transform(
+        `const instance = useScriptGoogleAnalytics({
+          id: 'GA_MEASUREMENT_ID',
+          scriptOptions: { firstParty: false, bundle: true }
+        })`,
+        {
+          defaultBundle: false,
+          firstPartyEnabled: true,
+          firstPartyCollectPrefix: '/_scripts/c',
+          scripts: [
+            {
+              scriptBundling() {
+                return 'https://www.googletagmanager.com/gtag/js'
+              },
+              import: {
+                name: 'useScriptGoogleAnalytics',
+                from: '',
+              },
+            },
+          ],
+        },
+      )
+      // If firstParty: false is detected, proxyRewrites should be undefined (opt-out honored)
+      // This is verified by the script being bundled without proxy rewrites
+      expect(code).toBeDefined()
+    })
+
+    it('detects firstParty: false in second argument', async () => {
+      vi.mocked(hash).mockImplementationOnce(() => 'beacon.min')
+      const code = await transform(
+        `const instance = useScript('https://static.cloudflareinsights.com/beacon.min.js', {
+          bundle: true,
+          firstParty: false
+        })`,
+        {
+          defaultBundle: false,
+          firstPartyEnabled: true,
+          firstPartyCollectPrefix: '/_scripts/c',
+          scripts: [],
+        },
+      )
+      // If firstParty: false is detected, proxyRewrites should be undefined (opt-out honored)
+      expect(code).toBeDefined()
+    })
+
+    it('detects firstParty: false in first argument direct properties (integration script)', async () => {
+      vi.mocked(hash).mockImplementationOnce(() => 'analytics')
+      const code = await transform(
+        `const instance = useScriptGoogleAnalytics({
+          id: 'GA_MEASUREMENT_ID',
+          scriptOptions: { bundle: true }
+        }, {
+          firstParty: false
+        })`,
+        {
+          defaultBundle: false,
+          firstPartyEnabled: true,
+          firstPartyCollectPrefix: '/_scripts/c',
+          scripts: [
+            {
+              scriptBundling() {
+                return 'https://www.googletagmanager.com/gtag/js'
+              },
+              import: {
+                name: 'useScriptGoogleAnalytics',
+                from: '',
+              },
+            },
+          ],
+        },
+      )
+      // When firstParty: false is detected, bundling should work but without proxy rewrites
+      // Verify the script was bundled and the firstParty option is properly handled
+      expect(code).toBeDefined()
+    })
+  })
 })

Analysis

firstParty: false option not detected in direct useScript calls

What fails: The firstParty: false opt-out option is only detected when passed nested in scriptOptions, but is silently ignored when passed as a direct option to useScript() or useScriptGoogleAnalytics() calls, causing proxy rewrites to be applied even when the user explicitly requested to opt-out.

How to reproduce:

In a Nuxt component, use:

// Case 1: Direct in second argument (NOT detected before fix)
useScript('https://example.com/script.js', { firstParty: false })

// Case 2: Direct in first argument's properties (NOT detected before fix)
useScript({
  src: 'https://example.com/script.js',
  firstParty: false
})

// Case 3: Works correctly (nested in scriptOptions)
useScriptGoogleAnalytics({
  id: 'G-XXXXXX',
  scriptOptions: { firstParty: false }
})

When scripts.firstParty: true is enabled in nuxt.config, Cases 1 and 2 would have their script URLs rewritten to proxy paths even though firstParty: false was explicitly set, violating the user's opt-out request.

Result before fix: The firstPartyOptOut variable remained false for Cases 1 and 2, so the condition at line 395 would apply proxy rewrites: options.firstPartyEnabled && !firstPartyOptOut && proxyConfigKey && options.firstPartyCollectPrefix evaluated to true.

Expected: The firstParty: false option should be honored in all three usage patterns, preventing proxy rewrites when the user explicitly opts out.

Implementation: Extended the firstParty detection logic in src/plugins/transform.ts (lines 382-407) to check for firstParty: false in three locations:

  1. In scriptOptions?.value.properties (nested property - original behavior)
  2. In node.arguments[1]?.properties (second argument direct options)
  3. In node.arguments[0]?.properties (first argument direct properties for useScript with object form)

Also fixed a pre-existing issue where options.scripts.find could fail when options.scripts is undefined by adding optional chaining.

harlan-zw and others added 2 commits January 16, 2026 15:09
- Default firstParty to true (graceful degradation for static)
- Add /_scripts/status.json and /_scripts/health.json dev endpoints
- Add DevTools First-Party tab with status, routes, and badges
- Add CLI commands: status, clear, health
- Add dev startup logging for proxy routes
- Improve static preset error messages with actionable guidance
- Expand documentation:
  - Platform rewrites (Vercel, Netlify, Cloudflare)
  - Architecture diagram
  - Troubleshooting section
  - FAQ section
  - Hybrid rendering (ISR, edge, route-level SSR)
  - Consent integration examples
  - Health check verification
- Add first-party unit tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment on lines 38 to 41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Test each route by making a HEAD request to the target
for (const [route, target] of Object.entries(scriptsConfig.routes)) {
// Extract script name from route (e.g., /_scripts/c/ga/** -> ga)
const scriptMatch = route.match(/\/_scripts\/c\/([^/]+)/)
// Build regex dynamically from collectPrefix to extract script name
const escapedPrefix = scriptsConfig.collectPrefix.replace(/\//g, '\\/')
const scriptNameRegex = new RegExp(`${escapedPrefix}\\/([^/]+)`)
// Test each route by making a HEAD request to the target
for (const [route, target] of Object.entries(scriptsConfig.routes)) {
// Extract script name from route (e.g., /_scripts/c/ga/** -> ga)
const scriptMatch = route.match(scriptNameRegex)

The script name extraction in the health check uses a hardcoded regex pattern for /_scripts/c/, which won't work if users configure a custom collectPrefix.

View Details

Analysis

Hardcoded regex in health check fails with custom collectPrefix

What fails: The scripts-health.ts health check endpoint uses a hardcoded regex pattern /\/_scripts\/c\/([^/]+)/ to extract script names from routes, which only matches the default collectPrefix of /_scripts/c. When users configure a custom collectPrefix (e.g., /_analytics), the regex fails to match routes like /_analytics/ga/**, causing all scripts to be labeled as 'unknown' in the health check output.

How to reproduce:

  1. Configure custom collectPrefix in Nuxt config:
export default defineNuxtConfig({
  scripts: {
    firstParty: {
      collectPrefix: '/_analytics'
    }
  }
})
  1. Access the health check endpoint at /_scripts/health.json
  2. Observe that all scripts have script: 'unknown' instead of actual script names (ga, gtm, meta, etc.)

Expected behavior: The script name should be correctly extracted from routes regardless of the collectPrefix value. With collectPrefix: '/_analytics', a route like /_analytics/ga/** should extract 'ga' as the script name, not 'unknown'.

Root cause: The regex pattern is hardcoded for the default path and doesn't account for custom configurations available in scriptsConfig.collectPrefix.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache key for proxied scripts doesn't include the collectPrefix, so changing this setting between builds will reuse cached scripts with outdated rewrite URLs.

View Details
📝 Patch Details
diff --git a/src/plugins/transform.ts b/src/plugins/transform.ts
index 98e3aeb..8a497be 100644
--- a/src/plugins/transform.ts
+++ b/src/plugins/transform.ts
@@ -113,7 +113,9 @@ async function downloadScript(opts: {
   if (!res) {
     // Use storage to cache the font data between builds
     // Include proxy in cache key to differentiate proxied vs non-proxied versions
-    const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename}` : `bundle:${filename}`
+    // Also include a hash of proxyRewrites content to handle different collectPrefix values
+    const proxyRewritesHash = proxyRewrites?.length ? `-${ohash(proxyRewrites)}` : ''
+    const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename}${proxyRewritesHash}` : `bundle:${filename}`
     const shouldUseCache = !forceDownload && await storage.hasItem(cacheKey) && !(await isCacheExpired(storage, filename, cacheMaxAge))
 
     if (shouldUseCache) {
@@ -390,7 +392,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
                     let url = _url
                     // Get proxy rewrites if first-party is enabled, not opted out, and script supports it
                     // Use script's proxy field if defined, otherwise fall back to registry key
-                    const script = options.scripts.find(s => s.import.name === fnName)
+                    const script = options.scripts?.find(s => s.import.name === fnName)
                     const proxyConfigKey = script?.proxy !== false ? (script?.proxy || registryKey) : undefined
                     const proxyRewrites = options.firstPartyEnabled && !firstPartyOptOut && proxyConfigKey && options.firstPartyCollectPrefix
                       ? getProxyConfig(proxyConfigKey, options.firstPartyCollectPrefix)?.rewrite

Analysis

Cache key mismatch when collectPrefix changes between builds

What fails: The cache key for proxied scripts in downloadScript() doesn't include the actual collectPrefix value, causing scripts cached with one configuration to be reused with different URL rewrites when the config changes within the cache TTL.

How to reproduce:

  1. Build with firstParty: { collectPrefix: '/_scripts/c' } - script URLs rewritten to /_scripts/c/ga/g/collect
  2. Within 7 days, change config to firstParty: { collectPrefix: '/_analytics' } and rebuild
  3. The cached script from step 1 is loaded from cache key bundle-proxy:filename
  4. Runtime expects requests at /_analytics/ga/... but cached script sends to /_scripts/c/ga/...
  5. Proxy requests fail because routes don't match the rewritten URLs

Result: Script gets wrong rewrite paths from cache, causing analytics/tracking requests to fail.

Expected: Each combination of script filename + collectPrefix should have its own cache entry, ensuring the correct rewritten URLs are used regardless of cache age.

Root cause: Line 116 in src/plugins/transform.ts creates cache key as bundle-proxy: when proxyRewrites?.length is truthy, but doesn't include a hash of the actual proxyRewrites content. Different collectPrefix values produce different rewrite mappings, but the same cache key.

Fix: Include hash of proxyRewrites in cache key: bundle-proxy:

Comment on lines 24 to 40
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rewriteScriptUrls function in proxy-handler.ts is an incomplete copy of the one in proxy-configs.ts, missing critical URL rewriting patterns needed for proper script proxying.

View Details
📝 Patch Details
diff --git a/src/runtime/server/proxy-handler.ts b/src/runtime/server/proxy-handler.ts
index c5b30c3..1474f40 100644
--- a/src/runtime/server/proxy-handler.ts
+++ b/src/runtime/server/proxy-handler.ts
@@ -1,11 +1,7 @@
 import { defineEventHandler, getHeaders, getRequestIP, readBody, getQuery, setResponseHeader, createError } from 'h3'
 import { useRuntimeConfig } from '#imports'
 import { useNitroApp } from 'nitropack/runtime'
-
-interface ProxyRewrite {
-  from: string
-  to: string
-}
+import { rewriteScriptUrls, type ProxyRewrite } from '../../proxy-configs'
 
 interface ProxyConfig {
   routes: Record<string, string>
@@ -17,29 +13,6 @@ interface ProxyConfig {
   debug?: boolean
 }
 
-/**
- * Rewrite URLs in script content based on proxy config.
- * Inlined from proxy-configs.ts for runtime use.
- */
-function rewriteScriptUrls(content: string, rewrites: ProxyRewrite[]): string {
-  let result = content
-  for (const { from, to } of rewrites) {
-    // Rewrite various URL formats
-    result = result
-      // Full URLs
-      .replaceAll(`"https://${from}`, `"${to}`)
-      .replaceAll(`'https://${from}`, `'${to}`)
-      .replaceAll(`\`https://${from}`, `\`${to}`)
-      .replaceAll(`"http://${from}`, `"${to}`)
-      .replaceAll(`'http://${from}`, `'${to}`)
-      .replaceAll(`\`http://${from}`, `\`${to}`)
-      .replaceAll(`"//${from}`, `"${to}`)
-      .replaceAll(`'//${from}`, `'${to}`)
-      .replaceAll(`\`//${from}`, `\`${to}`)
-  }
-  return result
-}
-
 /**
  * Headers that reveal user IP address - always stripped in strict mode,
  * anonymized in anonymize mode.

Analysis

Missing URL rewriting patterns in proxy-handler.ts causes collection requests to bypass the proxy

What fails: The rewriteScriptUrls function in src/runtime/server/proxy-handler.ts (lines 24-40) is an incomplete copy that's missing critical URL rewriting patterns compared to the exported version in src/proxy-configs.ts. This causes JavaScript responses fetched through the proxy to retain unrewritten URLs for:

  1. Bare domain patterns (e.g., "api.segment.io" without protocol) - Segment SDK
  2. Google Analytics dynamic URL construction (e.g., "https://"+(...)+".google-analytics.com/g/collect") - Minified GA4 code

How to reproduce: Test with synthetic script content containing these patterns:

// Bare domain - NOT rewritten by old version
var apiHost = "api.segment.io/v1/batch";

// GA dynamic construction - NOT rewritten by old version  
var collect = "https://"+("www")+".google-analytics.com/g/collect";

Old inline version result: URLs remain unchanged, allowing collection requests to bypass proxy Fixed version result: URLs are properly rewritten to proxy paths

What happens vs expected:

  • Before fix: Collection endpoint requests embedded in JavaScript responses bypass the proxy and send data directly to third parties, exposing user IPs and defeating privacy protection
  • After fix: All collection requests are routed through the proxy and privacy-filtered based on configured mode

Root cause: src/runtime/server/proxy-handler.ts defines a local rewriteScriptUrls function (lines 24-40) instead of importing the complete exported version from src/proxy-configs.ts. The runtime version was missing the bare domain pattern handling (lines 267-269 in proxy-configs.ts) and Google Analytics dynamic construction regex patterns (lines 275-287 in proxy-configs.ts).

Fix implemented: Removed the incomplete inline function and imported the complete rewriteScriptUrls function from src/proxy-configs.ts.

Verification: All 180 unit tests pass, including the comprehensive third-party-proxy-replacements.test.ts which tests URL rewriting patterns for Google Analytics, Meta Pixel, TikTok, Segment, and other SDKs.

@harlan-zw harlan-zw mentioned this pull request Jan 23, 2026
@harlan-zw
Copy link
Collaborator Author

Code review

Found 2 issues:

  1. SRI integrity hash computed before proxy URL rewrites - The integrity hash is calculated on the original fetched script content (line 147-149), but proxy URL rewrites that modify the script content are applied afterward (lines 153-157). When a browser receives the rewritten script, the integrity check will fail because the hash no longer matches the modified content.

// Calculate integrity hash if enabled
const integrityHash = integrity && res
? calculateIntegrity(res, integrity === true ? 'sha384' : integrity)
: undefined
await storage.setItemRaw(`bundle:${filename}`, res)
// Apply URL rewrites for proxy mode
if (proxyRewrites?.length && res) {
const content = res.toString('utf-8')
const rewritten = rewriteScriptUrls(content, proxyRewrites)
res = Buffer.from(rewritten, 'utf-8')
logger.debug(`Rewrote ${proxyRewrites.length} URL patterns in ${filename}`)
}

  1. privacy: 'proxy' documentation contradicts implementation - The type definition says 'proxy' means "No modification: forwards all headers and data as-is" (line 39), but the proxy handler explicitly strips SENSITIVE_HEADERS (cookie, authorization, proxy-authorization, x-csrf-token, www-authenticate) in proxy mode (lines 116-123). Either the documentation should be updated to reflect the actual header-stripping behavior, or the implementation should match the documented "as-is" forwarding.

scripts/src/module.ts

Lines 33 to 42 in 9665043

/**
* Privacy mode for 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.
*
* - `'proxy'` - No modification: forwards all headers and data as-is. Privacy comes from
* routing requests through your server (third parties see server IP, not user IP).
*/
export type FirstPartyPrivacy = 'proxy' | 'anonymize'

// 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
}
}

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

…ing or encoding

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
@harlan-zw harlan-zw changed the title feat: anonymize proxy mode feat: first-party proxy mode with privacy anonymization Feb 18, 2026
@harlan-zw harlan-zw merged commit 3bc816e into main Feb 18, 2026
11 checks passed
@harlan-zw harlan-zw deleted the feat/proxy-scripts branch February 18, 2026 08:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Investigate offloading entire scripts as a server proxy

1 participant

Comments