browser-commander 0.16.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.changeset/config.json +1 -1
- package/.changeset/merged-loud-river.md +7 -0
- package/.jscpd-baseline.json +257 -0
- package/.jscpd.json +1 -3
- package/.prettierignore +1 -0
- package/CHANGELOG.md +8 -0
- package/eslint.config.js +23 -1
- package/package.json +13 -24
- package/scripts/changeset-version.mjs +2 -6
- package/scripts/create-github-release.mjs +7 -8
- package/scripts/create-manual-changeset.mjs +7 -8
- package/scripts/format-github-release.mjs +6 -7
- package/scripts/format-release-notes.mjs +7 -8
- package/scripts/instant-version-bump.mjs +7 -8
- package/scripts/publish-to-npm.mjs +7 -8
- package/scripts/setup-npm.mjs +2 -6
- package/scripts/version-and-commit.mjs +7 -8
- package/src/browser/browser-cookie-credentials.js +3 -1
- package/src/browser/browser-cookies.js +6 -2
- package/src/browser/browser-profiles.js +1 -1
- package/src/browser/launch-options.js +31 -8
- package/src/browser/launcher.js +16 -0
- package/src/exports.js +34 -0
- package/src/fingerprint/apply.js +116 -0
- package/src/fingerprint/automation-parity.js +224 -0
- package/src/fingerprint/cdp-overrides.js +176 -0
- package/src/fingerprint/derive.js +104 -0
- package/src/fingerprint/init-payload.js +181 -0
- package/src/fingerprint/init-script.js +104 -0
- package/src/fingerprint/limitations.js +107 -0
- package/src/fingerprint/limitations.json +96 -0
- package/src/fingerprint/presets.js +244 -0
- package/src/fingerprint/profile.js +428 -0
- package/src/tests/index.js +2 -1
- package/tests/e2e/fingerprint-parity.e2e.test.js +212 -0
- package/tests/helpers/mocks.js +4 -2
- package/tests/helpers/repo.js +74 -0
- package/tests/unit/browser/launch-options.test.js +9 -1
- package/tests/unit/experiments/fingerprint-parity-harness.test.js +97 -0
- package/tests/unit/fingerprint/apply.test.js +271 -0
- package/tests/unit/fingerprint/automation-parity.test.js +212 -0
- package/tests/unit/fingerprint/cdp-overrides.test.js +227 -0
- package/tests/unit/fingerprint/derive.test.js +105 -0
- package/tests/unit/fingerprint/init-script.test.js +357 -0
- package/tests/unit/fingerprint/limitations.test.js +184 -0
- package/tests/unit/fingerprint/presets.test.js +135 -0
- package/tests/unit/fingerprint/profile.test.js +282 -0
- package/tests/unit/scripts/check-pipeline-status.test.js +133 -0
- package/tests/unit/scripts/check-web-archive.test.js +191 -0
- package/tests/unit/scripts/ci-timeout-budgets.test.js +311 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +193 -157
- package/tests/unit/scripts/debug-print.test.js +67 -0
- package/tests/unit/scripts/jscpd-config.test.js +141 -0
- package/tests/unit/scripts/pre-commit-config.test.js +209 -0
- package/tests/unit/scripts/read-manifest.test.js +194 -0
- package/tests/unit/scripts/run-with-budget-warning.test.js +115 -0
- package/tests/unit/scripts/use-module-adoption.test.js +74 -0
- package/tests/unit/scripts/use-module-integration.test.js +64 -0
- package/tests/unit/scripts/use-module.test.js +182 -0
- package/.husky/pre-commit +0 -1
- package/.prettierrc +0 -10
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import vm from 'node:vm';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
buildFingerprintInitScript,
|
|
7
|
+
buildInitScriptConfig,
|
|
8
|
+
FINGERPRINT_PAYLOAD_SOURCE,
|
|
9
|
+
} from '../../../src/fingerprint/init-script.js';
|
|
10
|
+
import { resolveFingerprintProfile } from '../../../src/fingerprint/profile.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Run a generated init script in a throwaway realm that looks enough like a
|
|
14
|
+
* page to exercise every patch: the payload only ever touches prototypes it
|
|
15
|
+
* finds on the global object, so a handful of stand-in constructors is all it
|
|
16
|
+
* needs. Running it for real is what proves the descriptor shapes, the
|
|
17
|
+
* `[native code]` masking and the double-application guard, none of which a
|
|
18
|
+
* string comparison would catch.
|
|
19
|
+
*/
|
|
20
|
+
function runInFakeRealm(script, { hostValues = {} } = {}) {
|
|
21
|
+
const context = vm.createContext({});
|
|
22
|
+
vm.runInContext(
|
|
23
|
+
`
|
|
24
|
+
function Navigator() {}
|
|
25
|
+
function WorkerNavigator() {}
|
|
26
|
+
function Screen() {}
|
|
27
|
+
function WebGLRenderingContext() {}
|
|
28
|
+
function WebGL2RenderingContext() {}
|
|
29
|
+
const host = ${JSON.stringify({
|
|
30
|
+
deviceMemory: 64,
|
|
31
|
+
vendor: 'Host Vendor',
|
|
32
|
+
platform: 'HostPlatform',
|
|
33
|
+
language: 'xx-XX',
|
|
34
|
+
languages: ['xx-XX'],
|
|
35
|
+
doNotTrack: '1',
|
|
36
|
+
hardwareConcurrency: 128,
|
|
37
|
+
maxTouchPoints: 0,
|
|
38
|
+
...hostValues,
|
|
39
|
+
})};
|
|
40
|
+
for (const property of Object.keys(host)) {
|
|
41
|
+
const value = host[property];
|
|
42
|
+
Object.defineProperty(Navigator.prototype, property, {
|
|
43
|
+
get() { return value; },
|
|
44
|
+
enumerable: true,
|
|
45
|
+
configurable: true,
|
|
46
|
+
});
|
|
47
|
+
Object.defineProperty(WorkerNavigator.prototype, property, {
|
|
48
|
+
get() { return value; },
|
|
49
|
+
enumerable: true,
|
|
50
|
+
configurable: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
for (const property of ['width', 'height', 'availWidth', 'availHeight', 'colorDepth', 'pixelDepth']) {
|
|
54
|
+
Object.defineProperty(Screen.prototype, property, {
|
|
55
|
+
get() { return 1; },
|
|
56
|
+
enumerable: true,
|
|
57
|
+
configurable: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
for (const name of ['WebGLRenderingContext', 'WebGL2RenderingContext']) {
|
|
61
|
+
this[name].prototype.getParameter = function getParameter(parameter) {
|
|
62
|
+
return 'real-' + parameter;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
globalThis.navigator = new Navigator();
|
|
66
|
+
globalThis.screen = new Screen();
|
|
67
|
+
globalThis.gl = new WebGLRenderingContext();
|
|
68
|
+
`,
|
|
69
|
+
context
|
|
70
|
+
);
|
|
71
|
+
vm.runInContext(script, context);
|
|
72
|
+
return context;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('init script config', () => {
|
|
76
|
+
it('rejects anything that is not a profile object', () => {
|
|
77
|
+
assert.throws(
|
|
78
|
+
() => buildFingerprintInitScript(undefined),
|
|
79
|
+
/must be a normalized fingerprint profile/u
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns null when the browser-side overrides already cover everything', () => {
|
|
84
|
+
const profile = resolveFingerprintProfile({
|
|
85
|
+
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) Chrome/140.0.0.0',
|
|
86
|
+
timezoneId: 'UTC',
|
|
87
|
+
hardwareConcurrency: 8,
|
|
88
|
+
maxTouchPoints: 0,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
assert.equal(buildInitScriptConfig(profile), null);
|
|
92
|
+
assert.equal(buildFingerprintInitScript(profile), null);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('patches only the fields the Emulation domain has no command for', () => {
|
|
96
|
+
const profile = resolveFingerprintProfile({
|
|
97
|
+
deviceMemory: 8,
|
|
98
|
+
vendor: 'Google Inc.',
|
|
99
|
+
doNotTrack: '1',
|
|
100
|
+
hardwareConcurrency: 8,
|
|
101
|
+
timezoneId: 'UTC',
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// hardwareConcurrency and timezoneId are browser-enforced, so they must
|
|
105
|
+
// not appear in the weaker JavaScript patch.
|
|
106
|
+
assert.deepEqual(Object.keys(buildInitScriptConfig(profile)).sort(), [
|
|
107
|
+
'deviceMemory',
|
|
108
|
+
'doNotTrack',
|
|
109
|
+
'vendor',
|
|
110
|
+
]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('adds webdriver only when the caller asks for it', () => {
|
|
114
|
+
const profile = resolveFingerprintProfile({ vendor: 'Google Inc.' });
|
|
115
|
+
|
|
116
|
+
assert.equal(buildInitScriptConfig(profile).webdriver, undefined);
|
|
117
|
+
assert.equal(
|
|
118
|
+
buildInitScriptConfig(profile, { patchWebdriver: true }).webdriver,
|
|
119
|
+
false
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('patches webdriver even for an otherwise empty profile', () => {
|
|
124
|
+
const script = buildFingerprintInitScript(resolveFingerprintProfile({}), {
|
|
125
|
+
patchWebdriver: true,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.ok(script);
|
|
129
|
+
const context = runInFakeRealm(script);
|
|
130
|
+
assert.equal(vm.runInContext('navigator.webdriver', context), false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('leaves languages to the browser unless explicitly asked', () => {
|
|
134
|
+
const profile = resolveFingerprintProfile({ languages: ['fr-FR', 'fr'] });
|
|
135
|
+
|
|
136
|
+
assert.equal(buildInitScriptConfig(profile), null);
|
|
137
|
+
assert.deepEqual(
|
|
138
|
+
buildInitScriptConfig(profile, { patchLanguages: true }).languages,
|
|
139
|
+
['fr-FR', 'fr']
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('drops the screen dimensions setDeviceMetricsOverride already enforces', () => {
|
|
144
|
+
const config = buildInitScriptConfig(
|
|
145
|
+
resolveFingerprintProfile({
|
|
146
|
+
screen: {
|
|
147
|
+
width: 1920,
|
|
148
|
+
height: 1080,
|
|
149
|
+
availWidth: 1920,
|
|
150
|
+
availHeight: 1032,
|
|
151
|
+
colorDepth: 24,
|
|
152
|
+
pixelDepth: 24,
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(Object.keys(config.screen).sort(), [
|
|
158
|
+
'availHeight',
|
|
159
|
+
'availWidth',
|
|
160
|
+
'colorDepth',
|
|
161
|
+
'pixelDepth',
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('skips the screen patch when only width and height are given', () => {
|
|
166
|
+
assert.equal(
|
|
167
|
+
buildInitScriptConfig(
|
|
168
|
+
resolveFingerprintProfile({ screen: { width: 1920, height: 1080 } })
|
|
169
|
+
),
|
|
170
|
+
null
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('init script behaviour in a page-like realm', () => {
|
|
176
|
+
const profile = resolveFingerprintProfile({
|
|
177
|
+
deviceMemory: 8,
|
|
178
|
+
vendor: 'Google Inc.',
|
|
179
|
+
doNotTrack: null,
|
|
180
|
+
platform: 'Win32',
|
|
181
|
+
languages: ['de-DE', 'de'],
|
|
182
|
+
screen: {
|
|
183
|
+
width: 1920,
|
|
184
|
+
height: 1080,
|
|
185
|
+
availWidth: 1920,
|
|
186
|
+
availHeight: 1032,
|
|
187
|
+
colorDepth: 24,
|
|
188
|
+
pixelDepth: 24,
|
|
189
|
+
},
|
|
190
|
+
webgl: {
|
|
191
|
+
vendor: 'WebKit',
|
|
192
|
+
renderer: 'WebKit WebGL',
|
|
193
|
+
unmaskedVendor: 'Google Inc. (NVIDIA)',
|
|
194
|
+
unmaskedRenderer: 'ANGLE (NVIDIA, NVIDIA GeForce RTX 3060, D3D11)',
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
const script = buildFingerprintInitScript(profile, {
|
|
198
|
+
patchWebdriver: true,
|
|
199
|
+
patchLanguages: true,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('replaces the values a page reads from navigator', () => {
|
|
203
|
+
const context = runInFakeRealm(script);
|
|
204
|
+
|
|
205
|
+
assert.equal(vm.runInContext('navigator.deviceMemory', context), 8);
|
|
206
|
+
assert.equal(vm.runInContext('navigator.vendor', context), 'Google Inc.');
|
|
207
|
+
assert.equal(vm.runInContext('navigator.webdriver', context), false);
|
|
208
|
+
assert.equal(vm.runInContext('navigator.language', context), 'de-DE');
|
|
209
|
+
assert.deepEqual(
|
|
210
|
+
vm.runInContext('Array.from(navigator.languages)', context),
|
|
211
|
+
['de-DE', 'de']
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('hands out a frozen copy of languages so page code cannot mutate ours', () => {
|
|
216
|
+
const context = runInFakeRealm(script);
|
|
217
|
+
|
|
218
|
+
assert.equal(
|
|
219
|
+
vm.runInContext('Object.isFrozen(navigator.languages)', context),
|
|
220
|
+
true
|
|
221
|
+
);
|
|
222
|
+
assert.equal(
|
|
223
|
+
vm.runInContext('navigator.languages !== navigator.languages', context),
|
|
224
|
+
true
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('patches only the screen fields the profile named', () => {
|
|
229
|
+
const context = runInFakeRealm(script);
|
|
230
|
+
|
|
231
|
+
assert.equal(vm.runInContext('screen.availHeight', context), 1032);
|
|
232
|
+
assert.equal(vm.runInContext('screen.colorDepth', context), 24);
|
|
233
|
+
// width and height stay with the browser-side override.
|
|
234
|
+
assert.equal(vm.runInContext('screen.width', context), 1);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('answers the WebGL renderer queries and forwards everything else', () => {
|
|
238
|
+
const context = runInFakeRealm(script);
|
|
239
|
+
|
|
240
|
+
// 0x9245/0x9246 are UNMASKED_VENDOR_WEBGL/UNMASKED_RENDERER_WEBGL.
|
|
241
|
+
assert.equal(
|
|
242
|
+
vm.runInContext('gl.getParameter(0x9245)', context),
|
|
243
|
+
'Google Inc. (NVIDIA)'
|
|
244
|
+
);
|
|
245
|
+
assert.equal(vm.runInContext('gl.getParameter(0x1f00)', context), 'WebKit');
|
|
246
|
+
assert.equal(
|
|
247
|
+
vm.runInContext('gl.getParameter(0x1234)', context),
|
|
248
|
+
'real-4660'
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('keeps the descriptor shape of a real accessor', () => {
|
|
253
|
+
const context = runInFakeRealm(script);
|
|
254
|
+
const descriptor = vm.runInContext(
|
|
255
|
+
`(() => {
|
|
256
|
+
const d = Object.getOwnPropertyDescriptor(Navigator.prototype, 'deviceMemory');
|
|
257
|
+
return {
|
|
258
|
+
enumerable: d.enumerable,
|
|
259
|
+
configurable: d.configurable,
|
|
260
|
+
hasGetter: typeof d.get === 'function',
|
|
261
|
+
setter: d.set,
|
|
262
|
+
name: d.get.name,
|
|
263
|
+
};
|
|
264
|
+
})()`,
|
|
265
|
+
context
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
assert.deepEqual(descriptor, {
|
|
269
|
+
enumerable: true,
|
|
270
|
+
configurable: true,
|
|
271
|
+
hasGetter: true,
|
|
272
|
+
setter: undefined,
|
|
273
|
+
name: 'get deviceMemory',
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('makes the replacements report native source', () => {
|
|
278
|
+
const context = runInFakeRealm(script);
|
|
279
|
+
|
|
280
|
+
assert.equal(
|
|
281
|
+
vm.runInContext(
|
|
282
|
+
"Object.getOwnPropertyDescriptor(Navigator.prototype, 'deviceMemory').get.toString()",
|
|
283
|
+
context
|
|
284
|
+
),
|
|
285
|
+
'function get deviceMemory() { [native code] }'
|
|
286
|
+
);
|
|
287
|
+
assert.equal(
|
|
288
|
+
vm.runInContext(
|
|
289
|
+
'WebGLRenderingContext.prototype.getParameter.toString()',
|
|
290
|
+
context
|
|
291
|
+
),
|
|
292
|
+
'function getParameter() { [native code] }'
|
|
293
|
+
);
|
|
294
|
+
// The masking of toString itself has to be masked too, or the patch is
|
|
295
|
+
// detectable by asking the one function that does the lying.
|
|
296
|
+
assert.equal(
|
|
297
|
+
vm.runInContext('Function.prototype.toString.toString()', context),
|
|
298
|
+
'function toString() { [native code] }'
|
|
299
|
+
);
|
|
300
|
+
// Untouched functions still report their real source.
|
|
301
|
+
assert.match(
|
|
302
|
+
vm.runInContext('(function example() { return 1; }).toString()', context),
|
|
303
|
+
/return 1/u
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('does not stack getters when the script runs twice', () => {
|
|
308
|
+
const context = runInFakeRealm(script);
|
|
309
|
+
vm.runInContext(script, context);
|
|
310
|
+
|
|
311
|
+
assert.equal(vm.runInContext('navigator.deviceMemory', context), 8);
|
|
312
|
+
assert.equal(
|
|
313
|
+
vm.runInContext(
|
|
314
|
+
'WebGLRenderingContext.prototype.getParameter.toString()',
|
|
315
|
+
context
|
|
316
|
+
),
|
|
317
|
+
'function getParameter() { [native code] }'
|
|
318
|
+
);
|
|
319
|
+
assert.equal(
|
|
320
|
+
vm.runInContext('gl.getParameter(0x1234)', context),
|
|
321
|
+
'real-4660'
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('carries the shared payload asset and nothing else', () => {
|
|
326
|
+
// The payload is one file that Python and Rust send byte for byte, so the
|
|
327
|
+
// script has to be that file rather than a copy generated from a function
|
|
328
|
+
// in this package. scripts/check-shared-fingerprint-assets.sh guards the copies.
|
|
329
|
+
assert.ok(script.includes(FINGERPRINT_PAYLOAD_SOURCE));
|
|
330
|
+
assert.match(
|
|
331
|
+
FINGERPRINT_PAYLOAD_SOURCE,
|
|
332
|
+
/^function fingerprintPayload\(/mu
|
|
333
|
+
);
|
|
334
|
+
assert.doesNotMatch(FINGERPRINT_PAYLOAD_SOURCE, /^(import|export)\s/mu);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('leaves no helper of its own on the page global', () => {
|
|
338
|
+
const context = runInFakeRealm(script);
|
|
339
|
+
|
|
340
|
+
assert.equal(
|
|
341
|
+
vm.runInContext('typeof fingerprintPayload', context),
|
|
342
|
+
'undefined'
|
|
343
|
+
);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('hides its own marker from enumeration', () => {
|
|
347
|
+
const context = runInFakeRealm(script);
|
|
348
|
+
|
|
349
|
+
assert.equal(
|
|
350
|
+
vm.runInContext(
|
|
351
|
+
"Object.keys(globalThis).includes('__browserCommanderFingerprint')",
|
|
352
|
+
context
|
|
353
|
+
),
|
|
354
|
+
false
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
FINGERPRINT_LIMITATIONS,
|
|
7
|
+
findFingerprintLimitation,
|
|
8
|
+
relevantFingerprintLimitations,
|
|
9
|
+
} from '../../../src/fingerprint/limitations.js';
|
|
10
|
+
|
|
11
|
+
const ids = (limitations) => limitations.map((limitation) => limitation.id);
|
|
12
|
+
|
|
13
|
+
describe('the documented limitations', () => {
|
|
14
|
+
it('gives every entry a unique id and the fields a reader needs', () => {
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
for (const limitation of FINGERPRINT_LIMITATIONS) {
|
|
17
|
+
assert.ok(!seen.has(limitation.id), `duplicate id ${limitation.id}`);
|
|
18
|
+
seen.add(limitation.id);
|
|
19
|
+
assert.ok(limitation.surface.length > 0, limitation.id);
|
|
20
|
+
assert.ok(limitation.detail.length > 0, limitation.id);
|
|
21
|
+
assert.ok(
|
|
22
|
+
['high', 'medium', 'low'].includes(limitation.severity),
|
|
23
|
+
limitation.id
|
|
24
|
+
);
|
|
25
|
+
assert.ok(
|
|
26
|
+
['measured', 'documented'].includes(limitation.evidence),
|
|
27
|
+
limitation.id
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('points every measured entry at the artifact that proves it', () => {
|
|
33
|
+
for (const limitation of FINGERPRINT_LIMITATIONS) {
|
|
34
|
+
if (limitation.evidence === 'measured') {
|
|
35
|
+
assert.ok(limitation.reference, `${limitation.id} needs a reference`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('cannot be edited by a caller', () => {
|
|
41
|
+
assert.ok(Object.isFrozen(FINGERPRINT_LIMITATIONS));
|
|
42
|
+
assert.ok(FINGERPRINT_LIMITATIONS.every((entry) => Object.isFrozen(entry)));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('is the shared JSON asset Python and Rust also publish', () => {
|
|
46
|
+
// The catalogue is data, not code, so the three packages ship one file
|
|
47
|
+
// rather than three translations of the same eleven paragraphs.
|
|
48
|
+
const asset = JSON.parse(
|
|
49
|
+
readFileSync(
|
|
50
|
+
new URL('../../../src/fingerprint/limitations.json', import.meta.url),
|
|
51
|
+
'utf8'
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
assert.deepEqual(asset, FINGERPRINT_LIMITATIONS);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('looks an entry up by id', () => {
|
|
59
|
+
assert.equal(
|
|
60
|
+
findFingerprintLimitation('webgl-strings-only').surface,
|
|
61
|
+
'WebGL renderer strings and driver limits'
|
|
62
|
+
);
|
|
63
|
+
assert.equal(findFingerprintLimitation('no-such-limitation'), undefined);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('limitations relevant to a profile', () => {
|
|
68
|
+
it('always reports the two nothing can be done about', () => {
|
|
69
|
+
assert.deepEqual(ids(relevantFingerprintLimitations()), [
|
|
70
|
+
'canvas-audio-font-follow-the-host',
|
|
71
|
+
'network-layer-not-covered',
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("mentions the launch-only switch only when attaching to someone else's browser", () => {
|
|
76
|
+
assert.ok(
|
|
77
|
+
!ids(relevantFingerprintLimitations({}, {})).includes(
|
|
78
|
+
'automation-controlled-is-launch-only'
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
assert.ok(
|
|
82
|
+
ids(relevantFingerprintLimitations({}, { attached: true })).includes(
|
|
83
|
+
'automation-controlled-is-launch-only'
|
|
84
|
+
)
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('mentions headless only for a headless browser', () => {
|
|
89
|
+
assert.ok(
|
|
90
|
+
ids(relevantFingerprintLimitations({}, { headless: true })).includes(
|
|
91
|
+
'headless-is-distinguishable'
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('mentions the JavaScript-only fields when the profile sets them', () => {
|
|
97
|
+
assert.ok(
|
|
98
|
+
ids(relevantFingerprintLimitations({ deviceMemory: 8 })).includes(
|
|
99
|
+
'no-cdp-device-memory-override'
|
|
100
|
+
)
|
|
101
|
+
);
|
|
102
|
+
assert.ok(
|
|
103
|
+
ids(relevantFingerprintLimitations({ doNotTrack: '1' })).includes(
|
|
104
|
+
'no-cdp-vendor-or-dnt-override'
|
|
105
|
+
)
|
|
106
|
+
);
|
|
107
|
+
assert.ok(
|
|
108
|
+
ids(
|
|
109
|
+
relevantFingerprintLimitations({ webgl: { vendor: 'WebKit' } })
|
|
110
|
+
).includes('webgl-strings-only')
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('mentions the screen entry only for the fields CDP does not emulate', () => {
|
|
115
|
+
assert.ok(
|
|
116
|
+
!ids(
|
|
117
|
+
relevantFingerprintLimitations({
|
|
118
|
+
screen: { width: 1920, height: 1080 },
|
|
119
|
+
})
|
|
120
|
+
).includes('screen-depth-and-avail-not-emulated')
|
|
121
|
+
);
|
|
122
|
+
assert.ok(
|
|
123
|
+
ids(
|
|
124
|
+
relevantFingerprintLimitations({
|
|
125
|
+
screen: { width: 1920, height: 1080, availHeight: 1032 },
|
|
126
|
+
})
|
|
127
|
+
).includes('screen-depth-and-avail-not-emulated')
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('mentions the pointer side effect only when touch is actually enabled', () => {
|
|
132
|
+
assert.ok(
|
|
133
|
+
!ids(relevantFingerprintLimitations({ maxTouchPoints: 0 })).includes(
|
|
134
|
+
'touch-emulation-changes-pointer-media'
|
|
135
|
+
)
|
|
136
|
+
);
|
|
137
|
+
assert.ok(
|
|
138
|
+
ids(relevantFingerprintLimitations({ maxTouchPoints: 5 })).includes(
|
|
139
|
+
'touch-emulation-changes-pointer-media'
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('mentions workers for every field a worker reads differently', () => {
|
|
145
|
+
// Measured in worker-visibility.json: platform, languages and
|
|
146
|
+
// hardwareConcurrency revert to the host values inside a worker even
|
|
147
|
+
// though the page session overrides them.
|
|
148
|
+
for (const profile of [
|
|
149
|
+
{ platform: 'Win32' },
|
|
150
|
+
{ languages: ['de-DE'] },
|
|
151
|
+
{ hardwareConcurrency: 8 },
|
|
152
|
+
{ deviceMemory: 8 },
|
|
153
|
+
]) {
|
|
154
|
+
assert.ok(
|
|
155
|
+
ids(relevantFingerprintLimitations(profile)).includes(
|
|
156
|
+
'init-script-does-not-reach-workers'
|
|
157
|
+
),
|
|
158
|
+
JSON.stringify(profile)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
assert.ok(
|
|
162
|
+
!ids(relevantFingerprintLimitations({ timezoneId: 'UTC' })).includes(
|
|
163
|
+
'init-script-does-not-reach-workers'
|
|
164
|
+
)
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('keeps the declaration order and returns a frozen list', () => {
|
|
169
|
+
const relevant = relevantFingerprintLimitations(
|
|
170
|
+
{ deviceMemory: 8, webgl: { vendor: 'WebKit' } },
|
|
171
|
+
{ headless: true, attached: true }
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
assert.ok(Object.isFrozen(relevant));
|
|
175
|
+
assert.deepEqual(
|
|
176
|
+
ids(relevant),
|
|
177
|
+
ids(
|
|
178
|
+
FINGERPRINT_LIMITATIONS.filter((entry) =>
|
|
179
|
+
ids(relevant).includes(entry.id)
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createFingerprintPreset,
|
|
6
|
+
FINGERPRINT_PRESET_NAMES,
|
|
7
|
+
} from '../../../src/fingerprint/presets.js';
|
|
8
|
+
import { buildCdpEmulationCommands } from '../../../src/fingerprint/cdp-overrides.js';
|
|
9
|
+
|
|
10
|
+
describe('fingerprint presets', () => {
|
|
11
|
+
it('names the platforms Chrome ships on', () => {
|
|
12
|
+
assert.deepEqual(
|
|
13
|
+
[...FINGERPRINT_PRESET_NAMES],
|
|
14
|
+
['android-chrome', 'linux-chrome', 'macos-chrome', 'windows-chrome']
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('rejects an unknown preset and lists the known ones', () => {
|
|
19
|
+
assert.throws(
|
|
20
|
+
() => createFingerprintPreset('windows-edge'),
|
|
21
|
+
/unknown fingerprint preset "windows-edge".*windows-chrome/su
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('rejects a Chrome version that is not a dotted number', () => {
|
|
26
|
+
for (const chromeVersion of ['latest', '', 140, '140.x']) {
|
|
27
|
+
assert.throws(
|
|
28
|
+
() => createFingerprintPreset('linux-chrome', { chromeVersion }),
|
|
29
|
+
/dotted numeric version string/u
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('describes one machine consistently', () => {
|
|
35
|
+
// Every serious fingerprinting script cross-checks these against each
|
|
36
|
+
// other, so a preset is only useful when they agree.
|
|
37
|
+
const expectations = {
|
|
38
|
+
'windows-chrome': {
|
|
39
|
+
platform: 'Win32',
|
|
40
|
+
uaToken: 'Windows NT 10.0',
|
|
41
|
+
hint: 'Windows',
|
|
42
|
+
mobile: false,
|
|
43
|
+
},
|
|
44
|
+
'macos-chrome': {
|
|
45
|
+
platform: 'MacIntel',
|
|
46
|
+
uaToken: 'Mac OS X',
|
|
47
|
+
hint: 'macOS',
|
|
48
|
+
mobile: false,
|
|
49
|
+
},
|
|
50
|
+
'linux-chrome': {
|
|
51
|
+
platform: 'Linux x86_64',
|
|
52
|
+
uaToken: 'X11; Linux x86_64',
|
|
53
|
+
hint: 'Linux',
|
|
54
|
+
mobile: false,
|
|
55
|
+
},
|
|
56
|
+
'android-chrome': {
|
|
57
|
+
platform: 'Linux armv81',
|
|
58
|
+
uaToken: 'Android 15',
|
|
59
|
+
hint: 'Android',
|
|
60
|
+
mobile: true,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const [name, expected] of Object.entries(expectations)) {
|
|
65
|
+
const profile = createFingerprintPreset(name);
|
|
66
|
+
assert.equal(profile.platform, expected.platform, name);
|
|
67
|
+
assert.ok(profile.userAgent.includes(expected.uaToken), name);
|
|
68
|
+
assert.equal(profile.userAgentData.platform, expected.hint, name);
|
|
69
|
+
assert.equal(profile.userAgentData.mobile, expected.mobile, name);
|
|
70
|
+
assert.equal(profile.viewport.mobile, expected.mobile, name);
|
|
71
|
+
assert.equal(
|
|
72
|
+
profile.userAgent.includes('Mobile Safari'),
|
|
73
|
+
expected.mobile,
|
|
74
|
+
name
|
|
75
|
+
);
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
profile.userAgentData.formFactors,
|
|
78
|
+
[expected.mobile ? 'Mobile' : 'Desktop'],
|
|
79
|
+
name
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('keeps the viewport inside the screen it claims', () => {
|
|
85
|
+
for (const name of FINGERPRINT_PRESET_NAMES) {
|
|
86
|
+
const { screen, viewport } = createFingerprintPreset(name);
|
|
87
|
+
assert.ok(viewport.width <= screen.width, name);
|
|
88
|
+
assert.ok(viewport.height <= screen.availHeight, name);
|
|
89
|
+
assert.ok(screen.availWidth <= screen.width, name);
|
|
90
|
+
assert.ok(screen.availHeight <= screen.height, name);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('gives touch points only to the mobile preset', () => {
|
|
95
|
+
assert.equal(createFingerprintPreset('android-chrome').maxTouchPoints, 5);
|
|
96
|
+
for (const name of ['windows-chrome', 'macos-chrome', 'linux-chrome']) {
|
|
97
|
+
assert.equal(createFingerprintPreset(name).maxTouchPoints, 0, name);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('puts the requested Chrome version everywhere it appears', () => {
|
|
102
|
+
const profile = createFingerprintPreset('windows-chrome', {
|
|
103
|
+
chromeVersion: '141.0.7390.55',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// The user agent carries only the major version, as Chrome freezes it.
|
|
107
|
+
assert.ok(profile.userAgent.includes('Chrome/141.0.0.0'));
|
|
108
|
+
for (const entry of profile.userAgentData.brands) {
|
|
109
|
+
assert.ok(['141', '24'].includes(entry.version), entry.brand);
|
|
110
|
+
}
|
|
111
|
+
assert.ok(
|
|
112
|
+
profile.userAgentData.fullVersionList.some(
|
|
113
|
+
(entry) => entry.version === '141.0.7390.55'
|
|
114
|
+
)
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('merges caller overrides over the preset', () => {
|
|
119
|
+
const profile = createFingerprintPreset('linux-chrome', {
|
|
120
|
+
overrides: { timezoneId: 'Europe/Lisbon', hardwareConcurrency: 32 },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
assert.equal(profile.timezoneId, 'Europe/Lisbon');
|
|
124
|
+
assert.equal(profile.hardwareConcurrency, 32);
|
|
125
|
+
assert.equal(profile.platform, 'Linux x86_64');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns a normalized, frozen profile every preset can be applied from', () => {
|
|
129
|
+
for (const name of FINGERPRINT_PRESET_NAMES) {
|
|
130
|
+
const profile = createFingerprintPreset(name);
|
|
131
|
+
assert.ok(Object.isFrozen(profile), name);
|
|
132
|
+
assert.ok(buildCdpEmulationCommands(profile).length > 0, name);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|