Skip to content

feat: anonymize proxy mode#577

Open
harlan-zw wants to merge 17 commits intomainfrom
feat/proxy-scripts
Open

feat: anonymize proxy mode#577
harlan-zw wants to merge 17 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 expose user data directly to external servers - every request shares the user's IP address, and scripts can set third-party cookies for cross-site tracking. Ad blockers rightfully block these for privacy reasons.

This PR adds a firstParty option that routes all script traffic through your own domain:

  • User IPs stay private - third parties see your server's IP, not your users'
  • No third-party cookies - requests are same-origin, eliminating cross-site tracking vectors
  • Reduced fingerprinting - fewer direct connections to tracking domains
  • Works with ad blockers - requests appear first-party

Scripts are downloaded at build time, collection URLs rewritten to local paths (/_scripts/c/ga), and Nitro route rules proxy requests to original endpoints.

// nuxt.config.ts
export default defineNuxtConfig({
  scripts: {
    firstParty: true,
    registry: {
      googleAnalytics: { id: 'G-XXXXXX' },
      metaPixel: { id: '123456' },
    }
  }
})

Supported: Google Analytics, GTM, Meta Pixel, TikTok, Segment, Clarity, Hotjar, X/Twitter, Snapchat, Reddit.

Includes new /docs/guides/first-party documentation and deprecation notice on bundling guide.

@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 15, 2026 2:00am
scripts-playground Ready Ready Preview, Comment Feb 15, 2026 2:00am

- 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: 4f2c6b7

Comment on lines 384 to 387
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
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
// 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\/([^/]+)/)
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.

// Use storage to cache the font data between builds
const cacheKey = `bundle:${filename}`
// Include proxy in cache key to differentiate proxied vs non-proxied versions
const cacheKey = proxyRewrites?.length ? `bundle-proxy:${filename}` : `bundle:${filename}`
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
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
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
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/module.ts (1)

315-318: ⚠️ Potential issue | 🔴 Critical

Proxy mode forwards all headers including cookies and credentials to third parties.

In proxy mode (line 315-318), Object.assign(headers, originalHeaders) copies everything, including cookie, authorization, and other session tokens. The SENSITIVE_HEADERS denylist is only applied in anonymize mode. This could leak first-party session credentials to third-party analytics endpoints.

Even in "no modification" mode, origin credentials should not be forwarded upstream. The credentials: 'omit' on line 444 prevents outgoing cookies set by the browser for the third-party domain, but originalHeaders contains incoming request headers from the user's browser to your server—which include first-party cookies/auth tokens.

Suggested fix
   if (privacy === 'proxy') {
     // Proxy mode: forward all headers except sensitive ones
-    Object.assign(headers, originalHeaders)
+    for (const [key, value] of Object.entries(originalHeaders)) {
+      if (!value) continue
+      if (SENSITIVE_HEADERS.includes(key.toLowerCase())) continue
+      headers[key] = value
+    }
   }

This note applies to src/runtime/server/proxy-handler.ts lines 315-318, referenced here because the wiring is set up in module.ts.

🤖 Fix all issues with AI agents
In `@src/module.ts`:
- Around line 284-294: The migration never runs because config.firstParty
defaults to true; change the condition so we only migrate when the user did not
explicitly set firstParty. In the block handling
config.defaultScriptOptions.bundle, keep the logger.warn, but replace the guard
if (!config.firstParty && config.defaultScriptOptions.bundle) with a check that
firstParty is undefined / not provided by the user (e.g., if (config.firstParty
=== undefined && config.defaultScriptOptions.bundle) or using
Object.prototype.hasOwnProperty.call(originalUserConfig, 'firstParty') to detect
absence) and then set config.firstParty = true; this ensures deprecated
bundle:true migrates for users who didn’t explicitly set firstParty.

In `@src/runtime/server/proxy-handler.ts`:
- Around line 57-84: STRIP_PARAMS and NORMALIZE_PARAMS plus helpers anonymizeIP,
normalizeUserAgent, normalizeLanguage, generalizeScreen, and
stripPayloadFingerprinting are duplicated between proxy-handler.ts and
test/utils/proxy-privacy.ts; extract these constants and functions into a single
shared module (e.g., create a new privacy module) and export them, then replace
the local definitions in both src/runtime/server/proxy-handler.ts and
test/utils/proxy-privacy.ts to import the shared exports (keep the exact
exported names STRIP_PARAMS, NORMALIZE_PARAMS, anonymizeIP, normalizeUserAgent,
normalizeLanguage, generalizeScreen, stripPayloadFingerprinting so existing
callers in ProxyHandler and tests remain unchanged).
- Around line 491-499: The current code unconditionally applies a long
"immutable" cache TTL to all JavaScript responses after calling
rewriteScriptUrls, which is wrong because some scripts (e.g., gtag.js) are not
content-addressed; update the logic in the block that checks responseContentType
and proxyConfig.rewrites so that after rewriteScriptUrls you detect whether the
script is content-addressed or marked immutable (e.g., inspect rewritten URLs
for a content-hash pattern or honor a new immutable flag on proxyConfig.rewrites
entries), or match known vendor paths (e.g., gtag) to treat as non-immutable;
then call setResponseHeader(event, 'cache-control', ...) with a different header
for immutable assets (long max-age, immutable) versus non-immutable/third-party
scripts (shorter max-age, no immutable or use stale-while-revalidate only); keep
reference to responseContentType, proxyConfig, rewriteScriptUrls,
setResponseHeader, and event when implementing the branching logic.
- Around line 283-289: The current error handler in the conditional checking
!targetBase || !matchedPrefix reflects the incoming request path into the HTTP
statusMessage; update the createError call in that block (the code that uses
createError, path, targetBase, matchedPrefix and log) to use a generic
statusMessage like "Not Found" and move the detailed path information into the
error body/message field (or include it in the thrown error object's message
property) so the raw user-controlled path is not placed in HTTP headers; keep
the log('[proxy] No match for path:', path) as-is for server-side debugging.

In `@test/utils/proxy-privacy.ts`:
- Line 143: Replace the overly-broad substring check used in isScreenParam with
the same exact-match + bracket-prefix logic used in production: instead of
STRIP_PARAMS.screen.some(p => lowerKey === p.toLowerCase() ||
lowerKey.includes(p.toLowerCase())), call the reusable helper
matchesParam(lowerKey, STRIP_PARAMS.screen) (or implement equivalent logic:
STRIP_PARAMS.screen.some(p => lowerKey === p.toLowerCase() ||
lowerKey.startsWith(`${p.toLowerCase()}[`)) ) so only exact keys or bracketed
indexed params match (e.g., "sd" or "sd[0]") and avoid accidental matches like
"vpaid".
- Around line 1-176: The test utility duplicates production privacy logic and
has drifted; fix by either extracting the shared implementation from
src/runtime/server/proxy-handler.ts and importing it into test/utils (preferred)
or updating test/utils/proxy-privacy.ts to exactly mirror production: align
STRIP_PARAMS.browser, STRIP_PARAMS.userId, STRIP_PARAMS.screen,
STRIP_PARAMS.platform values to include the missing keys found in
proxy-handler.ts; change isScreenParam to use the same matchesParam logic used
elsewhere (exact and bracket matching), add recursive array handling in
stripFingerprintingFromPayload to match production, and ensure generalizeScreen
behavior matches production (string-only vs object handling) so functions
anonymizeIP, normalizeLanguage, normalizeUserAgent, generalizeScreen, and
stripFingerprintingFromPayload are identical between test and production.
🧹 Nitpick comments (4)
test/unit/third-party-proxy-replacements.test.ts (2)

