Skip to content

Commit 9ac9948

Browse files
authored
fix(google-analytics): allow ga-audiences regional Google domains through proxy (#729)
1 parent ceecf57 commit 9ac9948

5 files changed

Lines changed: 97 additions & 4 deletions

File tree

‎packages/script/src/plugins/transform.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,10 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
465465
? options.proxyConfigs?.[proxyConfigKey]
466466
: undefined
467467
// Derive rewrites from domains: { from: domain, to: proxyPrefix/domain }
468-
constproxyRewrites=proxyConfig?.domains?.map(domain=>({
468+
// Skip wildcard patterns — those exist only for runtime allowlist matching of
469+
// dynamically-constructed URLs (e.g. ga-audiences geo-localized cctlds) and have
470+
// no literal form to rewrite at build time.
471+
constproxyRewrites=proxyConfig?.domains?.filter(domain=>!domain.includes('*')).map(domain=>({
469472
from: domain,
470473
to: `${options.proxyPrefix}/${domain}`,
471474
}))

‎packages/script/src/registry.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -734,7 +734,10 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
734734
},
735735
},
736736
proxy: {
737-
domains: ['www.google-analytics.com','analytics.google.com','stats.g.doubleclick.net','pagead2.googlesyndication.com','www.googleadservices.com','googleads.g.doubleclick.net','www.google.com','www.googletagmanager.com'],
737+
// `www.google.com` covers static URLs (www.google.com/g/collect) rewritten at build time;
738+
// `www.google.*` covers the geo-localized ga-audiences beacon, which gtag.js dynamically
739+
// fires to the visitor's local Google cctld (www.google.com.tw, www.google.co.jp, ...).
740+
domains: ['www.google-analytics.com','analytics.google.com','stats.g.doubleclick.net','pagead2.googlesyndication.com','www.googleadservices.com','googleads.g.doubleclick.net','www.google.com','www.google.*','www.googletagmanager.com'],
738741
privacy: PRIVACY_HEATMAP,
739742
},
740743
partytown: {forwards: ['dataLayer.push','gtag']},

‎packages/script/src/runtime/server/proxy-handler.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
importtype{ProxyPrivacyInput,ResolvedProxyPrivacy}from'./utils/privacy'
22
import{createError,defineEventHandler,getHeaders,getQuery,getRequestIP,getRequestWebStream,readBody,setResponseHeader,setResponseStatus}from'h3'
33
import{useNitroApp,useRuntimeConfig}from'nitropack/runtime'
4+
import{matchDomain}from'./utils/match-domain'
45
import{
56
anonymizeIP,
67
mergePrivacy,
@@ -83,10 +84,10 @@ export default defineEventHandler(async (event) => {
8384
})
8485
}
8586

86-
// Find privacy config by matching domain (exact or parent domain match)
87+
// Find privacy config by matching domain (exact, parent domain, or wildcard pattern)
8788
letperScriptInput: ProxyPrivacyInput|undefined
8889
for(const[configDomain,privacyInput]ofObject.entries(domainPrivacy)){
89-
if(domain===configDomain||domain.endsWith(`.${configDomain}`)){
90+
if(matchDomain(domain,configDomain)){
9091
perScriptInput=privacyInput
9192
break
9293
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* Match a hostname against an allowlist pattern.
3+
*
4+
* Patterns may include `*` as a TLD wildcard that matches a top-level domain
5+
* suffix shaped like a real ccTLD or gTLD:
6+
* - `com` (the canonical gTLD we care about)
7+
* - any 2-letter ccTLD (`tw`, `jp`, `de`, ...)
8+
* - regional `com.<cc>` or `co.<cc>` (e.g. `com.tw`, `co.jp`, `com.hk`)
9+
*
10+
* Used for geo-localized Google ccTLDs:
11+
* `www.google.*` matches `www.google.com`, `www.google.com.tw`, `www.google.co.jp`.
12+
*
13+
* The pattern is intentionally narrow: it rejects attacker-controlled suffixes
14+
* like `www.google.foo.bar` (two arbitrary 3-letter labels) or
15+
* `www.google.attacker.com` (long second-level label).
16+
*
17+
* Bare patterns also match subdomains, e.g. `google.com` matches `mail.google.com`.
18+
*/
19+
constTLD_WILDCARD_RE=/^(?:com|[a-z]{2}|(?:com|co)\.[a-z]{2})$/i
20+
21+
exportfunctionmatchDomain(domain: string,pattern: string): boolean{
22+
if(!pattern.includes('*'))
23+
returndomain===pattern||domain.endsWith(`.${pattern}`)
24+
25+
// Only support a trailing single `*` wildcard for TLD matching (the only
26+
// shape we use in practice). Reject any other pattern shape rather than
27+
// silently allowing it.
28+
if(!pattern.endsWith('*')||pattern.indexOf('*')!==pattern.length-1)
29+
returnfalse
30+
31+
constprefix=pattern.slice(0,-1)// includes trailing dot, e.g. "www.google."
32+
if(!domain.startsWith(prefix))
33+
returnfalse
34+
35+
consttld=domain.slice(prefix.length)
36+
returnTLD_WILDCARD_RE.test(tld)
37+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import{describe,expect,it}from'vitest'
2+
import{matchDomain}from'../../packages/script/src/runtime/server/utils/match-domain'
3+
4+
describe('matchDomain',()=>{
5+
it('matches exact hostname',()=>{
6+
expect(matchDomain('www.google-analytics.com','www.google-analytics.com')).toBe(true)
7+
})
8+
9+
it('matches subdomain via parent pattern',()=>{
10+
expect(matchDomain('mail.google.com','google.com')).toBe(true)
11+
expect(matchDomain('google.com','google.com')).toBe(true)
12+
})
13+
14+
it('rejects non-matching hostname',()=>{
15+
expect(matchDomain('evil.com','google.com')).toBe(false)
16+
expect(matchDomain('googleX.com','google.com')).toBe(false)
17+
})
18+
19+
// Issue #728: ga-audiences fires to www.google.<cctld> based on geo
20+
it('matches geo-localized Google ccTLDs via wildcard',()=>{
21+
expect(matchDomain('www.google.com','www.google.*')).toBe(true)
22+
expect(matchDomain('www.google.com.tw','www.google.*')).toBe(true)
23+
expect(matchDomain('www.google.co.jp','www.google.*')).toBe(true)
24+
expect(matchDomain('www.google.com.hk','www.google.*')).toBe(true)
25+
})
26+
27+
it('wildcard does not match a different host root',()=>{
28+
expect(matchDomain('evil.google.com','www.google.*')).toBe(false)
29+
expect(matchDomain('www.googleX.com','www.google.*')).toBe(false)
30+
})
31+
32+
// Security: the wildcard must not match attacker-controlled subdomains.
33+
// Without a TLD shape constraint, `*` would match `attacker.com` here.
34+
it('wildcard rejects attacker-controlled suffixes',()=>{
35+
expect(matchDomain('www.google.attacker.com','www.google.*')).toBe(false)
36+
expect(matchDomain('www.google.com.attacker.com','www.google.*')).toBe(false)
37+
expect(matchDomain('www.google.evil-domain.com','www.google.*')).toBe(false)
38+
// Three or more labels in the suffix → not a valid ccTLD shape
39+
expect(matchDomain('www.google.a.b.c','www.google.*')).toBe(false)
40+
// Two arbitrary 3-letter labels are not a real ccTLD shape; only com.<cc> / co.<cc> allowed
41+
expect(matchDomain('www.google.foo.bar','www.google.*')).toBe(false)
42+
expect(matchDomain('www.google.abc.xyz','www.google.*')).toBe(false)
43+
})
44+
45+
it('escapes regex metachars in the pattern',()=>{
46+
expect(matchDomain('foo.bar.com','foo+bar.com')).toBe(false)
47+
expect(matchDomain('foo+bar.com','foo+bar.com')).toBe(true)
48+
})
49+
})

0 commit comments

Comments
 (0)