From 6f3a54fae01012ea41a563c97bbb10053561dfd1 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 29 Apr 2026 09:45:10 +1000 Subject: [PATCH 1/4] fix(google-analytics): allow ga-audiences regional Google domains through proxy gtag.js fires ga-audiences beacons to the visitor's geo-localized Google ccTLD (www.google.com.tw, www.google.co.jp, ...), which the proxy allowlist rejected with "Domain not allowed". Add `www.google.*` to the proxy domains and a `matchDomain` helper that supports `*` wildcards for runtime allowlist checks. The build-time transform skips wildcard patterns since they have no literal form to rewrite. Resolves #728 --- packages/script/src/plugins/transform.ts | 5 ++- packages/script/src/registry.ts | 5 ++- .../src/runtime/server/proxy-handler.ts | 5 +-- .../src/runtime/server/utils/match-domain.ts | 15 ++++++++ test/unit/proxy-handler-match-domain.test.ts | 36 +++++++++++++++++++ 5 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/script/src/runtime/server/utils/match-domain.ts create mode 100644 test/unit/proxy-handler-match-domain.test.ts 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..013814287 --- /dev/null +++ b/packages/script/src/runtime/server/utils/match-domain.ts @@ -0,0 +1,15 @@ +const REGEX_ESCAPE_RE = /[.+?^${}()|[\]\\]/g + +/** + * Match a hostname against an allowlist pattern. + * Patterns may include `*` to match one or more non-dot characters; e.g. `www.google.*` + * matches `www.google.com`, `www.google.com.tw`, `www.google.co.jp`. Bare patterns also + * match subdomains, e.g. `google.com` matches `mail.google.com`. + */ +export function matchDomain(domain: string, pattern: string): boolean { + if (pattern.includes('*')) { + const re = new RegExp(`^${pattern.replace(REGEX_ESCAPE_RE, '\\$&').replace(/\*/g, '[^/]+')}$`) + return re.test(domain) + } + return domain === pattern || domain.endsWith(`.${pattern}`) +} 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..c7ac7bb4d --- /dev/null +++ b/test/unit/proxy-handler-match-domain.test.ts @@ -0,0 +1,36 @@ +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) + }) + + 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) + }) +}) From 78b158b39498d9bf77690ddcd73dee9a1c570359 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 29 Apr 2026 09:54:06 +1000 Subject: [PATCH 2/4] refactor(match-domain): walk literal segments instead of building a regex CodeQL flagged js/incomplete-hostname-regexp because the wildcard branch fed a free-form string to `new RegExp`, even though the existing escape covered `.`. Replace the regex compile with an indexOf-based literal segment walk; same semantics, no dynamic regex, no warning. --- .../src/runtime/server/utils/match-domain.ts | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/script/src/runtime/server/utils/match-domain.ts b/packages/script/src/runtime/server/utils/match-domain.ts index 013814287..20a82efd4 100644 --- a/packages/script/src/runtime/server/utils/match-domain.ts +++ b/packages/script/src/runtime/server/utils/match-domain.ts @@ -1,15 +1,41 @@ -const REGEX_ESCAPE_RE = /[.+?^${}()|[\]\\]/g - /** * Match a hostname against an allowlist pattern. - * Patterns may include `*` to match one or more non-dot characters; e.g. `www.google.*` - * matches `www.google.com`, `www.google.com.tw`, `www.google.co.jp`. Bare patterns also - * match subdomains, e.g. `google.com` matches `mail.google.com`. + * Patterns may include `*` as a wildcard that matches one or more characters + * (excluding `/`); e.g. `www.google.*` matches `www.google.com`, + * `www.google.com.tw`, `www.google.co.jp`. Bare patterns also match + * subdomains, e.g. `google.com` matches `mail.google.com`. */ export function matchDomain(domain: string, pattern: string): boolean { - if (pattern.includes('*')) { - const re = new RegExp(`^${pattern.replace(REGEX_ESCAPE_RE, '\\$&').replace(/\*/g, '[^/]+')}$`) - return re.test(domain) + if (!pattern.includes('*')) + return domain === pattern || domain.endsWith(`.${pattern}`) + + // Walk literal segments split on `*`. This avoids constructing a regex from + // a free-form string (and the static-analysis warnings that follow). Each + // `*` requires at least one matched character that is not `/`. + if (domain.includes('/')) + return false + const segments = pattern.split('*') + const lastIndex = segments.length - 1 + let cursor = 0 + for (let i = 0; i <= lastIndex; i++) { + const segment = segments[i] ?? '' + if (i === 0) { + if (!domain.startsWith(segment)) + return false + cursor = segment.length + continue + } + if (i === lastIndex) { + if (!domain.endsWith(segment)) + return false + const wildcardEnd = domain.length - segment.length + return wildcardEnd > cursor + } + const nextIdx = domain.indexOf(segment, cursor + 1) + if (nextIdx === -1) + return false + cursor = nextIdx + segment.length } - return domain === pattern || domain.endsWith(`.${pattern}`) + // Lone `*` pattern: any non-empty domain (already excluded `/` above). + return domain.length > 0 } From af4ac3fc8adc1101b21ebdd981472039d48123fd Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 29 Apr 2026 09:57:20 +1000 Subject: [PATCH 3/4] fix(match-domain): constrain wildcard to ccTLD shape The previous segment-walk wildcard accepted any character sequence after the prefix, which meant `www.google.*` would also match `www.google.attacker.com` if a request hostname were ever forged that way. Constrain `*` to a single trailing TLD position with a ccTLD shape (2-3 letter label, optionally `.<2-3 letter label>`), the only shape we actually need for geo-localized Google domains. Add a regression test asserting attacker-controlled suffixes are rejected. --- .../src/runtime/server/utils/match-domain.ts | 58 +++++++++---------- test/unit/proxy-handler-match-domain.test.ts | 10 ++++ 2 files changed, 36 insertions(+), 32 deletions(-) diff --git a/packages/script/src/runtime/server/utils/match-domain.ts b/packages/script/src/runtime/server/utils/match-domain.ts index 20a82efd4..a5daa2684 100644 --- a/packages/script/src/runtime/server/utils/match-domain.ts +++ b/packages/script/src/runtime/server/utils/match-domain.ts @@ -1,41 +1,35 @@ /** * Match a hostname against an allowlist pattern. - * Patterns may include `*` as a wildcard that matches one or more characters - * (excluding `/`); e.g. `www.google.*` matches `www.google.com`, - * `www.google.com.tw`, `www.google.co.jp`. Bare patterns also match - * subdomains, e.g. `google.com` matches `mail.google.com`. + * + * Patterns may include `*` as a TLD wildcard that matches a top-level domain + * suffix shaped like a ccTLD: either a single 2-3 letter label (`com`, `tw`, + * `jp`, `de`) or two such labels separated by a dot (`co.jp`, `com.tw`, + * `com.hk`). Used for geo-localized Google ccTLDs: + * `www.google.*` matches `www.google.com`, `www.google.com.tw`, `www.google.co.jp`. + * + * Crucially, the wildcard does NOT match arbitrary attacker-controlled + * subdomains: `www.google.attacker.com` is rejected because `attacker` is + * longer than 3 characters (the cap that excludes generic SLDs like + * `attacker`, `evil-domain`, etc). + * + * Bare patterns also match subdomains, e.g. `google.com` matches `mail.google.com`. */ +const TLD_WILDCARD_RE = /^[a-z]{2,3}(?:\.[a-z]{2,3})?$/i + export function matchDomain(domain: string, pattern: string): boolean { if (!pattern.includes('*')) return domain === pattern || domain.endsWith(`.${pattern}`) - // Walk literal segments split on `*`. This avoids constructing a regex from - // a free-form string (and the static-analysis warnings that follow). Each - // `*` requires at least one matched character that is not `/`. - if (domain.includes('/')) + // 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 segments = pattern.split('*') - const lastIndex = segments.length - 1 - let cursor = 0 - for (let i = 0; i <= lastIndex; i++) { - const segment = segments[i] ?? '' - if (i === 0) { - if (!domain.startsWith(segment)) - return false - cursor = segment.length - continue - } - if (i === lastIndex) { - if (!domain.endsWith(segment)) - return false - const wildcardEnd = domain.length - segment.length - return wildcardEnd > cursor - } - const nextIdx = domain.indexOf(segment, cursor + 1) - if (nextIdx === -1) - return false - cursor = nextIdx + segment.length - } - // Lone `*` pattern: any non-empty domain (already excluded `/` above). - return domain.length > 0 + + 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 index c7ac7bb4d..4841b64ba 100644 --- a/test/unit/proxy-handler-match-domain.test.ts +++ b/test/unit/proxy-handler-match-domain.test.ts @@ -29,6 +29,16 @@ describe('matchDomain', () => { 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) + }) + 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) From 91b472ba72ac34bf6ca4c9241f9b2f848eac5b42 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Wed, 29 Apr 2026 10:05:57 +1000 Subject: [PATCH 4/4] fix(match-domain): tighten TLD wildcard to canonical ccTLD shapes only The previous regex `[a-z]{2,3}(?:\.[a-z]{2,3})?` accepted any pair of 2-3 letter labels, so `www.google.foo.bar` would match `www.google.*`. Restrict to `com`, any 2-letter ccTLD, and `com.` / `co.`. This covers every Google geo-localized cctld in practice while rejecting attacker-controlled two-label suffixes. --- .../src/runtime/server/utils/match-domain.ts | 18 ++++++++++-------- test/unit/proxy-handler-match-domain.test.ts | 3 +++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/script/src/runtime/server/utils/match-domain.ts b/packages/script/src/runtime/server/utils/match-domain.ts index a5daa2684..f05063293 100644 --- a/packages/script/src/runtime/server/utils/match-domain.ts +++ b/packages/script/src/runtime/server/utils/match-domain.ts @@ -2,19 +2,21 @@ * Match a hostname against an allowlist pattern. * * Patterns may include `*` as a TLD wildcard that matches a top-level domain - * suffix shaped like a ccTLD: either a single 2-3 letter label (`com`, `tw`, - * `jp`, `de`) or two such labels separated by a dot (`co.jp`, `com.tw`, - * `com.hk`). Used for geo-localized Google ccTLDs: + * 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`. * - * Crucially, the wildcard does NOT match arbitrary attacker-controlled - * subdomains: `www.google.attacker.com` is rejected because `attacker` is - * longer than 3 characters (the cap that excludes generic SLDs like - * `attacker`, `evil-domain`, etc). + * 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 = /^[a-z]{2,3}(?:\.[a-z]{2,3})?$/i +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('*')) diff --git a/test/unit/proxy-handler-match-domain.test.ts b/test/unit/proxy-handler-match-domain.test.ts index 4841b64ba..c255bf7cc 100644 --- a/test/unit/proxy-handler-match-domain.test.ts +++ b/test/unit/proxy-handler-match-domain.test.ts @@ -37,6 +37,9 @@ describe('matchDomain', () => { 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', () => {