Skip to content

Commit 899b1cb

Browse files
authored
feat(proxy): support path aliases to hide third-party hostnames (#821)
1 parent a28962e commit 899b1cb

18 files changed

Lines changed: 560 additions & 17 deletions

File tree

FIRST_PARTY.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ Some SDKs have quirks that require targeted regex patches after AST rewriting. T
5353

5454
Note: Google Analytics previously needed `postProcess` regex patches for dynamically constructed collect URLs. This is no longer needed since the runtime intercept plugin catches all non-same-origin URLs at the `sendBeacon`/`fetch` call site.
5555

56+
## Path aliases (`proxy.alias`)
57+
58+
By default proxy paths embed the verbatim third-party hostname (`/_scripts/p/us.i.posthog.com/e/`), which leaks self-hosted/internal domains and is trivially classified by ad-blockers. `scripts.proxy.alias` replaces the hostname segment with an alias:
59+
- `true` — auto-generate a short deterministic hash per domain (`sha256(domain).slice(0,8)`)
60+
- `Record<domain, alias>` — explicit aliases; unlisted domains stay verbatim
61+
62+
The pure logic lives in `proxy-alias.ts` (`aliasForDomain`, `buildDomainAliasMap`, `invertAliasMap`, `aliasProxyValue`). The module builds a `domain → alias` map from every proxied domain (those in `domainPrivacy`) and threads it through every point that emits a proxy path:
63+
- **Build-time rewrites** (`transform.ts`): `to: ${proxyPrefix}/${alias ?? domain}`
64+
- **Auto-inject** (`applyAutoInject`): `aliasProxyValue` rewrites the host segment of the computed endpoint
65+
- **Runtime intercept** (`intercept.ts`): embeds the alias map; `proxyUrl` maps `parsed.host → alias`
66+
- **Partytown** (`generatePartytownResolveUrl`): embeds the alias map; worker requests map `url.host → alias`
67+
- **Server handler** (`proxy-handler.ts`): the inverted `aliasToDomain` map resolves the alias segment back to the real domain before allowlist matching and forwarding (verbatim hostnames still resolve, so aliasing is non-breaking)
68+
- **Devtools** (`useScript.ts` network matcher): `aliasToDomain` is exposed in devtools config so aliased requests still attribute to their script
69+
70+
Wildcard domains (`*`) are never aliased — they have no literal path form to rewrite and only exist for runtime allowlist matching.
71+
5672
## Key mapping
5773

5874
Proxy config keys match registry keys directly — no indirection layer. A script's `registryKey` is used to look up its proxy config from `proxy-configs.ts`.

docs/content/docs/1.guides/2.first-party.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,49 @@ export default defineNuxtConfig({
144144
})
145145
```
146146

147+
## Hiding Hostnames
148+
149+
By default, proxied requests embed the real third-party hostname in the path, for example `/_scripts/p/us.i.posthog.com/e/`. For self-hosted services this leaks your internal domain (e.g. `/_scripts/p/analytics.internal.example.com/api/send`) into client-facing URLs, and the verbatim hostname makes requests easy for ad-blockers and network observers to classify.
150+
151+
Use `proxy.alias` to replace hostnames with an alias in the path. The real domain never appears in the URL.
152+
153+
Set `alias: true` to auto-generate a short opaque alias per domain:
154+
155+
```ts [nuxt.config.ts]
156+
export default defineNuxtConfig({
157+
scripts: {
158+
proxy: {
159+
alias: true, // /_scripts/p/a1b2c3d4/e/
160+
}
161+
}
162+
})
163+
```
164+
165+
Or map specific domains to custom aliases. Domains not listed keep their verbatim hostname:
166+
167+
```ts [nuxt.config.ts]
168+
export default defineNuxtConfig({
169+
scripts: {
170+
proxy: {
171+
alias: {
172+
'us.i.posthog.com': 'ph',
173+
'analytics.internal.example.com': 'a',
174+
}
175+
}
176+
}
177+
})
178+
```
179+
180+
Aliases apply everywhere a proxy path is produced: build-time URL rewrites, auto-injected endpoints (such as PostHog's `apiHost`), runtime-intercepted requests, and Partytown worker requests. The server handler resolves the alias back to the real domain before forwarding upstream.
181+
182+
::callout{type="info"}
183+
Aliases only change the path segment. To also change the `/_scripts/p` prefix itself, set the top-level `prefix` option (e.g. `prefix: '/_t'`).
184+
::
185+
186+
::callout{type="warning"}
187+
Aliases keep the real hostname out of request **URLs**, which is what ad-blockers and network observers match on. For scripts whose collection URL is built at runtime (e.g. a self-hosted endpoint passed via config), the real host can still appear in the client JavaScript, since the script needs to know where it would otherwise send. Aliasing does not obfuscate your bundle, it removes the hostname from the network-visible path.
188+
::
189+
147190
## Opting Out
148191

149192
### Per-Script

packages/script/src/devtools.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ export interface ProxyDevtoolsData {
129129
privacyMode: string
130130
scripts: ProxyDevtoolsScript[]
131131
totalDomains: number
132+
/** Path alias → real domain, so devtools can attribute aliased proxy requests. */
133+
aliasToDomain?: Record<string, string>
132134
}
133135

134136
function computeDevtoolsPrivacyLevel(privacy: Record<string, boolean>): 'full' | 'partial' | 'none' {
@@ -178,6 +180,7 @@ export function buildDevtoolsData(
178180
proxyPrefix: string,
179181
privacyLabel: string,
180182
scripts: ProxyDevtoolsScript[],
183+
aliasToDomain?: Record<string, string>,
181184
): ProxyDevtoolsData {
182185
const allDomains = new Set<string>()
183186
for (const s of scripts) {
@@ -190,5 +193,6 @@ export function buildDevtoolsData(
190193
privacyMode: privacyLabel,
191194
scripts,
192195
totalDomains: allDomains.size,
196+
aliasToDomain,
193197
}
194198
}

packages/script/src/module.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { FetchOptions } from 'ofetch'
22
import type { ProxyDevtoolsScript } from './devtools'
33
import type { NormalizedRegistryEntry } from './normalize'
4+
import type { ProxyAliasConfig } from './proxy-alias'
45
import type { ProxyPrivacyInput } from './runtime/server/utils/privacy'
56
import type {
67
FirstPartyPrivacy,
@@ -38,6 +39,7 @@ import { extractRequiredFields, migrateDeprecatedRegistryKeys, normalizeRegistry
3839
import { NuxtScriptsCheckScripts } from './plugins/check-scripts'
3940
import { generateInterceptPluginContents } from './plugins/intercept'
4041
import { NuxtScriptBundleTransformer } from './plugins/transform'
42+
import { aliasProxyValue, buildDomainAliasMap, invertAliasMap, isSafeAliasSegment } from './proxy-alias'
4143
import { buildProxyConfigsFromRegistry, generatePartytownResolveUrl, getPartytownForwards, registry, resolveCapabilities } from './registry'
4244
import { registerTypeTemplates, templatePlugin, templateTriggerResolver } from './templates'
4345
import { validateScriptsEnvVars } from './validate-env'
@@ -215,6 +217,7 @@ export function applyAutoInject(
215217
proxyPrefix: string,
216218
registryKey: string,
217219
autoInject: ResolvedProxyAutoInject,
220+
alias?: ProxyAliasConfig,
218221
): void {
219222
if (isProxyDisabled(registryKey, registry, runtimeConfig))
220223
return
@@ -229,7 +232,7 @@ export function applyAutoInject(
229232
if (!config || config[autoInject.configField])
230233
return
231234

232-
const value = autoInject.computeValue(proxyPrefix, config)
235+
const value = aliasProxyValue(autoInject.computeValue(proxyPrefix, config), proxyPrefix, alias)
233236
input[autoInject.configField] = value
234237

235238
if (rtEntry && typeof rtEntry === 'object' && rtEntry !== input)
@@ -316,6 +319,23 @@ export interface ModuleOptions {
316319
* @default undefined (per-script defaults)
317320
*/
318321
privacy?: FirstPartyPrivacy
322+
/**
323+
* First-party proxy behaviour.
324+
*/
325+
proxy?: {
326+
/**
327+
* Replace third-party hostnames in proxy paths with aliases, so the real domain
328+
* (e.g. a self-hosted analytics host) never appears in client-facing URLs.
329+
*
330+
* - `false` (default): paths embed the verbatim hostname (`/_scripts/p/us.i.posthog.com/...`)
331+
* - `true`: auto-generate a short opaque alias per domain (`/_scripts/p/a1b2c3d4/...`)
332+
* - `Record<domain, alias>`: explicit aliases (domain → alias); unlisted domains stay verbatim
333+
*
334+
* @default false
335+
* @example { 'us.i.posthog.com': 'ph', 'analytics.internal.example.com': 'a' }
336+
*/
337+
alias?: ProxyAliasConfig
338+
}
319339
/**
320340
* The registry of supported third-party scripts. Presence enables infrastructure (proxy routes, types, bundling, composable auto-imports).
321341
* Scripts only auto-load globally when `trigger` is explicitly set in the config object.
@@ -853,6 +873,11 @@ export default defineNuxtModule<ModuleOptions>({
853873
}
854874
}
855875

876+
// Path alias config — maps real third-party domains to opaque/custom aliases so
877+
// hostnames don't leak into client-facing proxy URLs. Shared with the transformer.
878+
const proxyAlias = config.proxy?.alias
879+
let domainAliases: Record<string, string> = {}
880+
856881
// Finalize proxy setup: build configs, register intercept plugin, wire devtools
857882
if (anyNeedsProxy) {
858883
const builtConfigs = buildProxyConfigsFromRegistry(registryScripts, scriptByKey)
@@ -903,7 +928,7 @@ export default defineNuxtModule<ModuleOptions>({
903928
}
904929

905930
if (proxyConfig.autoInject && config.registry)
906-
applyAutoInject(config.registry, nuxt.options.runtimeConfig, proxyPrefix, key, proxyConfig.autoInject)
931+
applyAutoInject(config.registry, nuxt.options.runtimeConfig, proxyPrefix, key, proxyConfig.autoInject, proxyAlias)
907932

908933
if (nuxt.options.dev)
909934
devtoolsScripts.push(buildDevtoolsEntry(key, script, configKey, proxyConfig))
@@ -922,16 +947,37 @@ export default defineNuxtModule<ModuleOptions>({
922947
)
923948
}
924949

950+
// Build the domain → alias map from every proxied domain, then warn on any
951+
// collision (two domains resolving to the same alias would mis-route at runtime).
952+
domainAliases = buildDomainAliasMap(Object.keys(domainPrivacy), proxyAlias)
953+
// Validate aliases at the build boundary so the runtime map is unambiguous:
954+
// every alias must be a safe single path segment, unique, and must not equal a
955+
// real proxied domain (else the verbatim-hostname fallback could pick the wrong one).
956+
const realDomains = new Set(Object.keys(domainPrivacy))
957+
const aliasOwner = new Map<string, string>()
958+
for (const [domain, alias] of Object.entries(domainAliases)) {
959+
if (!isSafeAliasSegment(alias))
960+
throw new Error(`[nuxt-scripts] Invalid proxy alias "${alias}" for "${domain}": use a single URL-safe path segment (letters, digits, '-', '_', '.').`)
961+
if (realDomains.has(alias))
962+
throw new Error(`[nuxt-scripts] Proxy alias "${alias}" for "${domain}" collides with proxied domain "${alias}". Pick an alias that is not also a proxied hostname.`)
963+
const prev = aliasOwner.get(alias)
964+
if (prev)
965+
throw new Error(`[nuxt-scripts] Proxy alias collision: "${prev}" and "${domain}" both map to "${alias}". Give each domain a unique alias.`)
966+
aliasOwner.set(alias, domain)
967+
}
968+
const aliasToDomain = invertAliasMap(domainAliases)
969+
925970
// Register intercept plugin
926971
addPluginTemplate({
927972
filename: 'nuxt-scripts-intercept.client.mjs',
928-
getContents() { return generateInterceptPluginContents(proxyPrefix, { testMode: !!nuxt.options.test }) },
973+
getContents() { return generateInterceptPluginContents(proxyPrefix, { testMode: !!nuxt.options.test, domainAliases }) },
929974
})
930975

931976
// Server-side proxy config
932977
nuxt.options.runtimeConfig['nuxt-scripts-proxy'] = {
933978
proxyPrefix,
934979
domainPrivacy,
980+
aliasToDomain,
935981
privacy: config.privacy,
936982
} as any
937983

@@ -954,14 +1000,14 @@ export default defineNuxtModule<ModuleOptions>({
9541000

9551001
// Expose devtools data
9561002
if (nuxt.options.dev) {
957-
nuxt.options.runtimeConfig.public['nuxt-scripts-devtools'] = buildDevtoolsData(proxyPrefix, privacyLabel, devtoolsScripts) as any
1003+
nuxt.options.runtimeConfig.public['nuxt-scripts-devtools'] = buildDevtoolsData(proxyPrefix, privacyLabel, devtoolsScripts, aliasToDomain) as any
9581004
}
9591005

9601006
// Auto-configure Partytown resolveUrl for proxy
9611007
if (partytownScripts.size && hasNuxtModule('@nuxtjs/partytown')) {
9621008
const partytownConfig = (nuxt.options as any).partytown || {}
9631009
if (!partytownConfig.resolveUrl) {
964-
partytownConfig.resolveUrl = generatePartytownResolveUrl(proxyPrefix)
1010+
partytownConfig.resolveUrl = generatePartytownResolveUrl(proxyPrefix, domainAliases)
9651011
;(nuxt.options as any).partytown = partytownConfig
9661012
logger.info('[partytown] Auto-configured resolveUrl for proxy')
9671013
}
@@ -981,6 +1027,7 @@ export default defineNuxtModule<ModuleOptions>({
9811027
registryConfig: nuxt.options.runtimeConfig.public.scripts as Record<string, any> | undefined,
9821028
proxyConfigs,
9831029
proxyPrefix,
1030+
domainAliases,
9841031
partytownScripts,
9851032
moduleDetected(module) {
9861033
if (nuxt.options.dev && module !== '@nuxt/scripts' && !moduleInstallPromises.has(module) && !hasNuxtModule(module))

packages/script/src/plugins/intercept.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,21 @@
44
* that route matching URLs through the proxy. AST rewriting transforms
55
* native API calls to use these wrappers at build time.
66
*
7-
* Any non-same-origin URL is proxied through `proxyPrefix/<host><path>`.
7+
* Any non-same-origin URL is proxied through `proxyPrefix/<host-or-alias><path>`.
88
* No domain allowlist needed: only AST-rewritten third-party scripts call __nuxtScripts.
9+
*
10+
* `domainAliases` (real domain → path alias) is embedded so runtime-constructed URLs
11+
* (e.g. self-hosted analytics endpoints) use the same opaque path segment as build-time
12+
* rewrites, keeping hostnames out of client URLs.
913
*/
10-
export function generateInterceptPluginContents(proxyPrefix: string, options?: { testMode?: boolean }): string {
14+
export function generateInterceptPluginContents(proxyPrefix: string, options?: { testMode?: boolean, domainAliases?: Record<string, string> }): string {
1115
const testMode = options?.testMode ?? false
1216
return `export default defineNuxtPlugin({
1317
name: 'nuxt-scripts:intercept',
1418
enforce: 'pre',
1519
setup() {
1620
const proxyPrefix = ${JSON.stringify(proxyPrefix)};
21+
const domainAliases = ${JSON.stringify(options?.domainAliases ?? {})};
1722
const origBeacon = typeof navigator !== 'undefined' && navigator.sendBeacon
1823
? navigator.sendBeacon.bind(navigator)
1924
: () => false;
@@ -22,8 +27,10 @@ export function generateInterceptPluginContents(proxyPrefix: string, options?: {
2227
function proxyUrl(url) {
2328
try {
2429
const parsed = new URL(url, location.origin);
25-
if (parsed.origin !== location.origin)
26-
return location.origin + proxyPrefix + '/' + parsed.host + parsed.pathname + parsed.search;
30+
if (parsed.origin !== location.origin) {
31+
const seg = domainAliases[parsed.host] || parsed.host;
32+
return location.origin + proxyPrefix + '/' + seg + parsed.pathname + parsed.search;
33+
}
2734
} catch {}
2835
return url;
2936
}

packages/script/src/plugins/transform.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ export interface AssetBundlerTransformerOptions {
7373
* Proxy prefix for first-party mode. Used to derive rewrite targets from domains.
7474
*/
7575
proxyPrefix?: string
76+
/** Map of real third-party domain → path alias, used to hide hostnames in proxy URLs. */
77+
domainAliases?: Record<string, string>
7678
fallbackOnSrcOnBundleFail?: boolean
7779
fetchOptions?: FetchOptions
7880
cacheMaxAge?: number
@@ -469,7 +471,7 @@ export function NuxtScriptBundleTransformer(options: AssetBundlerTransformerOpti
469471
// no literal form to rewrite at build time.
470472
const proxyRewrites = proxyConfig?.domains?.filter(domain => !domain.includes('*')).map(domain => ({
471473
from: domain,
472-
to: `${options.proxyPrefix}/${domain}`,
474+
to: `${options.proxyPrefix}/${options.domainAliases?.[domain] ?? domain}`,
473475
}))
474476
// Bundle-only SDK patches (independent of proxy). Used when bundling
475477
// a script that needs neutralize-domain-check etc. but should keep

packages/script/src/proxy-alias.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { createHash } from 'node:crypto'
2+
3+
/**
4+
* Proxy path alias configuration.
5+
* - `false` / `undefined`: proxy paths use the verbatim third-party hostname
6+
* (`/_scripts/p/us.i.posthog.com/...`).
7+
* - `true`: every proxied domain gets a short deterministic opaque alias
8+
* (`/_scripts/p/a1b2c3d4/...`), so the real hostname never appears in client URLs.
9+
* - `Record<domain, alias>`: explicit aliases (domain → alias). Domains not listed
10+
* keep their verbatim hostname.
11+
*/
12+
export type ProxyAliasConfig = boolean | Record<string, string> | undefined
13+
14+
const WILDCARD_RE = /\*/
15+
// An alias is interpolated into `/_scripts/p/<alias>/...`, so it must be a single
16+
// URL path segment: no slash/query/hash/whitespace that would split or break the path.
17+
const SAFE_ALIAS_SEGMENT_RE = /^[\w.-]+$/
18+
19+
/** Whether an explicit alias is a single URL-safe path segment. */
20+
export function isSafeAliasSegment(alias: string): boolean {
21+
return SAFE_ALIAS_SEGMENT_RE.test(alias)
22+
}
23+
24+
/**
25+
* Resolve the alias for a single third-party domain. Pure and deterministic so the
26+
* build-time rewrites and the runtime reverse map always agree without sharing state.
27+
*
28+
* Returns `undefined` when the domain should keep its verbatim hostname (no alias
29+
* configured, or a wildcard pattern that has no literal path form to rewrite).
30+
*/
31+
export function aliasForDomain(domain: string, alias: ProxyAliasConfig): string | undefined {
32+
if (!alias || WILDCARD_RE.test(domain))
33+
return undefined
34+
if (alias === true)
35+
// 12 hex chars (48 bits) — collision space large enough that auto-aliasing never
36+
// silently misroutes; explicit-alias collisions are caught at build time instead.
37+
return createHash('sha256').update(domain).digest('hex').slice(0, 12)
38+
// Own-property guard so a domain literally named `toString`/`constructor` can't
39+
// resolve to an inherited prototype member.
40+
return Object.hasOwn(alias, domain) ? (alias[domain] || undefined) : undefined
41+
}
42+
43+
/** Build a `domain → alias` map for the given proxied domains. */
44+
export function buildDomainAliasMap(domains: Iterable<string>, alias: ProxyAliasConfig): Record<string, string> {
45+
const map: Record<string, string> = {}
46+
for (const domain of domains) {
47+
const value = aliasForDomain(domain, alias)
48+
if (value)
49+
map[domain] = value
50+
}
51+
return map
52+
}
53+
54+
/** Invert a `domain → alias` map into the `alias → domain` map the proxy handler resolves with. */
55+
export function invertAliasMap(map: Record<string, string>): Record<string, string> {
56+
const out: Record<string, string> = {}
57+
for (const [domain, alias] of Object.entries(map))
58+
out[alias] = domain
59+
return out
60+
}
61+
62+
/**
63+
* Rewrite the leading `${proxyPrefix}/${host}` segment of a generated proxy path so the
64+
* host is replaced with its alias. Used for auto-injected endpoint values (e.g. PostHog's
65+
* `apiHost`) where the host is produced by an arbitrary `resolve` function.
66+
*/
67+
export function aliasProxyValue(value: string, proxyPrefix: string, alias: ProxyAliasConfig): string {
68+
const prefix = `${proxyPrefix}/`
69+
if (!alias || !value.startsWith(prefix))
70+
return value
71+
const rest = value.slice(prefix.length)
72+
const host = rest.match(/^[^/?#]+/)?.[0]
73+
if (!host)
74+
return value
75+
const aliased = aliasForDomain(host, alias)
76+
return aliased ? `${prefix}${aliased}${rest.slice(host.length)}` : value
77+
}

packages/script/src/registry.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -869,12 +869,17 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
869869
/**
870870
* Generate a Partytown `resolveUrl` function string for proxy routing.
871871
* Partytown calls this for every network request made by worker-executed scripts.
872-
* Any non-same-origin URL is proxied through `proxyPrefix/<host><path>`.
872+
* Any non-same-origin URL is proxied through `proxyPrefix/<host-or-alias><path>`.
873+
*
874+
* `domainAliases` (real domain → path alias) is embedded so worker requests use the
875+
* same opaque path segment as build-time rewrites, keeping hostnames out of URLs.
873876
*/
874-
export function generatePartytownResolveUrl(proxyPrefix: string): string {
877+
export function generatePartytownResolveUrl(proxyPrefix: string, domainAliases: Record<string, string> = {}): string {
875878
return `function(url, location, type) {
876879
if (url.origin !== location.origin) {
877-
return new URL(${JSON.stringify(proxyPrefix)} + '/' + url.host + url.pathname + url.search, location.origin);
880+
var aliases = ${JSON.stringify(domainAliases)};
881+
var seg = aliases[url.host] || url.host;
882+
return new URL(${JSON.stringify(proxyPrefix)} + '/' + seg + url.pathname + url.search, location.origin);
878883
}
879884
}`
880885
}

0 commit comments

Comments
 (0)