81-136: Download failure silently passes the test instead of skipping it.

When $fetch throws (line 89-94), the test returns early without any assertion, counting as a pass. In CI this could mask regressions in the rewrite logic for all vendors. The synthetic tests below provide deterministic coverage, but these live tests still shouldn't silently succeed.

Consider using Vitest's skip API so CI reports clearly show which vendors were actually validated:

Suggested approach
       catch (e: any) {
         // Some scripts may require valid IDs or have rate limiting
-        // Skip if we can't download
         console.warn(`Could not download ${name}: ${e.message}`)
-        return
+        it.skip // or use: expect.soft to still mark as inconclusive
+        return // alternatively: throw new Error(`Skipped: ${e.message}`)
       }

A cleaner pattern would be to check availability in a beforeAll and conditionally skip the entire describe block, or use it.skipIf.


531-571: Snapshot test for comparison table is fragile and primarily produces diagnostic output.

This test generates a human-readable summary via console.warn and asserts it via inline snapshot. This has two concerns:

  1. The inline snapshot will break whenever any field is added/removed from the test payloads or the stripping logic changes, even for benign changes.
  2. The console.warn output is diagnostic—it's useful during development but adds noise in CI.

Consider splitting diagnostic output (a separate non-snapshot test or dev-only helper) from the assertion (which should focus on specific privacy guarantees rather than field-count formatting).

src/module.ts (2)

574-578: Variable config is shadowed by loop destructuring.

The config destructured in these for...of loops shadows the outer config parameter of the setup(config, nuxt) function. While it works because the inner config has .proxy and the outer isn't needed inside the loop body, this is easy to misread and could lead to subtle bugs if the loop body is extended later.

