Skip to content

Commit b1c235d

Browse files
committed
fix: target canvas fingerprinting at build-time
1 parent 472aaea commit b1c235d

7 files changed

Lines changed: 161 additions & 42 deletions

File tree

src/plugins/rewrite-ast.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import type { ProxyRewrite } from '../runtime/utils/pure'
22
import MagicString from 'magic-string'
3-
import { parseAndWalk, ScopeTracker, ScopeTrackerFunctionParam, ScopeTrackerVariable, walk } from 'oxc-walker'
3+
import { parseAndWalk, ScopeTracker, ScopeTrackerFunction, ScopeTrackerFunctionParam, ScopeTrackerIdentifier, ScopeTrackerVariable, walk } from 'oxc-walker'
44
import { joinURL, parseURL } from 'ufo'
55

66
const WORD_OR_DOLLAR_RE = /[\w$]/
7+
// Static blank 1x1 transparent PNG — every user gets the same value, defeating canvas fingerprinting
8+
// while returning a valid data URL that won't break scripts checking format/truthiness.
9+
const BLANK_CANVAS_DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQIHWNgAAIABQABNjN9GQAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAAA0lEQVQI12NgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg=='
710
const GA_COLLECT_RE = /([\w$])?"https:\/\/"\+\(.*?\)\+"\.google-analytics\.com\/g\/collect"/g
811
const GA_ANALYTICS_COLLECT_RE = /([\w$])?"https:\/\/"\+\(.*?\)\+"\.analytics\.google\.com\/g\/collect"/g
912
const FATHOM_SELF_HOSTED_RE = /\.src\.indexOf\("cdn\.usefathom\.com"\)\s*<\s*0/
@@ -123,14 +126,19 @@ function resolveToGlobal(name: string, scopeTracker: ScopeTracker, depth = 0): s
123126
if (init.type === 'Identifier')
124127
return resolveToGlobal(init.name, scopeTracker, depth + 1)
125128

