Skip to content

Commit 985d54f

Browse files
authored
fix(proxy): cover dynamic third-party domains for Clarity and PostHog (#732)
1 parent 9ac9948 commit 985d54f

4 files changed

Lines changed: 71 additions & 14 deletions

File tree

‎packages/script/src/registry.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,10 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
334334
return`${proxyPrefix}/${host}`
335335
},
336336
},
337+
// PostHog supports `apiHost` for self-hosted instances and custom
338+
// reverse proxies. Without this, custom-host users are 403'd through
339+
// the proxy because only the SaaS US/EU hosts are allowlisted.
340+
configDomainFields: ['apiHost'],
337341
},
338342
}),
339343
def('fathomAnalytics',{
@@ -575,7 +579,13 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
575579
},
576580
},
577581
proxy: {
578-
domains: ['www.clarity.ms','scripts.clarity.ms','d.clarity.ms','e.clarity.ms','k.clarity.ms','c.clarity.ms','a.clarity.ms','b.clarity.ms'],
582+
// Clarity buckets visitors across letter/hash-prefixed shards (a/b/c/d/e/k/...).
583+
// Microsoft adds shards over time, so an enumerated list silently 403s
584+
// through the proxy when an unlisted letter is rolled out (#728-class bug).
585+
// `*.clarity.ms` covers the full surface at runtime; `www.clarity.ms` is
586+
// kept literal so the build-time URL rewrite (which filters wildcards)
587+
// can still rewrite `https://www.clarity.ms/tag/<id>` in bundled SDKs.
588+
domains: ['www.clarity.ms','*.clarity.ms'],
579589
privacy: PRIVACY_HEATMAP,
580590
},
581591
partytown: {forwards: ['clarity']},

‎packages/script/src/runtime/server/utils/match-domain.ts‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,49 @@
11
/**
22
* Match a hostname against an allowlist pattern.
33
*
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`)
4+
* Two wildcard shapes are supported:
95
*
10-
* Used for geo-localized Google ccTLDs:
11-
* `www.google.*` matches `www.google.com`, `www.google.com.tw`, `www.google.co.jp`.
6+
* 1. Trailing TLD wildcard `host.<tld>.*` — matches a top-level domain suffix
7+
* shaped like a real ccTLD or gTLD:
8+
* - `com` (the canonical gTLD we care about)
9+
* - any 2-letter ccTLD (`tw`, `jp`, `de`, ...)
10+
* - regional `com.<cc>` or `co.<cc>` (e.g. `com.tw`, `co.jp`, `com.hk`)
1211
*
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).
12+
* Used for geo-localized Google ccTLDs:
13+
* `www.google.*` matches `www.google.com`, `www.google.com.tw`, `www.google.co.jp`.
14+
*
15+
* The pattern is intentionally narrow: it rejects attacker-controlled
16+
* suffixes like `www.google.foo.bar` (two arbitrary 3-letter labels) or
17+
* `www.google.attacker.com` (long second-level label).
18+
*
19+
* 2. Leading subdomain wildcard `*.host.tld` — matches exactly one DNS label
20+
* in front of the suffix. The wildcard label must be one or more non-dot
21+
* chars; it does not match the bare suffix or multi-label prefixes.
22+
*
23+
* Used for vendors that bucket clients across letter/hash-prefixed shards:
24+
* `*.clarity.ms` matches `a.clarity.ms`, `www.clarity.ms`, `scripts.clarity.ms`.
25+
* It does NOT match `clarity.ms` (no prefix) or `a.b.clarity.ms` (multi-label).
1626
*
1727
* Bare patterns also match subdomains, e.g. `google.com` matches `mail.google.com`.
1828
*/
1929
constTLD_WILDCARD_RE=/^(?:com|[a-z]{2}|(?:com|co)\.[a-z]{2})$/i
30+
constSUBDOMAIN_LABEL_RE=/^[^.]+$/
2031

2132
exportfunctionmatchDomain(domain: string,pattern: string): boolean{
2233
if(!pattern.includes('*'))
2334
returndomain===pattern||domain.endsWith(`.${pattern}`)
2435

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.
36+
// Leading subdomain wildcard: `*.host.tld` matches exactly one non-dot label.
37+
if(pattern.startsWith('*.')&&pattern.indexOf('*')===0){
38+
constsuffix=pattern.slice(2)// drop leading "*."
39+
if(!domain.endsWith(`.${suffix}`))
40+
returnfalse
41+
constlabel=domain.slice(0,-(suffix.length+1))
42+
returnSUBDOMAIN_LABEL_RE.test(label)
43+
}
44+
45+
// Trailing TLD wildcard: only support a single trailing `*`. Any other
46+
// wildcard shape is rejected rather than silently allowed.
2847
if(!pattern.endsWith('*')||pattern.indexOf('*')!==pattern.length-1)
2948
returnfalse
3049

‎test/unit/first-party.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,13 @@ describe('first-party mode', () => {
198198
apiUrl: 'https://events.analytics.example.com',
199199
},configs.databuddyAnalytics)).toEqual(['cdn.analytics.example.com','events.analytics.example.com'])
200200
})
201+
202+
it('derives extra allowlist domains for self-hosted PostHog apiHost',async()=>{
203+
constconfigs=awaitgetProxyConfigs()
204+
expect(resolveConfiguredProxyDomains({
205+
apiHost: 'https://posthog.example.com',
206+
},configs.posthog)).toEqual(['posthog.example.com'])
207+
})
201208
})
202209

203210
describe('full chain: capabilities → proxy config → domains',()=>{

‎test/unit/proxy-handler-match-domain.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,25 @@ describe('matchDomain', () => {
4646
expect(matchDomain('foo.bar.com','foo+bar.com')).toBe(false)
4747
expect(matchDomain('foo+bar.com','foo+bar.com')).toBe(true)
4848
})
49+
50+
// Microsoft Clarity buckets visitors across letter-prefixed shards
51+
// (a/b/c/d/e/k/scripts/www/...); enumerating them silently 403s through
52+
// the proxy when a new shard is rolled out.
53+
it('matches single-label subdomain via leading wildcard',()=>{
54+
expect(matchDomain('a.clarity.ms','*.clarity.ms')).toBe(true)
55+
expect(matchDomain('z.clarity.ms','*.clarity.ms')).toBe(true)
56+
expect(matchDomain('whatever.clarity.ms','*.clarity.ms')).toBe(true)
57+
expect(matchDomain('www.clarity.ms','*.clarity.ms')).toBe(true)
58+
expect(matchDomain('scripts.clarity.ms','*.clarity.ms')).toBe(true)
59+
})
60+
61+
it('leading wildcard requires exactly one non-dot label',()=>{
62+
// bare suffix has no prefix label
63+
expect(matchDomain('clarity.ms','*.clarity.ms')).toBe(false)
64+
// multi-label prefix would be too permissive (attacker.clarity.ms.evil.com)
65+
expect(matchDomain('a.b.clarity.ms','*.clarity.ms')).toBe(false)
66+
// different host root must not match
67+
expect(matchDomain('a.evil.ms','*.clarity.ms')).toBe(false)
68+
expect(matchDomain('clarity.ms.attacker.com','*.clarity.ms')).toBe(false)
69+
})
4970
})

0 commit comments

Comments
 (0)