diff --git a/packages/script/src/runtime/server/proxy-handler.ts b/packages/script/src/runtime/server/proxy-handler.ts index 51fa6709..741a9c1f 100644 --- a/packages/script/src/runtime/server/proxy-handler.ts +++ b/packages/script/src/runtime/server/proxy-handler.ts @@ -28,6 +28,9 @@ interface ProxyConfig { const COMPRESSION_RE = /gzip|deflate|br|compress|base64/i const CLIENT_HINT_VERSION_RE = /;v="(\d+)\.[^"]*"/g +/** GA4 collection hosts used by gtag.js, including the www.google.com copy of /g/collect. */ +const GA_COLLECT_HOST_RE = /(?:^|\.)analytics\.google\.com$|\.google-analytics\.com$|^www\.google\.com$/ +const GA_COLLECT_PATH_RE = /\/g\/(?:s\/)?collect$/ const MAX_TRANSFORM_BODY_SIZE = 2 * 1024 * 1024 const UPSTREAM_TIMEOUT_MS = 15000 const MAX_UPSTREAM_REDIRECTS = 5 @@ -502,6 +505,20 @@ export default defineEventHandler(async (event) => { .join(', ') } + // GA4 geolocates by the connecting IP (this server) and ignores X-Forwarded-For. + // `_uip` carries the same, possibly anonymized, client IP as the header instead. + // With IP privacy on, a client-supplied `_uip` is replaced so it cannot bypass anonymization. + const isGaCollect = GA_COLLECT_HOST_RE.test(domain) && GA_COLLECT_PATH_RE.test(remainingPath.split('?')[0] || '') + if (isGaCollect && (privacy.ip || originalQuery._uip === undefined)) { + const userIP = headers['x-forwarded-for']?.split(',')[0]?.trim() + const queryIdx = targetUrl.indexOf('?') + const base = queryIdx === -1 ? targetUrl : targetUrl.slice(0, queryIdx) + const params = queryIdx === -1 ? [] : targetUrl.slice(queryIdx + 1).split('&').filter(p => p && p !== '_uip' && !p.startsWith('_uip=')) + if (userIP) + params.push(`_uip=${encodeURIComponent(userIP)}`) + targetUrl = params.length ? `${base}?${params.join('&')}` : base + } + // Process request body: buffer the raw bytes once so privacy transforms can // decode them and redirect hops can replay the exact body upstream. let body: string | Record | unknown[] | number | boolean | null | undefined diff --git a/test/unit/proxy-handler-ga-uip.test.ts b/test/unit/proxy-handler-ga-uip.test.ts new file mode 100644 index 00000000..d4ef9354 --- /dev/null +++ b/test/unit/proxy-handler-ga-uip.test.ts @@ -0,0 +1,139 @@ +import type { Server } from 'node:http' +import { createServer } from 'node:http' +import { createApp, toNodeListener } from 'h3' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +/** + * Issue #939: GA4 geolocates collection hits by the connecting IP and ignores + * X-Forwarded-For, so the proxy forwards the first X-Forwarded-For entry as `_uip`. + */ + +vi.mock('#nuxt-scripts/nitro', () => ({ + useRuntimeConfig: () => ({ + 'nuxt-scripts-proxy': { + proxyPrefix: '/_scripts/p', + domainPrivacy: { + 'www.google-analytics.com': true, + 'region1.google-analytics.com': false, + 'analytics.google.com': true, + 'www.google.com': true, + 'stats.g.doubleclick.net': true, + 'www.facebook.com': true, + }, + debug: false, + }, + }), + useNitroApp: () => ({ + hooks: { callHook: async () => {} }, + }), +})) + +vi.mock('../../packages/script/src/runtime/server/utils/network-host', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createPublicNetworkDispatcher: async () => ({ + fetch: (...args: Parameters) => globalThis.fetch(...args), + close: async () => {}, + }), + } +}) + +describe('proxy handler - GA4 client IP (#939)', () => { + let proxyServer: Server + let proxyPort: number + let upstreamServer: Server + let upstreamPort: number + let realFetch: typeof globalThis.fetch + let lastTargetUrl = '' + + beforeAll(async () => { + upstreamServer = createServer((_req, res) => { + res.writeHead(204) + res.end() + }) + await new Promise(resolve => upstreamServer.listen(0, resolve)) + upstreamPort = (upstreamServer.address() as any).port + + realFetch = globalThis.fetch + globalThis.fetch = async (input: any, init?: any) => { + const reqUrl = typeof input === 'string' ? input : input.url + lastTargetUrl = reqUrl + const url = new URL(reqUrl) + return realFetch(`http://127.0.0.1:${upstreamPort}${url.pathname}${url.search}`, { ...init, headers: {} }) + } + + const mod = await import('../../packages/script/src/runtime/server/proxy-handler') + const app = createApp() + app.use(mod.default) + proxyServer = createServer(toNodeListener(app)) + await new Promise(resolve => proxyServer.listen(0, resolve)) + proxyPort = (proxyServer.address() as any).port + }) + + beforeEach(() => { + lastTargetUrl = '' + }) + + afterAll(() => { + if (realFetch) + globalThis.fetch = realFetch + upstreamServer?.close() + proxyServer?.close() + }) + + async function post(path: string, xForwardedFor = '203.0.113.7') { + const res = await realFetch(`http://127.0.0.1:${proxyPort}/_scripts/p/${path}`, { + method: 'POST', + headers: { 'x-forwarded-for': xForwardedFor }, + }) + expect(res.status).toBe(204) + return lastTargetUrl + } + + it('adds the anonymized client IP to www.google-analytics.com /g/collect', async () => { + expect(await post('www.google-analytics.com/g/collect?v=2&tid=G-TEST')) + .toBe('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST&_uip=203.0.113.0') + expect(await post('www.google-analytics.com/g/collect?v=2&tid=G-TEST', '2001:db8:abcd:12::1')) + .toBe('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST&_uip=2001%3Adb8%3Aabcd%3A%3A') + }) + + it('uses the first X-Forwarded-For entry for region1.analytics.google.com', async () => { + expect(await post('region1.analytics.google.com/g/collect?v=2', '198.51.100.23, 10.0.0.1')) + .toBe('https://region1.analytics.google.com/g/collect?v=2&_uip=198.51.100.0') + }) + + it('adds the client IP to the www.google.com copy of /g/collect', async () => { + expect(await post('www.google.com/g/collect?v=2&gaf=1')) + .toBe('https://www.google.com/g/collect?v=2&gaf=1&_uip=203.0.113.0') + }) + + it('adds the client IP to /g/s/collect', async () => { + expect(await post('www.google-analytics.com/g/s/collect?v=2')) + .toBe('https://www.google-analytics.com/g/s/collect?v=2&_uip=203.0.113.0') + }) + + it('uses the raw client IP when IP privacy is off', async () => { + expect(await post('region1.google-analytics.com/g/collect?v=2')) + .toBe('https://region1.google-analytics.com/g/collect?v=2&_uip=203.0.113.7') + }) + + it('replaces a client-supplied _uip with the anonymized client IP when IP privacy is on', async () => { + expect(await post('www.google-analytics.com/g/collect?v=2&_uip=192.0.2.55&tid=G-TEST')) + .toBe('https://www.google-analytics.com/g/collect?v=2&tid=G-TEST&_uip=203.0.113.0') + }) + + it('keeps a client-supplied _uip when IP privacy is off', async () => { + expect(await post('region1.google-analytics.com/g/collect?v=2&_uip=192.0.2.55')) + .toBe('https://region1.google-analytics.com/g/collect?v=2&_uip=192.0.2.55') + }) + + it('leaves non-GA4 endpoints unchanged', async () => { + expect(await post('www.google.com/pagead/1p-conversion/123/?v=2')) + .toBe('https://www.google.com/pagead/1p-conversion/123/?v=2') + expect(await post('stats.g.doubleclick.net/g/collect?v=2')) + .toBe('https://stats.g.doubleclick.net/g/collect?v=2') + expect(await post('www.facebook.com/tr?id=1&ev=PageView')) + .toBe('https://www.facebook.com/tr?id=1&ev=PageView') + }) +})