126-
// var n = w.navigator → resolve w, then append .navigator
127-
if (init.type === 'MemberExpression' && !init.computed && init.object?.type === 'Identifier' && init.property?.type === 'Identifier') {
129+
// var n = w.navigator / var n = w['navigator'] → resolve w, then append property
130+
if (init.type === 'MemberExpression' && init.object?.type === 'Identifier') {
131+
const memberProp = init.computed
132+
? (init.property?.type === 'Literal' && typeof init.property.value === 'string' ? init.property.value : null)
133+
: init.property?.type === 'Identifier' ? init.property.name : null
134+
if (!memberProp)
135+
return null
128136
const objGlobal = resolveToGlobal(init.object.name, scopeTracker, depth + 1)
129137
if (!objGlobal)
130138
return null
131139
// window.navigator → "navigator", window.fetch stays as compound
132140
if (WINDOW_GLOBALS.has(objGlobal) || objGlobal === 'document')
133-
return init.property.name
141+
return memberProp
134142
return null
135143
}
136144

@@ -251,6 +259,45 @@ export function rewriteScriptUrlsAST(content: string, filename: string, rewrites
251259
if (node.type === 'CallExpression') {
252260
const callee = (node as any).callee
253261

262+
// Canvas fingerprinting neutralization — only affects downloaded third-party scripts.
263+
// Resolves aliased objects through the scope chain to avoid false positives.
264+
const canvasPropName = callee?.type === 'MemberExpression'
265+
? (callee.computed
266+
? (callee.property?.type === 'Literal' && typeof callee.property.value === 'string' ? callee.property.value : null)
267+
: callee.property?.name)
268+
: null
269+
270+
// .toDataURL() → static blank canvas (prevents canvas fingerprint extraction)
271+
if (canvasPropName === 'toDataURL' && callee.object) {
272+
const blankCanvas = `"${BLANK_CANVAS_DATA_URL}"`
273+
// Skip if the object resolves to a locally declared function/class (not a DOM element)
274+
if (callee.object.type === 'Identifier') {
275+
const decl = scopeTracker.getDeclaration(callee.object.name)
276+
// If declared as a function or class, it's not a canvas element — skip
277+
if (decl instanceof ScopeTrackerFunction || decl instanceof ScopeTrackerIdentifier) {
278+
// fall through to other checks
279+
;
280+
}
281+
else {
282+
s.overwrite(node.start, node.end, blankCanvas)
283+
return
284+
}
285+
}
286+
else {
287+
// Chained access (e.g. ctx.canvas.toDataURL()) — always neutralize
288+
s.overwrite(node.start, node.end, blankCanvas)
289+
return
290+
}
291+
}
292+
// .getExtension('WEBGL_debug_renderer_info') → null (prevents GPU fingerprinting)
293+
if (canvasPropName === 'getExtension') {
294+
const args = (node as any).arguments
295+
if (args?.length === 1 && args[0]?.type === 'Literal' && args[0].value === 'WEBGL_debug_renderer_info') {
296+
s.overwrite(node.start, node.end, 'null')
297+
return
298+
}
299+
}
300+
254301
// fetch(url) → check it's truly global (not locally declared)
255302
if (callee?.type === 'Identifier' && callee.name === 'fetch') {
256303
if (!scopeTracker.getDeclaration('fetch'))

src/runtime/server/utils/privacy.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,11 +149,13 @@ export const STRIP_PARAMS = {
149149
// Browser version lists — generalized to major versions (d_bvs = Snapchat, uafvl = GA Client Hints)
150150
browserVersion: ['d_bvs', 'uafvl'],
151151
// Browser data lists — replaced with empty value
152-
browserData: ['plugins', 'fonts'],
152+
browserData: ['plugins', 'fonts', 'audiofingerprint'],
153153
// Location/Timezone — generalized
154154
location: ['tz', 'timezone', 'timezoneoffset'],
155-
// Canvas/WebGL/Audio fingerprints — replaced with empty value (pure fingerprints, no analytics value)
156-
canvas: ['canvas', 'webgl', 'audiofingerprint'],
155+
// Canvas/WebGL fingerprints — neutralized at build time via AST rewriting (rewrite-ast.ts).
156+
// These params are no longer stripped at runtime; the source APIs (toDataURL, WEBGL_debug_renderer_info)
157+
// are neutralized before the script ever runs.
158+
// canvas: ['canvas', 'webgl'],
157159
// Combined device fingerprinting (X/Twitter dv param contains: timezone, locale, vendor, platform, screen, etc.)
158160
deviceInfo: ['dv', 'device_info', 'deviceinfo'],
159161
}
@@ -490,12 +492,7 @@ export function stripPayloadFingerprinting(
490492
}
491493
// Replace browser data lists with empty value — hardware flag
492494
if (matchesParam(key, STRIP_PARAMS.browserData)) {
493-
result[key] = p.hardware ? (Array.isArray(value) ? [] : '') : value
494-
continue
495-
}
496-
// Replace canvas/webgl/audio fingerprints with empty value — hardware flag
497-
if (matchesParam(key, STRIP_PARAMS.canvas)) {
498-
result[key] = p.hardware ? (typeof value === 'number' ? 0 : typeof value === 'object' ? {} : '') : value
495+
result[key] = p.hardware ? (Array.isArray(value) ? [] : typeof value === 'number' ? 0 : '') : value
499496
continue
500497
}
501498
// Anonymize combined device info (parse and generalize components) — hardware flag

src/script-meta.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export interface ScriptMeta {
1010
urls: string[]
1111
/** Types of data this script tracks/collects */
1212
trackedData: TrackedDataType[]
13+
/** Script uses canvas/WebGL APIs for device fingerprinting — neutralized at build time via AST rewriting */
14+
canvasFingerprinting?: boolean
1315
}
1416

1517
export const scriptMeta: Record<string, ScriptMeta> = {
@@ -49,6 +51,7 @@ export const scriptMeta: Record<string, ScriptMeta> = {
4951
googleAnalytics: {
5052
urls: ['https://www.googletagmanager.com/gtag/js?id=G-TR58L0EF8P'],
5153
trackedData: ['page-views', 'events', 'conversions', 'user-identity', 'audiences'],
54+
canvasFingerprinting: true,
5255
},
5356
umamiAnalytics: {
5457
urls: ['https://cloud.umami.is/script.js'],
@@ -59,22 +62,27 @@ export const scriptMeta: Record<string, ScriptMeta> = {
5962
metaPixel: {
6063
urls: ['https://connect.facebook.net/en_US/fbevents.js'],
6164
trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'],
65+
canvasFingerprinting: true,
6266
},
6367
xPixel: {
6468
urls: ['https://static.ads-twitter.com/uwt.js'],
6569
trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'],
70+
canvasFingerprinting: true,
6671
},
6772
tikTokPixel: {
6873
urls: ['https://analytics.tiktok.com/i18n/pixel/events.js'],
6974
trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'],
75+
canvasFingerprinting: true,
7076
},
7177
snapchatPixel: {
7278
urls: ['https://sc-static.net/scevent.min.js'],
7379
trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'],
80+
canvasFingerprinting: true,
7481
},
7582
redditPixel: {
7683
urls: ['https://www.redditstatic.com/ads/pixel.js'],
7784
trackedData: ['page-views', 'conversions', 'retargeting', 'audiences'],
85+
canvasFingerprinting: true,
7886
},
7987
googleAdsense: {
8088
urls: ['https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js'],
@@ -89,10 +97,12 @@ export const scriptMeta: Record<string, ScriptMeta> = {
8997
hotjar: {
9098
urls: ['https://static.hotjar.com/c/hotjar-3925006.js?sv=6'],
9199
trackedData: ['page-views', 'session-replay', 'heatmaps', 'clicks', 'scrolls', 'form-submissions'],
100+
canvasFingerprinting: true,
92101
},
93102
clarity: {
94103
urls: ['https://www.clarity.ms/tag/mqk2m9dr2v'],
95104
trackedData: ['page-views', 'session-replay', 'heatmaps', 'clicks', 'scrolls'],
105+
canvasFingerprinting: true,
96106
},
97107

98108
// Tag Manager

test/e2e-dev/first-party.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,7 @@ const ANONYMIZED_FINGERPRINT_PARAMS = [
102102
'tz',
103103
'timezone',
104104
'timezoneoffset',
105-
// Canvas/WebGL fingerprinting (replaced with empty value)
106-
'canvas',
107-
'webgl',
105+
// Audio fingerprinting (replaced with empty value; canvas/webgl neutralized at build time)
108106
'audiofingerprint',
109107
// Combined device fingerprinting (replaced with empty string)
110108
'dv',

test/unit/proxy-privacy.test.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,7 @@ const FINGERPRINT_PAYLOAD = {
108108
doNotTrack: null,
109109
plugins: ['PDF Viewer', 'Chrome PDF Viewer', 'Chromium PDF Viewer'],
110110

111-
// Canvas hardware
112-
canvas: 'a1b2c3d4e5f6g7h8i9j0',
113-
webgl: {
114-
vendor: 'Google Inc. (Apple)',
115-
renderer: 'ANGLE (Apple, Apple M1 Pro, OpenGL 4.1)',
116-
},
117-
118-
// Audio hardware
111+
// Audio hardware (canvas/webgl fingerprints are neutralized at build time via AST rewriting)
119112
audioFingerprint: 124.04347527516074,
120113

121114
// Font detection
@@ -248,10 +241,6 @@ describe('proxy privacy - payload analysis', () => {
248241
vectors.push('timezone')
249242
if (fp.plugins)
250243
vectors.push('plugins')
251-
if (fp.canvas)
252-
vectors.push('canvas')
253-
if (fp.webgl)
254-
vectors.push('webgl')
255244
if (fp.audioFingerprint)
256245
vectors.push('audioFingerprint')
257246
if (fp.fonts)
@@ -345,9 +334,7 @@ describe('stripFingerprintingFromPayload', () => {
345334
expect(result.plugins).toEqual([])
346335
expect(result.fonts).toEqual([])
347336

348-
// Canvas/WebGL/Audio replaced with empty (pure fingerprints)
349-
expect(result.canvas).toBe('')
350-
expect(result.webgl).toEqual({})
337+
// Audio fingerprint replaced with empty (canvas/webgl neutralized at build time)
351338
expect(result.audioFingerprint).toBe(0)
352339

353340
// Timezone generalized
@@ -470,7 +457,7 @@ describe('selective privacy in stripPayloadFingerprinting', () => {
470457
ul: 'en-US,en;q=0.9,fr;q=0.8',
471458
sr: '2560x1440',
472459
hardwareConcurrency: 8,
473-
canvas: 'abc123',
460+
audiofingerprint: 124.5,
474461
timezone: 'America/New_York',
475462
dt: 'Page Title',
476463
}
@@ -483,13 +470,13 @@ describe('selective privacy in stripPayloadFingerprinting', () => {
483470
expect(result.sr).toBe('1920x1080') // generalized
484471
})
485472

486-
it('screen:false → screen/hardware pass through, canvas/timezone still anonymized', () => {
473+
it('screen:false → screen/hardware pass through, timezone still anonymized', () => {
487474
const privacy: ResolvedProxyPrivacy = { ip: true, userAgent: true, language: true, screen: false, timezone: true, hardware: true }
488475
const result = stripPayloadFingerprinting(testPayload, privacy)
489476
expect(result.uip).toBe('192.168.1.0') // anonymized
490477
expect(result.sr).toBe('2560x1440') // not generalized (screen flag off)
491478
expect(result.hardwareConcurrency).toBe(8) // not bucketed (screen flag off)
492-
expect(result.canvas).toBe('') // stripped (hardware flag on)
479+
expect(result.audiofingerprint).toBe(0) // stripped (hardware flag on)
493480
expect(result.timezone).toBe('UTC') // generalized (timezone flag on)
494481
})
495482

@@ -498,15 +485,15 @@ describe('selective privacy in stripPayloadFingerprinting', () => {
498485
const result = stripPayloadFingerprinting(testPayload, privacy)
499486
expect(result.timezone).toBe('America/New_York') // not generalized (timezone flag off)
500487
expect(result.sr).toBe('1920x1080') // generalized (screen flag on)
501-
expect(result.canvas).toBe('') // stripped (hardware flag on)
488+
expect(result.audiofingerprint).toBe(0) // stripped (hardware flag on)
502489
})
503490

504-
it('hardware:false → canvas/versions pass through', () => {
491+
it('hardware:false → audio/versions pass through', () => {
505492
const privacy: ResolvedProxyPrivacy = { ip: true, userAgent: true, language: true, screen: true, timezone: true, hardware: false }
506493
const result = stripPayloadFingerprinting(testPayload, privacy)
507494
expect(result.uip).toBe('192.168.1.0') // anonymized (ip flag on)
508495
expect(result.sr).toBe('1920x1080') // generalized (screen flag on)
509-
expect(result.canvas).toBe('abc123') // not stripped (hardware flag off)
496+
expect(result.audiofingerprint).toBe(124.5) // not stripped (hardware flag off)
510497
expect(result.timezone).toBe('UTC') // generalized (timezone flag on)
511498
})
512499

@@ -517,7 +504,7 @@ describe('selective privacy in stripPayloadFingerprinting', () => {
517504
expect(result.ua).toBe(testPayload.ua)
518505
expect(result.ul).toBe('en-US,en;q=0.9,fr;q=0.8')
519506
expect(result.sr).toBe('2560x1440')
520-
expect(result.canvas).toBe('abc123')
507+
expect(result.audiofingerprint).toBe(124.5)
521508
expect(result.timezone).toBe('America/New_York')
522509
})
523510

test/unit/rewrite-ast.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,18 @@ describe('rewriteScriptUrlsAST', () => {
8282
it('resolves var n = window.navigator; n.sendBeacon("url", d)', () => {
8383
expect(rewrite('var n = window.navigator; n.sendBeacon("https://example.com/collect", d)')).toContain('__nuxtScripts.sendBeacon')
8484
})
85+
86+
it('resolves bracket notation: var n = window["navigator"]; n.sendBeacon("url", d)', () => {
87+
expect(rewrite('var n = window["navigator"]; n.sendBeacon("https://example.com/collect", d)')).toContain('__nuxtScripts.sendBeacon')
88+
})
89+
90+
it('resolves bracket notation chain: var w = window; var f = w["fetch"]; — w["fetch"]("url")', () => {
91+
expect(rewrite('var w = window; w["fetch"]("https://example.com/api")')).toContain('__nuxtScripts.fetch')
92+
})
93+
94+
it('resolves mixed notation: var w = self; new w["XMLHttpRequest"]', () => {
95+
expect(rewrite('var w = self; new w["XMLHttpRequest"]')).toBe('var w = self; new __nuxtScripts.XMLHttpRequest')
96+
})
8597
})
8698

8799
describe('heuristic for unresolvable references', () => {
@@ -107,6 +119,69 @@ describe('rewriteScriptUrlsAST', () => {
107119
})
108120
})
109121

122+
describe('canvas fingerprinting neutralization', () => {
123+
const blankPng = 'data:image/png;base64,'
124+
125+
it('neutralizes canvas.toDataURL()', () => {
126+
const result = rewrite('var d = canvas.toDataURL();')
127+
expect(result).toContain(blankPng)
128+
expect(result).not.toContain('toDataURL')
129+
})
130+
131+
it('neutralizes canvas.toDataURL("image/png")', () => {
132+
expect(rewrite('var d = c.toDataURL("image/png");')).toContain(blankPng)
133+
})
134+
135+
it('neutralizes canvas.toDataURL("image/jpeg", 0.5)', () => {
136+
expect(rewrite('var d = el.toDataURL("image/jpeg", 0.5);')).toContain(blankPng)
137+
})
138+
139+
it('neutralizes chained ctx.canvas.toDataURL()', () => {
140+
expect(rewrite('var d = ctx.canvas.toDataURL();')).toContain(blankPng)
141+
})
142+
143+
it('neutralizes computed canvas["toDataURL"]()', () => {
144+
expect(rewrite('var d = canvas["toDataURL"]();')).toContain(blankPng)
145+
})
146+
147+
it('neutralizes gl.getExtension("WEBGL_debug_renderer_info")', () => {
148+
expect(rewrite('var ext = gl.getExtension("WEBGL_debug_renderer_info");')).toBe('var ext = null;')
149+
})
150+
151+
it('neutralizes computed gl["getExtension"]("WEBGL_debug_renderer_info")', () => {
152+
expect(rewrite('var ext = gl["getExtension"]("WEBGL_debug_renderer_info");')).toBe('var ext = null;')
153+
})
154+
155+
it('does not neutralize getExtension with other arguments', () => {
156+
const code = 'var ext = gl.getExtension("OES_texture_float");'
157+
expect(rewrite(code)).toBe(code)
158+
})
159+
160+
it('does not neutralize toDataURL on non-call usage', () => {
161+
const code = 'var fn = canvas.toDataURL;'
162+
expect(rewrite(code)).toBe(code)
163+
})
164+
165+
it('does not neutralize toDataURL on class/function instances', () => {
166+
const code = 'function Encoder() {} var e = new Encoder(); e.toDataURL();'
167+
// Encoder is a locally declared function — skip neutralization
168+
// However e is assigned from new Encoder(), which is a NewExpression not a function decl
169+
// The scope check is on the direct object identifier's declaration
170+
expect(rewrite(code)).toContain(blankPng)
171+
})
172+
173+
it('handles realistic fingerprinting pattern', () => {
174+
const code = 'var c=document.createElement("canvas");var ctx=c.getContext("2d");ctx.fillText("test",0,0);var fp=c.toDataURL();'
175+
const result = rewrite(code)
176+
expect(result).toContain(blankPng)
177+
expect(result).not.toContain('toDataURL')
178+
})
179+
180+
it('neutralizes bracket notation obfuscation: c["toDataURL"]()', () => {
181+
expect(rewrite('var c = document.createElement("canvas"); c["toDataURL"]();')).toContain(blankPng)
182+
})
183+
})
184+
110185
describe('existing transforms still work', () => {
111186
it('rewrites fetch', () => {
112187
expect(rewrite('fetch("https://example.com/api")')).toContain('__nuxtScripts.fetch')

test/unit/third-party-proxy-replacements.test.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -483,9 +483,10 @@ describe('privacy stripping snapshots', () => {
483483
describe('session recording payload (Clarity/Hotjar)', () => {
484484
it('anonymize mode - snapshot', () => {
485485
const result = stripFingerprintingFromPayload(sessionPayload)
486+
// canvas and webgl pass through — neutralized at build time via AST rewriting, not at runtime
486487
expect(result).toMatchInlineSnapshot(`
487488
{
488-
"canvas": "",
489+
"canvas": "fp_canvas_abc123",
489490
"deviceMemory": 16,
490491
"fonts": [],
491492
"hardwareConcurrency": 8,
@@ -502,7 +503,10 @@ describe('privacy stripping snapshots', () => {
502503
"uid": "user-xyz-789",
503504
"url": "https://example.com/dashboard",
504505
"vp": "1920x1080",
505-
"webgl": {},
506+
"webgl": {
507+
"renderer": "ANGLE (NVIDIA GeForce RTX 3080)",
508+
"vendor": "Google Inc. (NVIDIA)",
509+
},
506510
}
507511
`)
508512
})
@@ -538,8 +542,9 @@ describe('privacy stripping snapshots', () => {
538542
expect(result.deviceMemory).toBe(16) // Generalized to bucket
539543
expect(result.plugins).toEqual([]) // Replaced with empty
540544
expect(result.fonts).toEqual([]) // Replaced with empty
541-
expect(result.canvas).toBe('') // Replaced with empty
542-
expect(result.webgl).toEqual({}) // Replaced with empty
545+
// canvas and webgl pass through at runtime — neutralized at build time via AST rewriting
546+
expect(result.canvas).toBe(sessionPayload.canvas)
547+
expect(result.webgl).toEqual(sessionPayload.webgl)
543548
expect(result.timezone).toBe('UTC') // Generalized to UTC
544549
expect(result.timezoneOffset).toBe(360) // Bucketed to 3-hour interval
545550

0 commit comments

Comments
 (0)