diff --git a/packages/script/src/plugins/transform.ts b/packages/script/src/plugins/transform.ts index cda303475..302e647da 100644 --- a/packages/script/src/plugins/transform.ts +++ b/packages/script/src/plugins/transform.ts @@ -465,7 +465,10 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti ? options.proxyConfigs?.[proxyConfigKey] : undefined // Derive rewrites from domains: { from: domain, to: proxyPrefix/domain } - const proxyRewrites = proxyConfig?.domains?.map(domain => ({ + // Skip wildcard patterns — those exist only for runtime allowlist matching of + // dynamically-constructed URLs (e.g. ga-audiences geo-localized cctlds) and have + // no literal form to rewrite at build time. + const proxyRewrites = proxyConfig?.domains?.filter(domain => !domain.includes('*')).map(domain => ({ from: domain, to: `${options.proxyPrefix}/${domain}`, })) diff --git a/packages/script/src/registry.ts b/packages/script/src/registry.ts index 1c9a2364f..5a74a47a2 100644 --- a/packages/script/src/registry.ts +++ b/packages/script/src/registry.ts @@ -734,7 +734,10 @@ export async function registry(resolve?: (path: string) => Promise): Pro }, }, proxy: { - 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'], + // `www.google.com` covers static URLs (www.google.com/g/collect) rewritten at build time; + // `www.google.*` covers the geo-localized ga-audiences beacon, which gtag.js dynamically + // fires to the visitor's local Google cctld (www.google.com.tw, www.google.co.jp, ...). + 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'], privacy: PRIVACY_HEATMAP, }, partytown: { forwards: ['dataLayer.push', 'gtag'] }, diff --git a/packages/script/src/runtime/server/proxy-handler.ts b/packages/script/src/runtime/server/proxy-handler.ts index ccf7b0ee6..53c38ee8a 100644 --- a/packages/script/src/runtime/server/proxy-handler.ts +++ b/packages/script/src/runtime/server/proxy-handler.ts @@ -1,6 +1,7 @@ import type { ProxyPrivacyInput, ResolvedProxyPrivacy } from './utils/privacy' import { createError, defineEventHandler, getHeaders, getQuery, getRequestIP, getRequestWebStream, readBody, setResponseHeader, setResponseStatus } from 'h3' import { useNitroApp, useRuntimeConfig } from 'nitropack/runtime' +import { matchDomain } from './utils/match-domain' import { anonymizeIP, mergePrivacy, @@ -83,10 +84,10 @@ export default defineEventHandler(async (event) => { }) } - // Find privacy config by matching domain (exact or parent domain match) + // Find privacy config by matching domain (exact, parent domain, or wildcard pattern) let perScriptInput: ProxyPrivacyInput | undefined for (const [configDomain, privacyInput] of Object.entries(domainPrivacy)) { - if (domain === configDomain || domain.endsWith(`.${configDomain}`)) { + if (matchDomain(domain, configDomain)) { perScriptInput = privacyInput break } diff --git a/packages/script/src/runtime/server/utils/match-domain.ts b/packages/script/src/runtime/server/utils/match-domain.ts new file mode 100644 index 000000000..f05063293 --- /dev/null +++ b/packages/script/src/runtime/server/utils/match-domain.ts @@ -0,0 +1,37 @@ +/** + * Match a hostname against an allowlist pattern. + * + * Patterns may include `*` as a TLD wildcard that matches a top-level domain + * suffix shaped like a real ccTLD or gTLD: + * - `com` (the canonical gTLD we care about) + * - any 2-letter ccTLD (`tw`, `jp`, `de`, ...) + * - regional `com.` or `co.` (e.g. `com.tw`, `co.jp`, `com.hk`) + * + * Used for geo-localized Google ccTLDs: + * `www.google.*` matches `www.google.com`, `www.google.com.tw`, `www.google.co.jp`. + * + * The pattern is intentionally narrow: it rejects attacker-controlled suffixes + * like `www.google.foo.bar` (two arbitrary 3-letter labels) or + * `www.google.attacker.com` (long second-level label). + * + * Bare patterns also match subdomains, e.g. `google.com` matches `mail.google.com`. + */ +const TLD_WILDCARD_RE = /^(?:com|[a-z]{2}|(?:com|co)\.[a-z]{2})$/i + +export function matchDomain(domain: string, pattern: string): boolean { + if (!pattern.includes('*')) + return domain === pattern || domain.endsWith(`.${pattern}`) + + // Only support a trailing single `*` wildcard for TLD matching (the only + // shape we use in practice). Reject any other pattern shape rather than + // silently allowing it. + if (!pattern.endsWith('*') || pattern.indexOf('*') !== pattern.length - 1) + return false + + const prefix = pattern.slice(0, -1) // includes trailing dot, e.g. "www.google." + if (!domain.startsWith(prefix)) + return false + + const tld = domain.slice(prefix.length) + return TLD_WILDCARD_RE.test(tld) +} diff --git a/test/unit/proxy-handler-match-domain.test.ts b/test/unit/proxy-handler-match-domain.test.ts new file mode 100644 index 000000000..c255bf7cc --- /dev/null +++ b/test/unit/proxy-handler-match-domain.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { matchDomain } from '../../packages/script/src/runtime/server/utils/match-domain' + +describe('matchDomain', () => { + it('matches exact hostname', () => { + expect(matchDomain('www.google-analytics.com', 'www.google-analytics.com')).toBe(true) + }) + + it('matches subdomain via parent pattern', () => { + expect(matchDomain('mail.google.com', 'google.com')).toBe(true) + expect(matchDomain('google.com', 'google.com')).toBe(true) + }) + + it('rejects non-matching hostname', () => { + expect(matchDomain('evil.com', 'google.com')).toBe(false) + expect(matchDomain('googleX.com', 'google.com')).toBe(false) + }) + + // Issue #728: ga-audiences fires to www.google. based on geo + it('matches geo-localized Google ccTLDs via wildcard', () => { + expect(matchDomain('www.google.com', 'www.google.*')).toBe(true) + expect(matchDomain('www.google.com.tw', 'www.google.*')).toBe(true) + expect(matchDomain('www.google.co.jp', 'www.google.*')).toBe(true) + expect(matchDomain('www.google.com.hk', 'www.google.*')).toBe(true) + }) + + it('wildcard does not match a different host root', () => { + expect(matchDomain('evil.google.com', 'www.google.*')).toBe(false) + expect(matchDomain('www.googleX.com', 'www.google.*')).toBe(false) + }) + + // Security: the wildcard must not match attacker-controlled subdomains. + // Without a TLD shape constraint, `*` would match `attacker.com` here. + it('wildcard rejects attacker-controlled suffixes', () => { + expect(matchDomain('www.google.attacker.com', 'www.google.*')).toBe(false) + expect(matchDomain('www.google.com.attacker.com', 'www.google.*')).toBe(false) + expect(matchDomain('www.google.evil-domain.com', 'www.google.*')).toBe(false) + // Three or more labels in the suffix → not a valid ccTLD shape + expect(matchDomain('www.google.a.b.c', 'www.google.*')).toBe(false) + // Two arbitrary 3-letter labels are not a real ccTLD shape; only com. / co. allowed + expect(matchDomain('www.google.foo.bar', 'www.google.*')).toBe(false) + expect(matchDomain('www.google.abc.xyz', 'www.google.*')).toBe(false) + }) + + it('escapes regex metachars in the pattern', () => { + expect(matchDomain('foo.bar.com', 'foo+bar.com')).toBe(false) + expect(matchDomain('foo+bar.com', 'foo+bar.com')).toBe(true) + }) +})