Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions packages/script/src/runtime/server/proxy-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> | unknown[] | number | boolean | null | undefined
Expand Down
139 changes: 139 additions & 0 deletions test/unit/proxy-handler-ga-uip.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('../../packages/script/src/runtime/server/utils/network-host')>()
return {
...actual,
createPublicNetworkDispatcher: async () => ({
fetch: (...args: Parameters<typeof fetch>) => 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<void>(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<void>(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')
})
})
Loading