Suggested rename
-      for (const [path, config] of Object.entries(neededRoutes)) {
-        flatRoutes[path] = config.proxy
+      for (const [path, routeConfig] of Object.entries(neededRoutes)) {
+        flatRoutes[path] = routeConfig.proxy
       }

Apply similarly at the other occurrence (around line 623).

Also applies to: 621-626


406-438: Service Worker registration awaits activation with a 2 s hard timeout.

If the SW takes longer than 2 seconds to activate (e.g., on slow mobile devices or when the SW script is large), the plugin resolves before the SW is controlling the page. Subsequent sendBeacon intercepts or fetch interceptions may be missed for early-loaded scripts.

This may be acceptable as a pragmatic tradeoff, but consider documenting this limitation or making the timeout configurable via FirstPartyOptions.

src/module.ts Outdated
Comment on lines 284 to 294
// Handle deprecation of bundle option - migrate to firstParty
if (config.defaultScriptOptions?.bundle !== undefined) {
logger.warn(
'`scripts.defaultScriptOptions.bundle` is deprecated. '
+ 'Use `scripts.firstParty: true` instead.',
)
// Migrate: treat bundle as firstParty
if (!config.firstParty && config.defaultScriptOptions.bundle) {
config.firstParty = true
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Deprecation migration is effectively unreachable under the current defaults.

firstParty defaults to true (line 219). The migration on line 291 checks if (!config.firstParty && config.defaultScriptOptions.bundle), which can only be true if the user explicitly sets firstParty: false. In that case, re-enabling it via bundle: true seems contradictory—the user disabled first-party but left a deprecated bundle flag on.

The intended migration path (user had bundle: true, hasn't set firstParty) won't fire because config.firstParty is already true from defaults. The deprecation warning on line 286 will fire though, which is useful. Just note that the body of the if on line 291 is dead in practice.

🤖 Prompt for AI Agents
In `@src/module.ts` around lines 284 - 294, The migration never runs because
config.firstParty defaults to true; change the condition so we only migrate when
the user did not explicitly set firstParty. In the block handling
config.defaultScriptOptions.bundle, keep the logger.warn, but replace the guard
if (!config.firstParty && config.defaultScriptOptions.bundle) with a check that
firstParty is undefined / not provided by the user (e.g., if (config.firstParty
=== undefined && config.defaultScriptOptions.bundle) or using
Object.prototype.hasOwnProperty.call(originalUserConfig, 'firstParty') to detect
absence) and then set config.firstParty = true; this ensures deprecated
bundle:true migrates for users who didn’t explicitly set firstParty.

Comment on lines 491 to 499
// Rewrite URLs in JavaScript responses to route through our proxy
// This is necessary because some SDKs use sendBeacon() which can't be intercepted by SW
if (responseContentType.includes('javascript') && proxyConfig?.rewrites?.length) {
content = rewriteScriptUrls(content, proxyConfig.rewrites)
log('[proxy] Rewrote URLs in JavaScript response')

// Add cache headers for JavaScript responses (immutable content with hash in filename)
setResponseHeader(event, 'cache-control', `public, max-age=${cacheTtl}, stale-while-revalidate=${cacheTtl * 2}`)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Cache-control applied to all rewritten JS responses, including non-content-addressed URLs.

The comment on line 497 says "immutable content with hash in filename," but not all analytics script URLs are content-addressed (e.g., gtag.js?id=G-XXX). A 1-hour cache with 2-hour stale-while-revalidate is reasonable, but the "immutable" assumption in the comment is misleading and the TTL may need to vary per vendor.

🤖 Prompt for AI Agents
In `@src/runtime/server/proxy-handler.ts` around lines 491 - 499, The current code
unconditionally applies a long "immutable" cache TTL to all JavaScript responses
after calling rewriteScriptUrls, which is wrong because some scripts (e.g.,
gtag.js) are not content-addressed; update the logic in the block that checks
responseContentType and proxyConfig.rewrites so that after rewriteScriptUrls you
detect whether the script is content-addressed or marked immutable (e.g.,
inspect rewritten URLs for a content-hash pattern or honor a new immutable flag
on proxyConfig.rewrites entries), or match known vendor paths (e.g., gtag) to
treat as non-immutable; then call setResponseHeader(event, 'cache-control', ...)
with a different header for immutable assets (long max-age, immutable) versus
non-immutable/third-party scripts (shorter max-age, no immutable or use
stale-while-revalidate only); keep reference to responseContentType,
proxyConfig, rewriteScriptUrls, setResponseHeader, and event when implementing
the branching logic.

}

const isIpParam = matchesParam(key, STRIP_PARAMS.ip)
const isScreenParam = STRIP_PARAMS.screen.some(p => lowerKey === p.toLowerCase() || lowerKey.includes(p.toLowerCase()))
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

isScreenParam substring match is overly broad and inconsistent with production.

const isScreenParam = STRIP_PARAMS.screen.some(p => lowerKey === p.toLowerCase() || lowerKey.includes(p.toLowerCase()))

Using .includes() means any key containing the substring "sd", "vp", "sr", etc. will match. For example, a hypothetical key "vpaid" would match via "vp". The production handler (proxy-handler.ts) uses exact-match + bracket-prefix via matchesParam, which is more precise.

Suggested fix to align with production
-    const isScreenParam = STRIP_PARAMS.screen.some(p => lowerKey === p.toLowerCase() || lowerKey.includes(p.toLowerCase()))
+    const isScreenParam = matchesParam(key, STRIP_PARAMS.screen)
🤖 Prompt for AI Agents
In `@test/utils/proxy-privacy.ts` at line 143, Replace the overly-broad substring
check used in isScreenParam with the same exact-match + bracket-prefix logic
used in production: instead of STRIP_PARAMS.screen.some(p => lowerKey ===
p.toLowerCase() || lowerKey.includes(p.toLowerCase())), call the reusable helper
matchesParam(lowerKey, STRIP_PARAMS.screen) (or implement equivalent logic:
STRIP_PARAMS.screen.some(p => lowerKey === p.toLowerCase() ||
lowerKey.startsWith(`${p.toLowerCase()}[`)) ) so only exact keys or bracketed
indexed params match (e.g., "sd" or "sd[0]") and avoid accidental matches like
"vpaid".

- Don't reflect user-controlled path in HTTP statusMessage (security)
- Clone SW request before accessing body stream
- Fix rewriteScriptUrls: support host+path from patterns, preserve leading /
- Fix joinURL inserting / before query strings in rewritten URLs
- Replace hardcoded Segment writeKey with placeholder
- Use logger.debug instead of logger.info for first-party config
- Simplify unreachable deprecation migration
- Remove duplicate comment block in tests
- Update test expectations for normalizeUserAgent version output
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 11

🤖 Fix all issues with AI agents
In `@src/module.ts`:
- Around line 604-612: When firstPartyPrivacy === 'proxy' we're currently
merging neededRoutes into nuxt.options.routeRules which lets Nitro forward all
request headers (including cookies/Authorization) to third parties; update the
proxy-mode branch so route rules explicitly deny or strip sensitive headers
before forwarding (e.g., remove Cookie, Authorization, Set-Cookie, Credentials
headers) instead of blindly spreading neededRoutes, or wrap the proxy target
with a small header-filtering handler similar to the anonymize path; ensure the
change targets the same logic that builds neededRoutes/nuxt.options.routeRules
and add a clear comment documenting the privacy trade-off for proxy mode.

In `@src/runtime/server/api/scripts-health.ts`:
- Around line 82-93: The avgLatency calculation currently uses checks.length and
treats missing latencies as 0, which skews the metric when some checks errored;
update the computation in this block (variables: allOk, avgLatency, checks) to
first filter to only checks that have a numeric latency (e.g., checks.filter(c
=> typeof c.latency === 'number' || c.status === 'ok')), compute sum over that
filtered array and divide by its length (with a safe fallback to 0 when the
filtered array is empty), then Math.round the result before returning it in
summary.avgLatency.

In `@src/runtime/server/proxy-handler.ts`:
- Around line 195-203: The code passes a Record<string,unknown> from
stripPayloadFingerprinting into new URLSearchParams, which will coerce
non-string values (objects/arrays) to "[object Object]"; update the
application/x-www-form-urlencoded branch to convert all values to strings before
building URLSearchParams: iterate the stripped object returned by
stripPayloadFingerprinting, for each key convert non-string values using
JSON.stringify (leave existing strings unchanged), produce a
Record<string,string> (or Map) and then call new URLSearchParams(...) to assign
to body; target the logic around stripPayloadFingerprinting, params
construction, and where body is set so nested arrays/objects are serialized
correctly.
- Around line 116-118: In the proxy branch where privacy === 'proxy' you
currently copy all incoming headers via Object.assign(headers, originalHeaders);
change this to copy only non-sensitive headers by filtering out authentication
and session headers (e.g., "authorization", "cookie", "set-cookie", "x-api-key",
"proxy-authorization" and similar) before assigning; update the logic around the
headers and originalHeaders variables so headers receives a sanitized subset
(keep header name comparisons case-insensitive) and ensure any existing tests or
downstream uses of headers still work with the sanitized set.

In `@src/runtime/server/utils/privacy.ts`:
- Around line 74-86: Update the misleading comment in anonymizeIP: change the
IPv4 comment that says "country-level precision" to accurately state it zeroes
the last octet producing a /24 subnet (typically ISP/neighborhood or
city-level), and ensure the IPv6 comment clarifies it keeps the first 3 segments
(48 bits) for similar aggregation; modify the inline comments above the IPv6 and
IPv4 handling in the anonymizeIP function to reflect these accurate
privacy/precision descriptions.
- Around line 42-61: STRIP_PARAMS defines userId and userData but
stripPayloadFingerprinting does not remove those keys; update
stripPayloadFingerprinting to also detect and strip any fields matching
STRIP_PARAMS.userId and STRIP_PARAMS.userData (including nested dot-notation
like "traits.email") the same way ip/screen/platform/etc. are handled—remove or
replace values, and update any related tests; alternatively, if you intend to
forward IDs/PII, remove userId and userData from STRIP_PARAMS and add a clear
comment in the top-level STRIP_PARAMS declaration explaining why these
categories are intentionally preserved.

In `@src/runtime/utils/pure.ts`:
- Around line 48-66: In src/runtime/utils/pure.ts adjust the suffix host
comparisons to use fromHost instead of from: when isSuffixMatch is true replace
occurrences where url.host is checked with .endsWith(from) to use
.endsWith(fromHost) (this applies in the host-full-URL branch that references
url.host and the protocol-relative branch later), ensuring the logic that sets
shouldRewrite and rewriteSuffix still uses fromPath/fullPath as before;
reference variables/functions: isSuffixMatch, fromHost, fromPath, url.host,
fullPath, shouldRewrite, rewriteSuffix.

In `@test/e2e/first-party.test.ts`:
- Around line 277-293: The test "bundled scripts contain rewritten collect URLs"
currently hardcodes cacheDir and silently returns if it's missing; change it to
fail loudly instead. Replace the early `if (!existsSync(cacheDir)) { return }`
with an explicit assertion that the cache exists (use `existsSync(cacheDir)` /
`expect(...).toBe(true)` or similar) so the test fails when the internal path is
missing, then continue checking `files`, `allContent`, and `hasProxyUrl` as
before.
- Around line 73-93: The test currently lumps preserved user-id/PII fields into
FINGERPRINT_PARAMS which conflicts with runtime behavior in
stripPayloadFingerprinting; split FINGERPRINT_PARAMS into two arrays (e.g.,
STRIPPED_FINGERPRINT_PARAMS for true fingerprinting fields like
'plat','canvas','webgl','sr','vp','tz','plugins', etc., and
PRESERVED_FINGERPRINT_PARAMS for user-id/user-data like
'uid','cid','_gid','fbp','fbc','sid','pl_id','p_user_id','ud','email','phone')
and update assertions to only expect removal for STRIPPED_FINGERPRINT_PARAMS
while treating PRESERVED_FINGERPRINT_PARAMS as allowed/preserved by
stripPayloadFingerprinting so the tests reflect the actual function behavior.

In `@test/unit/first-party.test.ts`:
- Around line 6-11: Rename the test to accurately reflect what it asserts:
change the spec title from 'firstParty defaults to true' to something like
'proxy configs are available' (or similar) in the test that calls
getAllProxyConfigs('/_scripts/c'); keep the existing body and comment, and
ensure the test file/test case referencing getAllProxyConfigs remains unchanged
apart from the spec string.

In `@test/unit/proxy-privacy.test.ts`:
- Around line 250-312: The E2E tests assert that fields like cid, uid, _gid,
fbp, fbc, pl_id, p_user_id are stripped but stripFingerprintingFromPayload (and
stripPayloadFingerprinting) intentionally preserves user-id analytics fields
while only removing platform/canvas/browser/location/deviceInfo and normalizing
UA/language/IP; update the E2E constant FINGERPRINT_PARAMS (used by
verifyFingerprintingStripped) to remove user-id fields and only list the actual
fingerprinting categories the implementation strips (or add a comment
documenting the intentional preservation) so the E2E expectations match
stripFingerprintingFromPayload's behavior.
🧹 Nitpick comments (14)
src/runtime/utils/pure.ts (2)

27-27: Regex won't handle escaped quotes or template literal interpolation.

The literal regex /([ '"\])(.*?)\1/gcan mis-match on escaped quotes (e.g.,') or template literals with ${...}` expressions. For analytics script URLs this is rarely an issue in practice, and the GA-specific fallback on lines 110-121 covers the most common dynamic construction pattern. Just noting as a known limitation.


110-121: GA-specific fallback is a pragmatic workaround, but consider a comment noting it's coupled to GA's build output.

This regex-based replacement targets a specific code generation pattern from Google Analytics. If GA changes their dynamic URL construction, this fallback would silently stop working. A brief inline note documenting the expected input format would help future maintainers.

src/runtime/server/utils/privacy.ts (1)

144-150: matchesParam helper is re-created on every loop iteration.

The closure is defined inside the for loop body, causing a new function allocation per payload entry. Consider hoisting it outside the loop.

♻️ Proposed refactor

Move the helper before the for loop:

+  const matchesParam = (key: string, params: string[]) => {
+    const lk = key.toLowerCase()
+    return params.some((p) => {
+      const lp = p.toLowerCase()
+      return lk === lp || lk.startsWith(lp + '[')
+    })
+  }
+
   for (const [key, value] of Object.entries(payload)) {
     const lowerKey = key.toLowerCase()
     // ...
-    const matchesParam = (key: string, params: string[]) => {
-      const lk = key.toLowerCase()
-      return params.some((p) => {
-        const lp = p.toLowerCase()
-        return lk === lp || lk.startsWith(lp + '[')
-      })
-    }
src/proxy-configs.ts (1)

19-176: Consider caching the result of buildProxyConfig since it's pure and deterministic.

buildProxyConfig is called fresh on every invocation of getProxyConfig, getAllProxyConfigs, and getSWInterceptRules. Since the output is purely determined by collectPrefix, consider memoizing it if the same prefix is used repeatedly during build.

src/runtime/sw/proxy-sw.template.js (1)

41-49: No error handling — a failed proxy fetch will cause the analytics request to silently fail.

If the proxy endpoint is down or returns an error, event.respondWith(fetch(...)) will reject and the original third-party request has already been consumed. For analytics this is typically acceptable (fire-and-forget), but adding a .catch() fallback would improve resilience.

♻️ Proposed fix with fallback
       event.respondWith(
-        fetch(proxyUrl.href, {
-          method: clonedRequest.method,
-          headers: clonedRequest.headers,
-          body: clonedRequest.method !== 'GET' && clonedRequest.method !== 'HEAD' ? clonedRequest.body : undefined,
-          credentials: 'same-origin',
-          redirect: 'follow',
-        }),
+        fetch(proxyUrl.href, {
+          method: clonedRequest.method,
+          headers: clonedRequest.headers,
+          body: clonedRequest.method !== 'GET' && clonedRequest.method !== 'HEAD' ? clonedRequest.body : undefined,
+          credentials: 'same-origin',
+          redirect: 'follow',
+        }).catch(() => fetch(event.request)),
       )
test/unit/first-party.test.ts (1)

82-110: Test only asserts values it manually constructed — consider adding an assertion on actual route content.

The status data structure test simulates the module's behavior by building neededRoutes from configs, then asserts on the structure it just built. This validates shape but not correctness of the config content. The route content assertions in lines 57-80 partially cover this, so this is a minor gap.

test/unit/third-party-proxy-replacements.test.ts (2)

81-136: Network-dependent tests silently pass on download failure.

The return on line 93 causes the test to pass silently when the script can't be fetched (rate limiting, network issues, invalid IDs). Consider using it.skip or expect with a skip annotation so CI reporters surface skipped tests rather than false greens.

♻️ Suggested approach
       catch (e: any) {
         // Some scripts may require valid IDs or have rate limiting
-        // Skip if we can't download
-        console.warn(`Could not download ${name}: ${e.message}`)
-        return
+        console.warn(`Skipping ${name}: ${e.message}`)
+        return // TODO: consider using vitest's `ctx.skip()` for proper skip reporting
       }

Alternatively, if using Vitest ≥ 0.30, the test context's skip() method can be used to mark the test as skipped instead of silently passing.


139-157: Hardcoded '/_scripts/c' defeats the purpose of the COLLECT_PREFIX constant.

Line 149 uses routeBase.replace('/_scripts/c', COLLECT_PREFIX) which is a no-op since both are the same value. If COLLECT_PREFIX is ever changed, this string literal would need to change too. Use COLLECT_PREFIX consistently or remove the replace since it's redundant.

-            return rewrite.to.startsWith(routeBase.replace('/_scripts/c', COLLECT_PREFIX))
+            return rewrite.to.startsWith(routeBase)
src/module.ts (3)

573-576: Variable config shadows the outer module config parameter.

The destructured config in this loop (line 574) shadows the config from setup(config, nuxt) on line 238. While it works correctly here because only config.proxy is accessed within the loop body, the shadowing is confusing and fragile.

♻️ Suggested rename
-      for (const [path, config] of Object.entries(neededRoutes)) {
-        flatRoutes[path] = config.proxy
+      for (const [path, routeConfig] of Object.entries(neededRoutes)) {
+        flatRoutes[path] = routeConfig.proxy
       }

546-562: Proxy key resolution logic is duplicated.

The pattern of deriving proxyKey from registryScriptsWithImport and registryKeys is repeated identically in two loops (lines 546–562 for routes, lines 578–589 for rewrites). Extract this into a helper to keep it DRY.

♻️ Suggested refactor
+      // Helper: resolve proxy config key for a registry key
+      function resolveProxyKey(key: string): string | undefined {
+        const script = registryScriptsWithImport.find(
+          s => s.import.name === `useScript${key.charAt(0).toUpperCase() + key.slice(1)}`,
+        )
+        return script?.proxy !== false ? (script?.proxy || key) : undefined
+      }
+
       const neededRoutes: Record<string, { proxy: string }> = {}
       const unsupportedScripts: string[] = []
+      const allRewrites: Array<{ from: string, to: string }> = []
+
       for (const key of registryKeys) {
-        const script = registryScriptsWithImport.find(...)
-        const proxyKey = script?.proxy !== false ? (script?.proxy || key) : undefined
+        const proxyKey = resolveProxyKey(key)
         if (proxyKey) {
           const proxyConfig = proxyConfigs[proxyKey]
           if (proxyConfig?.routes) {
             Object.assign(neededRoutes, proxyConfig.routes)
           }
+          if (proxyConfig?.rewrite) {
+            allRewrites.push(...proxyConfig.rewrite)
+          }
           // ...

Also applies to: 578-589


402-436: Inline SW registration plugin has no unregister/update strategy.

The generated plugin registers the service worker and waits for controller change, but there's no version tracking or update handling. If the SW script content changes between deploys, the old SW may remain cached. Consider adding reg.update() or a version-based cache-bust strategy.

src/runtime/server/api/scripts-health.ts (1)

44-80: Health checks run sequentially — consider Promise.all for faster response.

Each route is checked one at a time with a 5-second timeout. With many proxy routes, the worst case is N × 5s. Since this is dev-only, it's not critical, but parallel execution would improve DX.

♻️ Suggested approach
- for (const [route, target] of Object.entries(scriptsConfig.routes)) {
-   // ... sequential fetch ...
-   checks.push(result)
- }
+ const checkPromises = Object.entries(scriptsConfig.routes).map(async ([route, target]) => {
+   const scriptMatch = route.match(scriptNameRegex)
+   const scriptName = scriptMatch?.[1] || 'unknown'
+   const testUrl = target.replace('/**', '/')
+   const start = Date.now()
+   return fetch(testUrl, { method: 'HEAD', signal: AbortSignal.timeout(5000) })
+     .then((res) => ({
+       script: scriptName, route, target: testUrl,
+       status: res.ok ? 'ok' : 'error',
+       latency: Date.now() - start,
+       error: res.ok ? undefined : `HTTP ${res.status}`,
+     } satisfies HealthCheckResult))
+     .catch((err) => ({
+       script: scriptName, route, target: testUrl,
+       status: 'error', error: err.message || 'Connection failed',
+     } satisfies HealthCheckResult))
+ })
+ const checks = await Promise.all(checkPromises)
test/e2e/first-party.test.ts (2)

335-369: Inconsistent assertion strictness across providers.

Some providers (Segment line 456, xPixel line 476, Snapchat line 509) unconditionally assert captures.length > 0, while others (GA, GTM, Meta, TikTok, Reddit, Clarity, Hotjar) wrap all assertions in if (captures.length > 0). This means the unconditional tests will become flaky if the headless environment changes, while the conditional tests provide no regression coverage if captures stop working.

Consider unifying the approach — either all providers are conditional (acceptable for third-party dependent tests) or document why specific providers are expected to always produce captures.

Also applies to: 440-471, 473-491, 493-524


301-333: testProvider helper is only used by one test (xPixel).

The helper extracts a common test pattern nicely, but other provider tests (GA, GTM, Meta, etc.) duplicate the same browser setup / navigation / capture reading logic manually. Consider using testProvider more broadly to reduce boilerplate, or removing it in favor of consistent inline patterns.

Comment on lines 82 to 93
const allOk = checks.every(c => c.status === 'ok')
const avgLatency = checks.length > 0 ? checks.reduce((sum, c) => sum + (c.latency || 0), 0) / checks.length : 0

return {
status: allOk ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
summary: {
total: checks.length,
ok: checks.filter(c => c.status === 'ok').length,
errors: checks.filter(c => c.status === 'error').length,
avgLatency: Math.round(avgLatency),
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Average latency calculation includes failed checks (latency = 0), skewing the metric.

Error checks have no latency property, so c.latency || 0 contributes 0 to the sum while still counting toward checks.length. This understates average latency when errors occur.

♻️ Suggested fix
- const avgLatency = checks.length > 0 ? checks.reduce((sum, c) => sum + (c.latency || 0), 0) / checks.length : 0
+ const okChecks = checks.filter(c => c.status === 'ok' && c.latency !== undefined)
+ const avgLatency = okChecks.length > 0 ? okChecks.reduce((sum, c) => sum + c.latency!, 0) / okChecks.length : 0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const allOk = checks.every(c => c.status === 'ok')
const avgLatency = checks.length > 0 ? checks.reduce((sum, c) => sum + (c.latency || 0), 0) / checks.length : 0
return {
status: allOk ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
summary: {
total: checks.length,
ok: checks.filter(c => c.status === 'ok').length,
errors: checks.filter(c => c.status === 'error').length,
avgLatency: Math.round(avgLatency),
},
const allOk = checks.every(c => c.status === 'ok')
const okChecks = checks.filter(c => c.status === 'ok' && c.latency !== undefined)
const avgLatency = okChecks.length > 0 ? okChecks.reduce((sum, c) => sum + c.latency!, 0) / okChecks.length : 0
return {
status: allOk ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
summary: {
total: checks.length,
ok: checks.filter(c => c.status === 'ok').length,
errors: checks.filter(c => c.status === 'error').length,
avgLatency: Math.round(avgLatency),
},
🤖 Prompt for AI Agents
In `@src/runtime/server/api/scripts-health.ts` around lines 82 - 93, The
avgLatency calculation currently uses checks.length and treats missing latencies
as 0, which skews the metric when some checks errored; update the computation in
this block (variables: allOk, avgLatency, checks) to first filter to only checks
that have a numeric latency (e.g., checks.filter(c => typeof c.latency ===
'number' || c.status === 'ok')), compute sum over that filtered array and divide
by its length (with a safe fallback to 0 when the filtered array is empty), then
Math.round the result before returning it in summary.avgLatency.

Comment on lines 42 to 61
export const STRIP_PARAMS = {
// IP addresses
ip: ['uip', 'ip', 'client_ip_address', 'ip_address', 'user_ip', 'ipaddress', 'context.ip'],
// User identifiers
userId: ['uid', 'user_id', 'userid', 'external_id', 'cid', '_gid', 'fbp', 'fbc', 'sid', 'session_id', 'sessionid', 'pl_id', 'p_user_id', 'uuid', 'anonymousid', 'twclid', 'u_c1', 'u_sclid', 'u_scsid'],
// User data (PII) - includes email, phone, etc.
userData: ['ud', 'user_data', 'userdata', 'email', 'phone', 'traits.email', 'traits.phone'],
// Screen/Hardware fingerprinting (sh/sw = Snapchat screen height/width)
screen: ['sr', 'vp', 'sd', 'screen', 'viewport', 'colordepth', 'pixelratio', 'sh', 'sw'],
// Platform fingerprinting (d_a = architecture, d_ot = OS type, d_os = OS version)
platform: ['plat', 'platform', 'hardwareconcurrency', 'devicememory', 'cpu', 'mem', 'd_a', 'd_ot', 'd_os'],
// Browser fingerprinting (d_bvs = Snapchat browser versions)
browser: ['plugins', 'fonts', 'd_bvs'],
// Location/Timezone
location: ['tz', 'timezone', 'timezoneoffset'],
// Canvas/WebGL fingerprinting
canvas: ['canvas', 'webgl', 'audiofingerprint'],
// Combined device fingerprinting (X/Twitter dv param contains: timezone, locale, vendor, platform, screen, etc.)
deviceInfo: ['dv', 'device_info', 'deviceinfo', 'bci', 'eci'],
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the original concern about userId/userData usage
echo "=== STRIP_PARAMS definition and usage ==="
rg -n "STRIP_PARAMS|userId|userData" src/runtime/server/utils/privacy.ts -A 2

echo ""
echo "=== stripPayloadFingerprinting implementation detail ==="
rg -n "stripPayloadFingerprinting" src/runtime/server/ -l

Repository: nuxt/scripts

Length of output: 1463


🏁 Script executed:

#!/bin/bash
# Check if userId/userData stripping is handled elsewhere
echo "=== Check for alternative userId/userData handling in privacy.ts ==="
sed -n '128,182p' src/runtime/server/utils/privacy.ts

echo ""
echo "=== Look for references to STRIP_PARAMS outside privacy.ts ==="
rg "STRIP_PARAMS\.(userId|userData)" --glob="*.ts" src/

Repository: nuxt/scripts

Length of output: 2003


🏁 Script executed:

#!/bin/bash
# Check proxy-handler and other consumers
echo "=== Check proxy-handler for privacy/fingerprinting logic ==="
find src/runtime/server -name "proxy-handler.ts" -o -name "*proxy*.ts" | head -5

echo ""
echo "=== Search for imports of stripPayloadFingerprinting ==="
rg "stripPayloadFingerprinting|privacy" src/runtime/server/ --glob="*.ts" -l

Repository: nuxt/scripts

Length of output: 329


🏁 Script executed:

#!/bin/bash
echo "=== Check proxy-handler.ts usage of stripPayloadFingerprinting ==="
rg -n "stripPayloadFingerprinting" src/runtime/server/proxy-handler.ts -B 5 -A 10

echo ""
echo "=== Check for any comments or design notes about userId/userData ==="
rg -n "userId|userData|user.*id|PII|personal" src/runtime/server/proxy-handler.ts -i

Repository: nuxt/scripts

Length of output: 3232


🏁 Script executed:

#!/bin/bash
echo "=== Check the entire privacy.ts for any comments about userId/userData handling ==="
cat -n src/runtime/server/utils/privacy.ts | head -100

echo ""
echo "=== Check if there's any git history or comments ==="
rg -n "userId|userData" src/runtime/server/utils/privacy.ts -B 2 -A 2

Repository: nuxt/scripts

Length of output: 4937


STRIP_PARAMS.userId and STRIP_PARAMS.userData are defined but never checked in stripPayloadFingerprinting.

Lines 46–48 define sensitive parameter categories (user IDs like uid, fbp, fbc and PII like email, phone), but the stripPayloadFingerprinting function (lines 128–182) only handles ip, screen, platform, canvas, browser, location, and deviceInfo. The userId and userData parameters silently pass through unstripped.

Either add these checks to the function or remove them from STRIP_PARAMS with a comment explaining why user identifiers and PII are intentionally forwarded.

🤖 Prompt for AI Agents
In `@src/runtime/server/utils/privacy.ts` around lines 42 - 61, STRIP_PARAMS
defines userId and userData but stripPayloadFingerprinting does not remove those
keys; update stripPayloadFingerprinting to also detect and strip any fields
matching STRIP_PARAMS.userId and STRIP_PARAMS.userData (including nested
dot-notation like "traits.email") the same way ip/screen/platform/etc. are
handled—remove or replace values, and update any related tests; alternatively,
if you intend to forward IDs/PII, remove userId and userData from STRIP_PARAMS
and add a clear comment in the top-level STRIP_PARAMS declaration explaining why
these categories are intentionally preserved.

- Strip sensitive headers (cookie/authorization) in proxy mode
- Fix suffix host comparisons to use fromHost in pure.ts
- Fix URLSearchParams coercion for non-string values
- Fix avgLatency skew from errored health checks
- Split FINGERPRINT_PARAMS into stripped vs preserved categories
- Document intentional preservation of userId/userData in STRIP_PARAMS
- Fix anonymizeIP comment accuracy (/24 subnet, not country-level)
- Rename misleading test spec title
- Fix x-pixel.md copy-paste error (Meta Pixel → X Pixel)
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@src/module.ts`:
- Around line 606-626: The current approach inside the firstPartyPrivacy ===
'proxy' branch is incorrect because nuxt.options.routeRules.headers sets
response headers, not request headers, so sensitive headers will still be
forwarded; instead, replace the sanitizedRoutes routeRules approach with actual
Nitro proxy handlers that use h3's proxyRequest and getProxyRequestHeaders: for
each entry in neededRoutes (use the same keys from neededRoutes), register a
Nitro route handler that calls getProxyRequestHeaders(req) (or builds headers
from req), deletes 'cookie', 'authorization', 'proxy-authorization', and
'x-csrf-token' from that header object, then calls proxyRequest(event,
config.proxy, { headers }) to forward the request and returns the upstream
response; remove the sanitizedRoutes assignment to nuxt.options.routeRules and
ensure the new handlers use the proxy target from config.proxy so request
headers are stripped before proxying.

In `@src/runtime/server/proxy-handler.ts`:
- Around line 265-291: The catch block in proxy-handler.ts currently injects the
raw caught error message into the createError response (variable message used
when throwing 502), which can leak internal details; update the handler so that
createError for Bad Gateway returns a generic, non-revealing message (e.g.,
"Failed to reach upstream") and do not include the raw error string in the
client-facing response, while still logging the full error internally via the
existing log(...) call or another internal logger; preserve the 204 behavior for
analytics paths (path.includes('/collect'...) block) and keep the 504 branch for
timeouts but ensure you only use new URL(targetUrl).hostname in the 504 message
(or make that generic too) and treat err as unknown safely (check instanceof
Error before accessing properties) before logging the full details.

In `@src/runtime/server/utils/privacy.ts`:
- Around line 96-106: normalizeUserAgent currently relies on ua.match(...) which
returns the first positional match so "Chrome" is found before "Edg" in Edge
UAs; update normalizeUserAgent to scan for browser tokens in a specificity-aware
way (e.g., test for tokens in order of specificity like "Edg", "OPR"/"Opera",
"Chrome", "Safari", "Firefox" or use matchAll to collect all matches and prefer
the most specific) and then extract the corresponding major version to build the
family/majorVersion used to produce the normalized string (references: function
normalizeUserAgent, the match variable, and the family/majorVersion
construction).

In `@test/e2e/first-party.test.ts`:
- Around line 459-474: The tests for Segment (uses variable captures and
function verifyFingerprintingStripped) unconditionally assert captures.length >
0 and then run expectations and snapshot matching which can flake in headless
CI; update the Segment block (and the similar xPixel and Snapchat blocks) to
mirror the other provider tests by guarding the detailed assertions and snapshot
check with if (captures.length > 0) { /* existing expectations: check
path/targetUrl/privacy, loop calling verifyFingerprintingStripped, and await
expect(captures).toMatchFileSnapshot(...) */ } else { /* optionally assert
captures.length === 0 or skip */ }, so the test only runs those checks when
captures were actually recorded. Ensure you modify the blocks that reference
captures, the hasValidCapture check, verifyFingerprintingStripped loop, and the
toMatchFileSnapshot call.
🧹 Nitpick comments (3)
src/module.ts (3)

573-576: Variable config shadows the outer setup parameter.

The destructured config on line 574 shadows the module's config parameter from setup(config, nuxt) on line 238. The same shadowing occurs on lines 611 and 635. If anyone later references the module config inside these loops, they'll get the route config object instead.

♻️ Suggested fix — rename the loop variable
-        for (const [path, config] of Object.entries(neededRoutes)) {
-          flatRoutes[path] = config.proxy
+        for (const [path, routeConfig] of Object.entries(neededRoutes)) {
+          flatRoutes[path] = routeConfig.proxy
         }

Apply the same rename on line 611 and 635.


546-562: Duplicate proxyKey resolution logic.

The pattern of finding a script by name and resolving proxyKey is repeated in two separate loops (lines 546–561 and 580–589). Consider extracting a helper like resolveProxyKey(registryScriptsWithImport, key) to DRY this up.

Also applies to: 578-589


404-434: Inline SW registration plugin has no unregistration/cleanup path.

The SW is registered with scope: '/' and never unregistered. If a user later disables firstParty, the previously registered SW will persist in their browser and continue intercepting requests. Consider adding a cleanup mechanism (e.g., when firstParty is disabled, register a plugin that unregisters any existing nuxt-scripts SW).

src/module.ts Outdated
Comment on lines 606 to 626
if (firstPartyPrivacy === 'proxy') {
// Proxy mode: use Nitro route rules with sensitive headers stripped.
// Even in passthrough proxy mode, we must not forward auth/session
// headers (Cookie, Authorization, etc.) to third-party analytics endpoints.
const sanitizedRoutes: Record<string, { proxy: string, headers: Record<string, string> }> = {}
for (const [path, config] of Object.entries(neededRoutes)) {
sanitizedRoutes[path] = {
proxy: config.proxy,
headers: {
'cookie': '',
'authorization': '',
'proxy-authorization': '',
'x-csrf-token': '',
},
}
}
nuxt.options.routeRules = {
...nuxt.options.routeRules,
...sanitizedRoutes,
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for Nitro route rules implementation in the repository
fd -t f "route" --search-path . | head -20

Repository: nuxt/scripts

Length of output: 38


🏁 Script executed:

#!/bin/bash
# Check if this is a Nuxt module and look for Nitro-related dependencies
if [ -f "package.json" ]; then
  cat package.json | grep -A 5 -B 5 "nitro\|nuxt"
fi

Repository: nuxt/scripts

Length of output: 3293


🌐 Web query:

Nitro route rules proxy headers request forwarding documentation

💡 Result:

For Nitro (NitroPack / Nuxt’s server engine) route rules, proxying, headers, and request forwarding, the relevant docs (and the underlying implementation) are:

  • Nitro routeRules (headers / redirect / proxy, etc.): Nitro config docs show the routeRules map, including headers and proxy examples. The proxy rule can target an absolute URL (e.g. https://example.com) or another path. [1]
  • Nitro devProxy (development-only proxying): Nitro config docs cover devProxy and note it uses node-http-proxy-style options (e.g. changeOrigin, plus other proxy options). [1]
  • What routeRules.proxy actually does (request forwarding behavior): Nitro’s runtime route-rules handler calls H3’s proxyRequest(event, target, opts) and spreads your routeRules.proxy object into the options, meaning proxy options you put under routeRules.proxy are forwarded to proxyRequest. It also applies routeRules.headers via setHeaders. [2]
  • H3 proxy utilities (proxyRequest, and header forwarding helpers): H3 documents proxyRequest, plus getProxyRequestHeaders(event) (which returns request headers excluding ones known to cause issues when proxying) and fetchWithEvent (fetch with the event’s headers/context). [3]

Sources
[1] Nitro Configuration docs (includes routeRules and devProxy sections) (nitro-docs.pages.dev)
[2] Nitro runtime route rules implementation (setHeaders, proxyRequest, and option spreading) (app.unpkg.com)
[3] H3 proxy utilities docs (proxyRequest, getProxyRequestHeaders, fetchWithEvent) (h3.dev)

Citations:


🌐 Web query:

Nitro h3 proxy request headers manipulation

💡 Result:

In Nuxt Nitro / h3, the supported way to manipulate (add/remove/override) outgoing proxy request headers is to call h3’s proxy utilities and pass your own headers object—typically starting from getProxyRequestHeaders(event) (which already drops headers known to cause proxying issues). [1]

Minimal Nitro (server route) example: override/add/remove headers

// server/api/proxy/[...path].ts
import {
  defineEventHandler,
  getProxyRequestHeaders,
  proxyRequest,
} from "h3";

export default defineEventHandler(async (event) => {
  const target = "https://upstream.example.com";

  // Start from a safe header set (filters problematic proxy headers)
  const headers = getProxyRequestHeaders(event);

  // Add / override
  headers["x-forwarded-host"] = event.req.headers.get("host") ?? "";
  headers["x-my-header"] = "my-value";

  // Remove (example)
  delete headers["authorization"]; // or any header you don’t want to forward

  // If you need to "change origin", set Host to upstream host explicitly
  headers["host"] = "upstream.example.com";

  return proxyRequest(event, target, { headers });
});

If you prefer a ready-made proxy handler with a hook

The community h3-proxy wrapper exposes configureProxyRequest(event) specifically to customize the underlying proxyRequest options (including setting request headers), and also has a changeOrigin option for host/origin behavior. [2]

Security footnote (important if you’re touching hop-by-hop/body headers)

There’s a published request-smuggling issue in h3 < 1.15.5 involving a case-sensitive Transfer-Encoding: chunked check; upgrade to h3 ≥ 1.15.5 and avoid doing anything custom with Transfer-Encoding unless you fully understand the implications. [3]

Sources: [1] h3 proxy utils (getProxyRequestHeaders, proxyRequest) documentation, [2] h3-proxy README (configureProxyRequest, changeOrigin), [3] CVE write-up for h3 request smuggling / fix version.


Proxy mode header stripping won't work — Nitro route rules headers set response headers, not request headers.

Setting headers: { 'cookie': '', authorization: '', ... } in route rules doesn't strip outgoing request headers. Nitro's routeRules.headers applies response headers via setHeaders, while request header manipulation requires using h3's proxyRequest utility with getProxyRequestHeaders to build and customize the upstream request headers. The current implementation will still forward sensitive headers (Cookie, Authorization, etc.) to third-party endpoints.

🤖 Prompt for AI Agents
In `@src/module.ts` around lines 606 - 626, The current approach inside the
firstPartyPrivacy === 'proxy' branch is incorrect because
nuxt.options.routeRules.headers sets response headers, not request headers, so
sensitive headers will still be forwarded; instead, replace the sanitizedRoutes
routeRules approach with actual Nitro proxy handlers that use h3's proxyRequest
and getProxyRequestHeaders: for each entry in neededRoutes (use the same keys
from neededRoutes), register a Nitro route handler that calls
getProxyRequestHeaders(req) (or builds headers from req), deletes 'cookie',
'authorization', 'proxy-authorization', and 'x-csrf-token' from that header
object, then calls proxyRequest(event, config.proxy, { headers }) to forward the
request and returns the upstream response; remove the sanitizedRoutes assignment
to nuxt.options.routeRules and ensure the new handlers use the proxy target from
config.proxy so request headers are stripped before proxying.

Comment on lines 265 to 291
catch (err: unknown) {
clearTimeout(timeoutId)
const message = err instanceof Error ? err.message : 'Unknown error'
log('[proxy] Fetch error:', message)

// For analytics endpoints, return a graceful 204 No Content instead of a noisy 5xx error
// this avoids cluttering the user's console with errors for non-critical tracking requests
if (path.includes('/collect') || path.includes('/tr') || path.includes('/events')) {
event.node.res.statusCode = 204
return ''
}

// Return a graceful error response instead of crashing for other requests
if (message.includes('aborted') || message.includes('timeout')) {
throw createError({
statusCode: 504,
statusMessage: 'Upstream timeout',
message: `Request to ${new URL(targetUrl).hostname} timed out`,
})
}

throw createError({
statusCode: 502,
statusMessage: 'Bad Gateway',
message: `Failed to reach upstream: ${message}`,
})
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Upstream error message may leak internal details.

On line 289, message from the caught error is included in the createError response. For non-analytics endpoints, this could expose internal network topology or error details to the client (e.g., DNS resolution failures, internal hostnames). Consider sanitizing or generalizing the message.

🛡️ Suggested fix
     throw createError({
       statusCode: 502,
       statusMessage: 'Bad Gateway',
-      message: `Failed to reach upstream: ${message}`,
+      message: 'Failed to reach upstream service',
     })
🤖 Prompt for AI Agents
In `@src/runtime/server/proxy-handler.ts` around lines 265 - 291, The catch block
in proxy-handler.ts currently injects the raw caught error message into the
createError response (variable message used when throwing 502), which can leak
internal details; update the handler so that createError for Bad Gateway returns
a generic, non-revealing message (e.g., "Failed to reach upstream") and do not
include the raw error string in the client-facing response, while still logging
the full error internally via the existing log(...) call or another internal
logger; preserve the 204 behavior for analytics paths
(path.includes('/collect'...) block) and keep the 504 branch for timeouts but
ensure you only use new URL(targetUrl).hostname in the 504 message (or make that
generic too) and treat err as unknown safely (check instanceof Error before
accessing properties) before logging the full details.

Comment on lines 96 to 106
export function normalizeUserAgent(ua: string): string {
// We use a specific regex here for precision as it's the most reliable way
// to extract browser info from UA strings without a heavy parser.
const match = ua.match(/(Firefox|Edg|OPR|Opera|Safari|Chrome)\/(\d+)/)
if (match) {
const family = match[1] === 'Edg' ? 'Edge' : (match[1] === 'OPR' ? 'Opera' : match[1])
const majorVersion = match[2]
return `Mozilla/5.0 (compatible; ${family}/${majorVersion}.0)`
}
return 'Mozilla/5.0 (compatible)'
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Edge browser detection is ineffective due to regex match ordering.

The regex /(Firefox|Edg|OPR|Opera|Safari|Chrome)\/(\d+)/ matches the first occurrence in the UA string. Edge UA strings look like ...Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0Chrome/120 appears before Edg/120, so Edge is always identified as Chrome. The EdgEdge mapping on line 101 is effectively dead code.

For privacy normalization this is arguably fine (less specific = more private), but if the intent is to accurately normalize to the correct browser family, the regex alternation order should be reversed to match more specific tokens first, or use a different approach.

♻️ Suggested fix
-  const match = ua.match(/(Firefox|Edg|OPR|Opera|Safari|Chrome)\/(\d+)/)
+  const match = ua.match(/(Edg|OPR|Opera|Firefox|Chrome|Safari)\/(\d+)/)

This won't help either since .match() finds the first positional match. You'd need to scan for all matches and pick the most specific:

-  const match = ua.match(/(Firefox|Edg|OPR|Opera|Safari|Chrome)\/(\d+)/)
-  if (match) {
-    const family = match[1] === 'Edg' ? 'Edge' : (match[1] === 'OPR' ? 'Opera' : match[1])
-    const majorVersion = match[2]
-    return `Mozilla/5.0 (compatible; ${family}/${majorVersion}.0)`
-  }
+  // Match most specific browser token (order: Edge > Opera > Chrome > Firefox > Safari)
+  const browsers = [
+    { pattern: /Edg\/(\d+)/, name: 'Edge' },
+    { pattern: /OPR\/(\d+)/, name: 'Opera' },
+    { pattern: /Opera\/(\d+)/, name: 'Opera' },
+    { pattern: /Firefox\/(\d+)/, name: 'Firefox' },
+    { pattern: /Chrome\/(\d+)/, name: 'Chrome' },
+    { pattern: /Safari\/(\d+)/, name: 'Safari' },
+  ]
+  for (const { pattern, name } of browsers) {
+    const m = ua.match(pattern)
+    if (m) return `Mozilla/5.0 (compatible; ${name}/${m[1]}.0)`
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function normalizeUserAgent(ua: string): string {
// We use a specific regex here for precision as it's the most reliable way
// to extract browser info from UA strings without a heavy parser.
const match = ua.match(/(Firefox|Edg|OPR|Opera|Safari|Chrome)\/(\d+)/)
if (match) {
const family = match[1] === 'Edg' ? 'Edge' : (match[1] === 'OPR' ? 'Opera' : match[1])
const majorVersion = match[2]
return `Mozilla/5.0 (compatible; ${family}/${majorVersion}.0)`
}
return 'Mozilla/5.0 (compatible)'
}
export function normalizeUserAgent(ua: string): string {
// Match most specific browser token (order: Edge > Opera > Chrome > Firefox > Safari)
const browsers = [
{ pattern: /Edg\/(\d+)/, name: 'Edge' },
{ pattern: /OPR\/(\d+)/, name: 'Opera' },
{ pattern: /Opera\/(\d+)/, name: 'Opera' },
{ pattern: /Firefox\/(\d+)/, name: 'Firefox' },
{ pattern: /Chrome\/(\d+)/, name: 'Chrome' },
{ pattern: /Safari\/(\d+)/, name: 'Safari' },
]
for (const { pattern, name } of browsers) {
const m = ua.match(pattern)
if (m) return `Mozilla/5.0 (compatible; ${name}/${m[1]}.0)`
}
return 'Mozilla/5.0 (compatible)'
}
🤖 Prompt for AI Agents
In `@src/runtime/server/utils/privacy.ts` around lines 96 - 106,
normalizeUserAgent currently relies on ua.match(...) which returns the first
positional match so "Chrome" is found before "Edg" in Edge UAs; update
normalizeUserAgent to scan for browser tokens in a specificity-aware way (e.g.,
test for tokens in order of specificity like "Edg", "OPR"/"Opera", "Chrome",
"Safari", "Firefox" or use matchAll to collect all matches and prefer the most
specific) and then extract the corresponding major version to build the
family/majorVersion used to produce the normalized string (references: function
normalizeUserAgent, the match variable, and the family/majorVersion
construction).

Comment on lines 459 to 474
expect(captures.length).toBeGreaterThan(0)
const hasValidCapture = captures.some(c =>
c.path?.startsWith('/_proxy/segment')
&& (isAllowedDomain(c.targetUrl, 'segment.io') || isAllowedDomain(c.targetUrl, 'segment.com'))
&& c.privacy === 'anonymize',
)
expect(hasValidCapture).toBe(true)

// Verify ALL fingerprinting params are stripped
for (const capture of captures) {
const leaked = verifyFingerprintingStripped(capture)
expect(leaked).toEqual([])
}

await expect(captures).toMatchFileSnapshot('__snapshots__/proxy/segment.json')
}, 30000)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Some provider tests unconditionally expect captures, risking CI flakiness.

Segment (line 459), xPixel (line 479), and Snapchat (line 512) assert captures.length > 0 without the conditional if (captures.length > 0) guard used by other providers (GA, GTM, Meta, etc.). If these SDKs don't fire events in a headless CI environment (as noted for other providers), these tests will fail.

Consider wrapping these assertions in the same conditional pattern used by the other providers, or document why these specific providers are expected to always produce captures in headless mode.

Also applies to: 476-494, 512-527

🤖 Prompt for AI Agents
In `@test/e2e/first-party.test.ts` around lines 459 - 474, The tests for Segment
(uses variable captures and function verifyFingerprintingStripped)
unconditionally assert captures.length > 0 and then run expectations and
snapshot matching which can flake in headless CI; update the Segment block (and
the similar xPixel and Snapchat blocks) to mirror the other provider tests by
guarding the detailed assertions and snapshot check with if (captures.length >
0) { /* existing expectations: check path/targetUrl/privacy, loop calling
verifyFingerprintingStripped, and await
expect(captures).toMatchFileSnapshot(...) */ } else { /* optionally assert
captures.length === 0 or skip */ }, so the test only runs those checks when
captures were actually recorded. Ensure you modify the blocks that reference
captures, the hasValidCapture check, verifyFingerprintingStripped loop, and the
toMatchFileSnapshot call.

@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 👎.

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