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
215 changes: 215 additions & 0 deletions packages/script/src/plugins/rewrite-ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,11 +208,181 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
return prev && WORD_OR_DOLLAR_RE.test(prev) ? ' ' : ''
}

function sourceOf(node: any): string {
return content.slice(node.start, node.end)
}

// Pass 1: collect all declarations
const scopeTracker = new ScopeTracker({ preserveExitedScopes: true })
const { program } = parseAndWalk(content, filename, { scopeTracker })
scopeTracker.freeze()

const scriptLoaderUrlPatches = sdkPatches?.filter((p): p is Extract<SdkPatch, { type: 'replace-script-loader-url' }> =>
p.type === 'replace-script-loader-url') ?? []
const scriptLoaderFunctionKeys = new Set<string>()

function isIdentifier(node: any, name?: string): boolean {
return node?.type === 'Identifier' && (!name || node.name === name)
}

function propertyName(node: any): string | null {
if (!node)
return null
if (node.type === 'Identifier')
return node.name
if (node.type === 'Literal' && typeof node.value === 'string')
return node.value
return null
}

function firstParamName(node: any): string | null {
const param = node?.params?.[0]
return param?.type === 'Identifier' ? param.name : null
}

function functionName(node: any, parent: any): string | null {
if (node.type === 'FunctionDeclaration' && node.id?.type === 'Identifier')
return node.id.name
if ((node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression')
&& parent?.type === 'VariableDeclarator'
&& parent.id?.type === 'Identifier') {
return parent.id.name
}
if ((node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression')
&& parent?.type === 'AssignmentExpression'
&& parent.left?.type === 'Identifier') {
return parent.left.name
}
return null
}

function declarationKey(name: string): string {
const decl = scopeTracker.getDeclaration(name)
if (decl) {
const node = (decl as any).node
return `${decl.type}:${decl.scope}:${node?.start ?? decl.start}:${node?.end ?? decl.end}`
}
return `global:${name}`
}

function isScriptLoaderPathLiteral(value: string, pathPrefix: string): boolean {
return value === pathPrefix || value.startsWith(`${pathPrefix}/`)
}

function getIdentifierInit(name: string): any | null {
const decl = scopeTracker.getDeclaration(name)
if (!(decl instanceof ScopeTrackerVariable))
return null

const declarators = (decl.variableNode as any).declarations
if (!declarators)
return null

for (const declarator of declarators) {
if (declarator.id?.type === 'Identifier' && declarator.id.name === name)
return declarator.init ?? null
}
return null
}

function findScriptLoaderPathExpression(node: any, pathPrefix: string, seenIdentifiers = new Set<string>()): any {
if (!node)
return null
if (node.type === 'Identifier') {
if (seenIdentifiers.has(node.name))
return null
const init = getIdentifierInit(node.name)
if (!init)
return null
seenIdentifiers.add(node.name)
return findScriptLoaderPathExpression(init, pathPrefix, seenIdentifiers) ? node : null
}
if (node.type === 'Literal' && typeof node.value === 'string' && isScriptLoaderPathLiteral(node.value, pathPrefix))
return node
if (node.type === 'TemplateLiteral') {
const hasConfigPrefix = node.quasis?.some((q: any) => {
const value = q.value?.cooked ?? q.value?.raw
return typeof value === 'string' && isScriptLoaderPathLiteral(value, pathPrefix)
})
return hasConfigPrefix ? node : null
}
if (node.type === 'CallExpression') {
for (const arg of node.arguments ?? []) {
const found = findScriptLoaderPathExpression(arg, pathPrefix, seenIdentifiers)
if (found)
return found
}
return null
}
if (node.type === 'BinaryExpression' || node.type === 'LogicalExpression')
return findScriptLoaderPathExpression(node.right, pathPrefix, seenIdentifiers) || findScriptLoaderPathExpression(node.left, pathPrefix, seenIdentifiers)
if (node.type === 'ConditionalExpression')
return findScriptLoaderPathExpression(node.consequent, pathPrefix, seenIdentifiers) || findScriptLoaderPathExpression(node.alternate, pathPrefix, seenIdentifiers)
if (node.type === 'SequenceExpression') {
for (let i = node.expressions.length - 1; i >= 0; i--) {
const found = findScriptLoaderPathExpression(node.expressions[i], pathPrefix, seenIdentifiers)
if (found)
return found
}
}
return null
}

function isScriptLoaderFunction(node: any, urlParam: string): boolean {
let matches = false
walk(node.body, {
enter(child) {
if (child !== node.body
&& (child.type === 'FunctionDeclaration' || child.type === 'FunctionExpression' || child.type === 'ArrowFunctionExpression')) {
this.skip()
return
}
if (child.type === 'CallExpression'
&& child.callee?.type === 'Identifier'
&& child.callee.name === 'importScripts'
&& isIdentifier(child.arguments?.[0], urlParam)) {
matches = true
}
if (child.type === 'CallExpression'
&& child.callee?.type === 'MemberExpression'
&& propertyName(child.callee.property) === 'setAttribute'
&& child.arguments?.[0]?.type === 'Literal'
&& child.arguments[0].value === 'src'
&& isIdentifier(child.arguments?.[1], urlParam)) {
matches = true
}
if (child.type === 'Property'
&& propertyName(child.key) === 'src'
&& isIdentifier(child.value, urlParam)) {
matches = true
}
if (child.type === 'AssignmentExpression'
&& child.left?.type === 'MemberExpression'
&& propertyName(child.left.property) === 'src'
&& isIdentifier(child.right, urlParam)) {
matches = true
}
},
})
return matches
}

if (scriptLoaderUrlPatches.length) {
walk(program, {
scopeTracker,
enter(node, parent) {
if (node.type !== 'FunctionDeclaration' && node.type !== 'FunctionExpression' && node.type !== 'ArrowFunctionExpression')
return
const name = functionName(node, parent)
const param = firstParamName(node)
if (!name || !param)
return
if (isScriptLoaderFunction(node, param))
scriptLoaderFunctionKeys.add(declarationKey(name))
},
})
}

// Pass 2: rewrite with scope resolution
walk(program, {
scopeTracker,
Expand Down Expand Up @@ -434,6 +604,51 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
}
}

// SDK patch: replace-new-url-host
// Matches `new URL(<expr>).host` / `.hostname` and replaces it with a
// known vendor host so bundled scripts do not detect self-hosting.
if (sdkPatches?.some(p => p.type === 'replace-new-url-host')
&& node.type === 'MemberExpression'
&& !(node as any).computed) {
const obj = (node as any).object
const prop = (node as any).property
if (prop?.type === 'Identifier' && (prop.name === 'host' || prop.name === 'hostname')
&& obj?.type === 'NewExpression'
&& obj.callee?.type === 'Identifier' && obj.callee.name === 'URL'
&& obj.arguments?.length >= 1) {
const patch = sdkPatches.find(p => p.type === 'replace-new-url-host')
if (patch)
s.overwrite(node.start, node.end, `${needsLeadingSpace(node.start)}${JSON.stringify(patch.host)}`)
}
}

// SDK patch: replace-script-loader-url
// Some SDKs assemble script URLs from minified variables, then pass them
// into a tiny script loader helper. Match the stable behavior: a known
// path prefix reaches a function that uses its first parameter as a
// script `src` or `importScripts` URL.
if (scriptLoaderUrlPatches.length
&& node.type === 'CallExpression'
&& (node as any).callee?.type === 'Identifier'
&& scriptLoaderFunctionKeys.has(declarationKey((node as any).callee.name))) {
const urlArg = (node as any).arguments?.[0]
if (urlArg) {
for (const patch of scriptLoaderUrlPatches) {
const rewrite = rewrites.find(r => r.from === patch.fromDomain)
if (!rewrite)
continue
const pathNode = findScriptLoaderPathExpression(urlArg, patch.pathPrefix)
if (!pathNode)
continue
s.overwrite(urlArg.start, urlArg.end, `${needsLeadingSpace(urlArg.start)}self.location.origin+"${rewrite.to}"+${sourceOf(pathNode)}`)
// The replaced range may contain literals the domain-rewrite pass
// would also edit β€” descending would produce conflicting overwrites.
this.skip()
break
}
}
}

// new XMLHttpRequest / new Image / new x.XMLHttpRequest / new x.Image
if (node.type === 'NewExpression' && !options?.skipApiRewrites) {
const callee = (node as any).callee
Expand Down
8 changes: 7 additions & 1 deletion packages/script/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,10 +519,16 @@ export async function registry(resolve?: (path: string) => Promise<string>): Pro
src: 'https://sc-static.net/scevent.min.js',
category: 'ad',
envDefaults: { id: '' },
bundle: true,
bundle: {
sdkPatches: [{ type: 'replace-new-url-host', host: 'sc-static.net' }],
},
proxy: {
domains: ['sc-static.net', 'tr.snapchat.com', 'pixel.tapad.com'],
privacy: PRIVACY_FULL,
sdkPatches: [
{ type: 'replace-new-url-host', host: 'sc-static.net' },
{ type: 'replace-script-loader-url', fromDomain: 'tr.snapchat.com', pathPrefix: '/config' },
],
},
partytown: { forwards: ['snaptr'] },
}),
Expand Down
23 changes: 23 additions & 0 deletions packages/script/src/runtime/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,29 @@ export type SdkPatch
* lands on a 404 instead of the proxy. This patch redirects it through the proxy.
*/
| { type: 'replace-new-url-origin', fromDomain: string }
/**
* Replace `new URL(<expr>).host` / `.hostname` with a known vendor host.
* Used by SDKs that detect self-hosting from their own script URL and switch
* into a different endpoint mode when bundled.
*
* Applies to EVERY `new URL(...).host` / `.hostname` in the bundle, not just
* the self-src detection β€” only use it for SDKs that don't parse other URLs
* (e.g. referrers, click-throughs) with this pattern.
*/
| { type: 'replace-new-url-host', host: string }
/**
* Replace script loader calls that receive a known path prefix with the
* configured proxied domain. This covers SDKs that assemble URLs from
* minified variables before passing them to a script `src` helper or
* `importScripts`, where ordinary string literal URL rewriting cannot see
* the final host.
*
* The loader's whole first argument is replaced with
* `origin + proxyPath + <matched path expression>` β€” any parts of the
* argument outside the matched path expression (e.g. an appended query
* string) are dropped.
*/
| { type: 'replace-script-loader-url', fromDomain: string, pathPrefix: string }

/**
* Partytown capability config. When present, the script can run in a
Expand Down
18 changes: 13 additions & 5 deletions test/e2e-dev/first-party.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -563,8 +563,8 @@ describe('first-party privacy stripping', () => {

it('bundled scripts contain rewritten collect URLs', async () => {
// Check bundled scripts have proxy URLs
const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/scripts/bundle-proxy')
expect(existsSync(cacheDir), `Bundle proxy cache dir should exist at ${cacheDir}`).toBe(true)
const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/scripts/bundle-patched')
expect(existsSync(cacheDir), `Patched bundle cache dir should exist at ${cacheDir}`).toBe(true)

const files = readdirSync(cacheDir).filter(f => f.endsWith('.js'))
expect(files.length).toBeGreaterThan(0)
Expand All @@ -591,6 +591,7 @@ describe('first-party privacy stripping', () => {
let serverOrigin = ''
const proxyRequests: string[] = []
const externalRequests: string[] = []
const sameOriginRequests: string[] = []

page.on('request', (req) => {
const reqUrl = req.url()
Expand All @@ -606,6 +607,9 @@ describe('first-party privacy stripping', () => {
if (parsed.pathname.startsWith('/_scripts/p/')) {
proxyRequests.push(parsed.pathname)
}
else if (serverOrigin && reqUrl.startsWith(serverOrigin)) {
sameOriginRequests.push(parsed.pathname)
}
else if (serverOrigin && !reqUrl.startsWith(serverOrigin) && parsed.protocol.startsWith('http')) {
externalRequests.push(reqUrl)
}
Expand Down Expand Up @@ -639,7 +643,7 @@ describe('first-party privacy stripping', () => {

await page.close()

return { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount }
return { captures, rawCaptures, proxyRequests, externalRequests, sameOriginRequests, preClickProxyCount, postClickProxyCount }
}

/**
Expand Down Expand Up @@ -818,13 +822,17 @@ describe('first-party privacy stripping', () => {
}, 30000)

it('snapchatPixel', async () => {
const { captures, rawCaptures, proxyRequests, externalRequests, preClickProxyCount, postClickProxyCount } = await testProvider('snapchatPixel', '/snap', {
const { captures, rawCaptures, proxyRequests, externalRequests, sameOriginRequests, preClickProxyCount, postClickProxyCount } = await testProvider('snapchatPixel', '/snap', {
clickSelectors: ['#trigger-pageview', '#trigger-event'],
})
await assertCaptures('snapchatPixel', captures, rawCaptures, proxyRequests, externalRequests, {
proxyPrefix: '/_scripts/p/snap',
domains: ['snapchat.com'],
}, { pre: preClickProxyCount, post: postClickProxyCount })
expect(
sameOriginRequests.some(path => path.startsWith('/config/')),
`snapchatPixel: bundled SDK requested config from the app origin: ${JSON.stringify(sameOriginRequests.filter(path => path.startsWith('/config/')))}`,
).toBe(false)
}, 30000)

it('clarity', async () => {
Expand Down Expand Up @@ -1111,7 +1119,7 @@ describe('first-party privacy stripping', () => {
*/
describe('bundled script integrity', () => {
it('all cached proxy-rewritten scripts are syntactically valid', async () => {
const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/scripts/bundle-proxy')
const cacheDir = join(fixtureDir, 'node_modules/.cache/nuxt/scripts/bundle-patched')
if (!existsSync(cacheDir))
return // skip if no cached scripts

Expand Down
4 changes: 2 additions & 2 deletions test/fixtures/first-party/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { defineNuxtConfig } from 'nuxt/config'

// trigger: 'manual' prevents the auto-generated plugin from loading all 18
// scripts globally on every page. Each page's composable call then overrides
// the trigger and loads only its own script, eliminating cross-provider noise.
// scripts globally on every page. Pages that need the real SDK should provide
// an explicit trigger in their composable call, keeping cross-provider noise low.
const manual = { trigger: 'manual' as const }

export default defineNuxtConfig({
Expand Down
4 changes: 4 additions & 0 deletions test/fixtures/first-party/pages/snap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ useHead({

const { proxy, status } = useScriptSnapchatPixel({
id: '2295cbcc-cb3f-4727-8c09-1133b742722c',
scriptOptions: {
bundle: 'force',
trigger: 'client',
},
})

function trackPageView() {
Expand Down
Loading
Loading