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
5 changes: 4 additions & 1 deletion packages/script/src/plugins/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
}))
Expand Down
5 changes: 4 additions & 1 deletion packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,10 @@ export async function registry(resolve?: (path: string) => Promise<string>): 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'] },
Expand Down
5 changes: 3 additions & 2 deletions packages/script/src/runtime/server/proxy-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
37 changes: 37 additions & 0 deletions packages/script/src/runtime/server/utils/match-domain.ts
Original file line number Diff line number Diff line change
@@ -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.<cc>` or `co.<cc>` (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)
}
49 changes: 49 additions & 0 deletions test/unit/proxy-handler-match-domain.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
})

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.<cctld> 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.<cc> / co.<cc> 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)
})
})
Loading