barebrowse 0.15.0 → 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/CHANGELOG.md +111 -1
- package/README.md +18 -1
- package/barebrowse.context.md +3 -3
- package/package.json +1 -1
- package/src/consent-firefox.js +125 -0
- package/src/consent-patterns.js +154 -0
- package/src/consent.js +5 -144
- package/src/daemon.js +74 -4
- package/src/firefox-page.js +73 -44
- package/src/index.js +19 -6
- package/src/network-idle.js +66 -28
- package/src/stealth-firefox.js +42 -0
- package/src/stealth.js +107 -69
- package/types/consent-firefox.d.ts +9 -0
- package/types/consent-patterns.d.ts +12 -0
- package/types/daemon.d.ts +22 -0
- package/types/firefox-page.d.ts +2 -0
- package/types/network-idle.d.ts +16 -8
- package/types/stealth-firefox.d.ts +9 -0
- package/types/stealth.d.ts +31 -0
package/src/stealth.js
CHANGED
|
@@ -5,9 +5,114 @@
|
|
|
5
5
|
* any page scripts (unlike Runtime.evaluate which runs after).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* navigator.webdriver hiding — the one automation tell shared by every engine
|
|
10
|
+
* (Chromium headless AND Firefox-under-BiDi both report `true`). Split out so
|
|
11
|
+
* the Firefox stealth path (stealth-firefox.js) reuses the exact same patch
|
|
12
|
+
* instead of duplicating it. Measured baseline on both engines: `true`.
|
|
13
|
+
*
|
|
14
|
+
* A naive `Object.defineProperty(navigator, 'webdriver', …)` hides the *value*
|
|
15
|
+
* but leaves an advanced tell: it creates an OWN property on the instance, so
|
|
16
|
+
* `navigator.hasOwnProperty('webdriver')` becomes `true` where a real browser
|
|
17
|
+
* (property lives on Navigator.prototype) reports `false` — sophisticated
|
|
18
|
+
* anti-bot checks (e.g. sannysoft's "WebDriver New") catch that inconsistency.
|
|
19
|
+
* So we DELETE the getter off the prototype instead: then `navigator.webdriver`
|
|
20
|
+
* is `undefined` AND `'webdriver' in navigator` AND `hasOwnProperty` are both
|
|
21
|
+
* `false`, matching a stock browser. POC-verified on Chromium and Firefox
|
|
22
|
+
* (own/in both false, prototype descriptor gone). The defineProperty fallback
|
|
23
|
+
* only fires if the descriptor is somehow non-configurable (delete a no-op).
|
|
24
|
+
*/
|
|
25
|
+
export const WEBDRIVER_PATCH = `
|
|
26
|
+
try {
|
|
27
|
+
const proto = Object.getPrototypeOf(navigator);
|
|
28
|
+
const desc = proto && Object.getOwnPropertyDescriptor(proto, 'webdriver');
|
|
29
|
+
if (desc && desc.configurable) delete proto.webdriver;
|
|
30
|
+
if (navigator.webdriver) {
|
|
31
|
+
Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });
|
|
32
|
+
}
|
|
33
|
+
} catch {}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Canvas-fingerprint noise — browser-agnostic (standard Canvas2D API), so both
|
|
38
|
+
* the Chromium and Firefox stealth scripts compose it. This block carries the
|
|
39
|
+
* subtle double-XOR-cancellation fixes (see git history / project memory); keep
|
|
40
|
+
* it SINGLE-SOURCED here so a fix never has to be made in two places.
|
|
41
|
+
*/
|
|
42
|
+
export const CANVAS_NOISE_PATCH = `
|
|
43
|
+
// Canvas fingerprinting — sites render standard text/shapes, then read
|
|
44
|
+
// pixels via toDataURL or getImageData. The output is stable per machine
|
|
45
|
+
// (GPU, font rasterizer, anti-aliasing) but unique across machines, which
|
|
46
|
+
// makes it the second-most-common fingerprint after WebGL. Defense: nudge
|
|
47
|
+
// a few RGB channels by ±1 per session so the hash changes each visit
|
|
48
|
+
// while the canvas still looks identical to the human eye. The per-tab
|
|
49
|
+
// seed keeps reads stable within a session so legitimate canvas use
|
|
50
|
+
// (image processing, games) doesn't flicker.
|
|
51
|
+
// crypto.getRandomValues is guaranteed unique per browsing context; using
|
|
52
|
+
// Math.random alone can collide when two fresh V8 contexts start within
|
|
53
|
+
// microseconds of each other (real-world: parallel tests, real-world hit:
|
|
54
|
+
// we observed it). performance.now adds a wall-clock anchor as a belt-and-
|
|
55
|
+
// braces guard against contexts where crypto is somehow stubbed.
|
|
56
|
+
const _seedBuf = new Uint32Array(1);
|
|
57
|
+
crypto.getRandomValues(_seedBuf);
|
|
58
|
+
const CANVAS_SEED = (_seedBuf[0] ^ ((performance.now() * 1e6) | 0)) >>> 0;
|
|
59
|
+
function shiftPixels(data) {
|
|
60
|
+
// Touch ~1 byte per 64-byte stride. The bit we XOR with is taken from a
|
|
61
|
+
// position-dependent SLICE of a seed-mixed hash, not its low bit — a
|
|
62
|
+
// naive 'mix & 1' collapses to only two possible outputs per seed
|
|
63
|
+
// parity because every stride index is even (the position multiplier
|
|
64
|
+
// is odd, so the low bit only depends on seed parity). Indexing the
|
|
65
|
+
// hash by (i/64) mod 32 makes every stride position sample a different
|
|
66
|
+
// bit, so two distinct seeds produce different mask patterns.
|
|
67
|
+
for (let i = 0; i < data.length; i += 64) {
|
|
68
|
+
const h = ((CANVAS_SEED * 2654435761) ^ (i * 1597334677)) >>> 0;
|
|
69
|
+
const bit = (h >>> ((i >>> 6) & 31)) & 1;
|
|
70
|
+
data[i] = (data[i] ^ bit) & 0xff;
|
|
71
|
+
}
|
|
72
|
+
return data;
|
|
73
|
+
}
|
|
74
|
+
// Capture originals BEFORE replacing — toDataURL must read pixels via the
|
|
75
|
+
// native getImageData (not the patched one), otherwise the patch double-
|
|
76
|
+
// applies and the second XOR cancels the first, leaving output unchanged.
|
|
77
|
+
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
|
|
78
|
+
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
|
79
|
+
HTMLCanvasElement.prototype.toDataURL = function() {
|
|
80
|
+
const ctx = this.getContext('2d');
|
|
81
|
+
if (ctx && this.width > 0 && this.height > 0) {
|
|
82
|
+
try {
|
|
83
|
+
const img = origGetImageData.call(ctx, 0, 0, this.width, this.height);
|
|
84
|
+
// Snapshot the original bytes so we can restore them after encoding.
|
|
85
|
+
// Without this, repeated toDataURL() alternates noisy/clean: call 1
|
|
86
|
+
// XORs the canvas in place, call 2 reads the noisy canvas and XORs
|
|
87
|
+
// again (self-inverse), call 3 again, etc. Same XOR-cancellation
|
|
88
|
+
// class as the earlier double-apply bug, just through canvas state
|
|
89
|
+
// rather than method composition. The restore also keeps the
|
|
90
|
+
// bitmap idempotent for any downstream legitimate canvas reads.
|
|
91
|
+
const original = new Uint8ClampedArray(img.data);
|
|
92
|
+
shiftPixels(img.data);
|
|
93
|
+
ctx.putImageData(img, 0, 0);
|
|
94
|
+
const result = origToDataURL.apply(this, arguments);
|
|
95
|
+
img.data.set(original);
|
|
96
|
+
ctx.putImageData(img, 0, 0);
|
|
97
|
+
return result;
|
|
98
|
+
} catch {
|
|
99
|
+
// Tainted canvas (cross-origin image) — can't read; skip the nudge
|
|
100
|
+
// and fall through to the original call so the page sees the
|
|
101
|
+
// expected SecurityError instead of silent corruption.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return origToDataURL.apply(this, arguments);
|
|
105
|
+
};
|
|
106
|
+
CanvasRenderingContext2D.prototype.getImageData = function() {
|
|
107
|
+
const img = origGetImageData.apply(this, arguments);
|
|
108
|
+
shiftPixels(img.data);
|
|
109
|
+
return img;
|
|
110
|
+
};
|
|
111
|
+
`;
|
|
112
|
+
|
|
8
113
|
const STEALTH_SCRIPT = `
|
|
9
114
|
// Hide webdriver flag
|
|
10
|
-
|
|
115
|
+
${WEBDRIVER_PATCH}
|
|
11
116
|
|
|
12
117
|
// Fake plugins (headless has 0)
|
|
13
118
|
Object.defineProperty(navigator, 'plugins', {
|
|
@@ -93,74 +198,7 @@ const STEALTH_SCRIPT = `
|
|
|
93
198
|
};
|
|
94
199
|
}
|
|
95
200
|
|
|
96
|
-
|
|
97
|
-
// pixels via toDataURL or getImageData. The output is stable per machine
|
|
98
|
-
// (GPU, font rasterizer, anti-aliasing) but unique across machines, which
|
|
99
|
-
// makes it the second-most-common fingerprint after WebGL. Defense: nudge
|
|
100
|
-
// a few RGB channels by ±1 per session so the hash changes each visit
|
|
101
|
-
// while the canvas still looks identical to the human eye. The per-tab
|
|
102
|
-
// seed keeps reads stable within a session so legitimate canvas use
|
|
103
|
-
// (image processing, games) doesn't flicker.
|
|
104
|
-
// crypto.getRandomValues is guaranteed unique per browsing context; using
|
|
105
|
-
// Math.random alone can collide when two fresh V8 contexts start within
|
|
106
|
-
// microseconds of each other (real-world: parallel tests, real-world hit:
|
|
107
|
-
// we observed it). performance.now adds a wall-clock anchor as a belt-and-
|
|
108
|
-
// braces guard against contexts where crypto is somehow stubbed.
|
|
109
|
-
const _seedBuf = new Uint32Array(1);
|
|
110
|
-
crypto.getRandomValues(_seedBuf);
|
|
111
|
-
const CANVAS_SEED = (_seedBuf[0] ^ ((performance.now() * 1e6) | 0)) >>> 0;
|
|
112
|
-
function shiftPixels(data) {
|
|
113
|
-
// Touch ~1 byte per 64-byte stride. The bit we XOR with is taken from a
|
|
114
|
-
// position-dependent SLICE of a seed-mixed hash, not its low bit — a
|
|
115
|
-
// naive 'mix & 1' collapses to only two possible outputs per seed
|
|
116
|
-
// parity because every stride index is even (the position multiplier
|
|
117
|
-
// is odd, so the low bit only depends on seed parity). Indexing the
|
|
118
|
-
// hash by (i/64) mod 32 makes every stride position sample a different
|
|
119
|
-
// bit, so two distinct seeds produce different mask patterns.
|
|
120
|
-
for (let i = 0; i < data.length; i += 64) {
|
|
121
|
-
const h = ((CANVAS_SEED * 2654435761) ^ (i * 1597334677)) >>> 0;
|
|
122
|
-
const bit = (h >>> ((i >>> 6) & 31)) & 1;
|
|
123
|
-
data[i] = (data[i] ^ bit) & 0xff;
|
|
124
|
-
}
|
|
125
|
-
return data;
|
|
126
|
-
}
|
|
127
|
-
// Capture originals BEFORE replacing — toDataURL must read pixels via the
|
|
128
|
-
// native getImageData (not the patched one), otherwise the patch double-
|
|
129
|
-
// applies and the second XOR cancels the first, leaving output unchanged.
|
|
130
|
-
const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;
|
|
131
|
-
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
|
132
|
-
HTMLCanvasElement.prototype.toDataURL = function() {
|
|
133
|
-
const ctx = this.getContext('2d');
|
|
134
|
-
if (ctx && this.width > 0 && this.height > 0) {
|
|
135
|
-
try {
|
|
136
|
-
const img = origGetImageData.call(ctx, 0, 0, this.width, this.height);
|
|
137
|
-
// Snapshot the original bytes so we can restore them after encoding.
|
|
138
|
-
// Without this, repeated toDataURL() alternates noisy/clean: call 1
|
|
139
|
-
// XORs the canvas in place, call 2 reads the noisy canvas and XORs
|
|
140
|
-
// again (self-inverse), call 3 again, etc. Same XOR-cancellation
|
|
141
|
-
// class as the earlier double-apply bug, just through canvas state
|
|
142
|
-
// rather than method composition. The restore also keeps the
|
|
143
|
-
// bitmap idempotent for any downstream legitimate canvas reads.
|
|
144
|
-
const original = new Uint8ClampedArray(img.data);
|
|
145
|
-
shiftPixels(img.data);
|
|
146
|
-
ctx.putImageData(img, 0, 0);
|
|
147
|
-
const result = origToDataURL.apply(this, arguments);
|
|
148
|
-
img.data.set(original);
|
|
149
|
-
ctx.putImageData(img, 0, 0);
|
|
150
|
-
return result;
|
|
151
|
-
} catch {
|
|
152
|
-
// Tainted canvas (cross-origin image) — can't read; skip the nudge
|
|
153
|
-
// and fall through to the original call so the page sees the
|
|
154
|
-
// expected SecurityError instead of silent corruption.
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
return origToDataURL.apply(this, arguments);
|
|
158
|
-
};
|
|
159
|
-
CanvasRenderingContext2D.prototype.getImageData = function() {
|
|
160
|
-
const img = origGetImageData.apply(this, arguments);
|
|
161
|
-
shiftPixels(img.data);
|
|
162
|
-
return img;
|
|
163
|
-
};
|
|
201
|
+
${CANVAS_NOISE_PATCH}
|
|
164
202
|
`;
|
|
165
203
|
|
|
166
204
|
/**
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Try to dismiss a cookie consent dialog in a reconstructed Firefox AX tree.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} root - Spliced AX tree root (from firefox-page's buildTree).
|
|
5
|
+
* @param {(ref: string) => Promise<void>} click - Clicks the element for a ref
|
|
6
|
+
* (firefox-page injects one that routes to pointerClick / performActions).
|
|
7
|
+
* @returns {Promise<boolean>} true if an accept button was found and clicked.
|
|
8
|
+
*/
|
|
9
|
+
export function dismissConsentFirefox(root: object, click: (ref: string) => Promise<void>): Promise<boolean>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* consent-patterns.js — Shared cookie-consent detection data.
|
|
3
|
+
*
|
|
4
|
+
* The engine-agnostic half of consent handling: the multilingual regex sets
|
|
5
|
+
* and dialog roles used to recognise a consent dialog and its "accept" button.
|
|
6
|
+
* Both the CDP walker (consent.js) and the BiDi walker (consent-firefox.js)
|
|
7
|
+
* import these so the language coverage is single-sourced — a new language or
|
|
8
|
+
* pattern is added once and both engines get it.
|
|
9
|
+
*/
|
|
10
|
+
export const ACCEPT_PATTERNS: RegExp[];
|
|
11
|
+
export const DIALOG_ROLES: Set<string>;
|
|
12
|
+
export const CONSENT_DIALOG_HINTS: RegExp[];
|
package/types/daemon.d.ts
CHANGED
|
@@ -10,6 +10,28 @@
|
|
|
10
10
|
* @returns {string[]}
|
|
11
11
|
*/
|
|
12
12
|
export function buildDaemonArgs(opts: object, absDir: string, initialUrl: string | undefined, cliPath: string): string[];
|
|
13
|
+
/**
|
|
14
|
+
* Wire Firefox/BiDi console + network capture onto the daemon's log arrays.
|
|
15
|
+
* The BiDi analogue of the CDP `Runtime.consoleAPICalled` / `Network.*`
|
|
16
|
+
* capture — measured event shapes: `log.entryAdded` carries {method, level,
|
|
17
|
+
* args:[{type,value}], text}; the network events key in-flight requests by
|
|
18
|
+
* `request.request` and expose `response.{status,statusText,mimeType}` /
|
|
19
|
+
* top-level `errorText`. Pure + exported so the shape mapping (warn→warning
|
|
20
|
+
* normalization, arg extraction, orphan-safe response/error pairing) is
|
|
21
|
+
* unit-testable against a fake bidi without launching Firefox.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} bidi - BiDi client (async .subscribe(), .on(method, cb))
|
|
24
|
+
* @param {object} sinks
|
|
25
|
+
* @param {Array} sinks.consoleLogs - push-target for console entries
|
|
26
|
+
* @param {Array} sinks.networkLogs - push-target for completed/failed requests
|
|
27
|
+
* @param {Map} sinks.pendingRequests - in-flight request bookkeeping
|
|
28
|
+
* @returns {Promise<void>}
|
|
29
|
+
*/
|
|
30
|
+
export function attachBiDiCapture(bidi: object, { consoleLogs, networkLogs, pendingRequests }: {
|
|
31
|
+
consoleLogs: any[];
|
|
32
|
+
networkLogs: any[];
|
|
33
|
+
pendingRequests: Map<any, any>;
|
|
34
|
+
}): Promise<void>;
|
|
13
35
|
/**
|
|
14
36
|
* Spawn a detached child process that runs the daemon.
|
|
15
37
|
* Parent polls for session.json, then exits.
|
package/types/firefox-page.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* @param {{allowLocalUrls?: boolean, blockPrivateNetwork?: boolean}} [opts.urlGuard] - Navigation safety policy, applied on every goto().
|
|
7
7
|
* @param {string} [opts.uploadDir] - When set, upload() rejects files outside this dir.
|
|
8
8
|
* @param {boolean} [opts.incognito=false] - Clean session: injectCookies() is a no-op.
|
|
9
|
+
* @param {boolean} [opts.consent=true] - Auto-dismiss cookie consent dialogs after goto().
|
|
9
10
|
* @returns {Promise<object>} page object
|
|
10
11
|
*/
|
|
11
12
|
export function createFirefoxPage(bidi: object, opts?: {
|
|
@@ -16,4 +17,5 @@ export function createFirefoxPage(bidi: object, opts?: {
|
|
|
16
17
|
} | undefined;
|
|
17
18
|
uploadDir?: string | undefined;
|
|
18
19
|
incognito?: boolean | undefined;
|
|
20
|
+
consent?: boolean | undefined;
|
|
19
21
|
}): Promise<object>;
|
package/types/network-idle.d.ts
CHANGED
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Tracks in-flight requests by requestId in a Set, so an orphan
|
|
5
|
-
* loadingFinished/Failed (event for a request whose requestWillBeSent
|
|
6
|
-
* arrived before our listener attached) is a harmless no-op instead of
|
|
7
|
-
* driving a counter negative and resolving prematurely.
|
|
8
|
-
*/
|
|
9
|
-
/**
|
|
2
|
+
* CDP: wait for network idle over `Network.*` events.
|
|
10
3
|
* @param {object} session - CDP session-scoped handle with .on() returning unsub
|
|
11
4
|
* @param {object} [opts]
|
|
12
5
|
* @param {number} [opts.timeout=30000] - Max wait time before reject
|
|
@@ -17,3 +10,18 @@ export function waitForNetworkIdle(session: object, opts?: {
|
|
|
17
10
|
timeout?: number | undefined;
|
|
18
11
|
idle?: number | undefined;
|
|
19
12
|
}): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Firefox/BiDi: wait for network idle over `network.*` events. Subscribes the
|
|
15
|
+
* events first (idempotent — safe even if the daemon already subscribed for
|
|
16
|
+
* log capture), then runs the same orphan-resilient idle loop. The in-flight
|
|
17
|
+
* key is `params.request.request` (the BiDi request id).
|
|
18
|
+
* @param {object} bidi - BiDi client with async .subscribe() and .on() returning unsub
|
|
19
|
+
* @param {object} [opts]
|
|
20
|
+
* @param {number} [opts.timeout=30000] - Max wait time before reject
|
|
21
|
+
* @param {number} [opts.idle=500] - Required idle duration before resolve
|
|
22
|
+
* @returns {Promise<void>}
|
|
23
|
+
*/
|
|
24
|
+
export function waitForNetworkIdleBiDi(bidi: object, opts?: {
|
|
25
|
+
timeout?: number | undefined;
|
|
26
|
+
idle?: number | undefined;
|
|
27
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register the Firefox stealth patches so they run before any page script.
|
|
3
|
+
* Must be called before the first navigation. addPreloadScript is global
|
|
4
|
+
* (applies to every browsing context / navigation), so a single registration
|
|
5
|
+
* covers the whole session.
|
|
6
|
+
*
|
|
7
|
+
* @param {object} bidi - BiDi client from createBiDi()
|
|
8
|
+
*/
|
|
9
|
+
export function applyFirefoxStealth(bidi: object): Promise<void>;
|
package/types/stealth.d.ts
CHANGED
|
@@ -12,3 +12,34 @@
|
|
|
12
12
|
* @param {object} session - Session-scoped CDP handle
|
|
13
13
|
*/
|
|
14
14
|
export function applyStealth(session: object): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* stealth.js — Anti-detection patches for headless Chromium.
|
|
17
|
+
*
|
|
18
|
+
* Uses Page.addScriptToEvaluateOnNewDocument so patches run before
|
|
19
|
+
* any page scripts (unlike Runtime.evaluate which runs after).
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* navigator.webdriver hiding — the one automation tell shared by every engine
|
|
23
|
+
* (Chromium headless AND Firefox-under-BiDi both report `true`). Split out so
|
|
24
|
+
* the Firefox stealth path (stealth-firefox.js) reuses the exact same patch
|
|
25
|
+
* instead of duplicating it. Measured baseline on both engines: `true`.
|
|
26
|
+
*
|
|
27
|
+
* A naive `Object.defineProperty(navigator, 'webdriver', …)` hides the *value*
|
|
28
|
+
* but leaves an advanced tell: it creates an OWN property on the instance, so
|
|
29
|
+
* `navigator.hasOwnProperty('webdriver')` becomes `true` where a real browser
|
|
30
|
+
* (property lives on Navigator.prototype) reports `false` — sophisticated
|
|
31
|
+
* anti-bot checks (e.g. sannysoft's "WebDriver New") catch that inconsistency.
|
|
32
|
+
* So we DELETE the getter off the prototype instead: then `navigator.webdriver`
|
|
33
|
+
* is `undefined` AND `'webdriver' in navigator` AND `hasOwnProperty` are both
|
|
34
|
+
* `false`, matching a stock browser. POC-verified on Chromium and Firefox
|
|
35
|
+
* (own/in both false, prototype descriptor gone). The defineProperty fallback
|
|
36
|
+
* only fires if the descriptor is somehow non-configurable (delete a no-op).
|
|
37
|
+
*/
|
|
38
|
+
export const WEBDRIVER_PATCH: "\n try {\n const proto = Object.getPrototypeOf(navigator);\n const desc = proto && Object.getOwnPropertyDescriptor(proto, 'webdriver');\n if (desc && desc.configurable) delete proto.webdriver;\n if (navigator.webdriver) {\n Object.defineProperty(navigator, 'webdriver', { get: () => undefined, configurable: true });\n }\n } catch {}\n";
|
|
39
|
+
/**
|
|
40
|
+
* Canvas-fingerprint noise — browser-agnostic (standard Canvas2D API), so both
|
|
41
|
+
* the Chromium and Firefox stealth scripts compose it. This block carries the
|
|
42
|
+
* subtle double-XOR-cancellation fixes (see git history / project memory); keep
|
|
43
|
+
* it SINGLE-SOURCED here so a fix never has to be made in two places.
|
|
44
|
+
*/
|
|
45
|
+
export const CANVAS_NOISE_PATCH: "\n // Canvas fingerprinting \u2014 sites render standard text/shapes, then read\n // pixels via toDataURL or getImageData. The output is stable per machine\n // (GPU, font rasterizer, anti-aliasing) but unique across machines, which\n // makes it the second-most-common fingerprint after WebGL. Defense: nudge\n // a few RGB channels by \u00B11 per session so the hash changes each visit\n // while the canvas still looks identical to the human eye. The per-tab\n // seed keeps reads stable within a session so legitimate canvas use\n // (image processing, games) doesn't flicker.\n // crypto.getRandomValues is guaranteed unique per browsing context; using\n // Math.random alone can collide when two fresh V8 contexts start within\n // microseconds of each other (real-world: parallel tests, real-world hit:\n // we observed it). performance.now adds a wall-clock anchor as a belt-and-\n // braces guard against contexts where crypto is somehow stubbed.\n const _seedBuf = new Uint32Array(1);\n crypto.getRandomValues(_seedBuf);\n const CANVAS_SEED = (_seedBuf[0] ^ ((performance.now() * 1e6) | 0)) >>> 0;\n function shiftPixels(data) {\n // Touch ~1 byte per 64-byte stride. The bit we XOR with is taken from a\n // position-dependent SLICE of a seed-mixed hash, not its low bit \u2014 a\n // naive 'mix & 1' collapses to only two possible outputs per seed\n // parity because every stride index is even (the position multiplier\n // is odd, so the low bit only depends on seed parity). Indexing the\n // hash by (i/64) mod 32 makes every stride position sample a different\n // bit, so two distinct seeds produce different mask patterns.\n for (let i = 0; i < data.length; i += 64) {\n const h = ((CANVAS_SEED * 2654435761) ^ (i * 1597334677)) >>> 0;\n const bit = (h >>> ((i >>> 6) & 31)) & 1;\n data[i] = (data[i] ^ bit) & 0xff;\n }\n return data;\n }\n // Capture originals BEFORE replacing \u2014 toDataURL must read pixels via the\n // native getImageData (not the patched one), otherwise the patch double-\n // applies and the second XOR cancels the first, leaving output unchanged.\n const origGetImageData = CanvasRenderingContext2D.prototype.getImageData;\n const origToDataURL = HTMLCanvasElement.prototype.toDataURL;\n HTMLCanvasElement.prototype.toDataURL = function() {\n const ctx = this.getContext('2d');\n if (ctx && this.width > 0 && this.height > 0) {\n try {\n const img = origGetImageData.call(ctx, 0, 0, this.width, this.height);\n // Snapshot the original bytes so we can restore them after encoding.\n // Without this, repeated toDataURL() alternates noisy/clean: call 1\n // XORs the canvas in place, call 2 reads the noisy canvas and XORs\n // again (self-inverse), call 3 again, etc. Same XOR-cancellation\n // class as the earlier double-apply bug, just through canvas state\n // rather than method composition. The restore also keeps the\n // bitmap idempotent for any downstream legitimate canvas reads.\n const original = new Uint8ClampedArray(img.data);\n shiftPixels(img.data);\n ctx.putImageData(img, 0, 0);\n const result = origToDataURL.apply(this, arguments);\n img.data.set(original);\n ctx.putImageData(img, 0, 0);\n return result;\n } catch {\n // Tainted canvas (cross-origin image) \u2014 can't read; skip the nudge\n // and fall through to the original call so the page sees the\n // expected SecurityError instead of silent corruption.\n }\n }\n return origToDataURL.apply(this, arguments);\n };\n CanvasRenderingContext2D.prototype.getImageData = function() {\n const img = origGetImageData.apply(this, arguments);\n shiftPixels(img.data);\n return img;\n };\n";
|