@fanboynz/network-scanner 3.0.2 → 3.1.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 +34 -0
- package/lib/adblock-rust.js +17 -4
- package/lib/adblock.js +92 -15
- package/lib/browserhealth.js +57 -28
- package/lib/cdp.js +68 -34
- package/lib/clear_sitedata.js +68 -20
- package/lib/compress.js +26 -58
- package/lib/curl.js +44 -22
- package/lib/domain-cache.js +8 -57
- package/lib/dry-run.js +9 -4
- package/lib/fingerprint.js +735 -114
- package/lib/interaction.js +262 -26
- package/lib/nettools.js +47 -76
- package/lib/openvpn_vpn.js +116 -35
- package/lib/searchstring.js +15 -237
- package/lib/validate_rules.js +285 -3
- package/lib/wireguard_vpn.js +64 -12
- package/nwss.js +529 -217
- package/package.json +1 -1
- package/regex-tool/index.html +321 -628
- package/scripts/test-stealth.js +39 -13
package/lib/fingerprint.js
CHANGED
|
@@ -28,13 +28,24 @@ const FINGERPRINT_CACHE_MAX = 500;
|
|
|
28
28
|
|
|
29
29
|
// User agent collections with latest versions
|
|
30
30
|
const USER_AGENT_COLLECTIONS = Object.freeze(new Map([
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
['
|
|
36
|
-
['
|
|
37
|
-
['
|
|
31
|
+
// Chrome version uses the reduced 'X.0.0.0' privacy-preserving form (per
|
|
32
|
+
// Chrome's UA reduction since v101). The full build (148.0.7778.179) is
|
|
33
|
+
// not exposed via UA in modern Chrome — UA-Client-Hints carries that
|
|
34
|
+
// separately. User confirmed Chrome 148 stable as of late May 2026.
|
|
35
|
+
['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"],
|
|
36
|
+
['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"],
|
|
37
|
+
['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"],
|
|
38
|
+
// Firefox 151 stable (user-confirmed: 151.0.2). UA convention is to use
|
|
39
|
+
// major.minor for both rv: and Firefox/ — patch level is not customary
|
|
40
|
+
// in the UA string and matches existing nwss style.
|
|
41
|
+
['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0"],
|
|
42
|
+
['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) Gecko/20100101 Firefox/151.0"],
|
|
43
|
+
['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:151.0) Gecko/20100101 Firefox/151.0"],
|
|
44
|
+
// Safari 19.5 estimated current stable as of late May 2026 (macOS Tahoe).
|
|
45
|
+
// Not user-confirmed; adjust if a more recent Safari version is verified.
|
|
46
|
+
// The Version/X.Y prefix is what fingerprinters key off; Safari/605.1.15
|
|
47
|
+
// is the long-stable WebKit version string (~unchanged since Safari 17).
|
|
48
|
+
['safari', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.5 Safari/605.1.15"]
|
|
38
49
|
]));
|
|
39
50
|
|
|
40
51
|
// GPU pool — realistic vendor/renderer combos per OS (used for WebGL spoofing)
|
|
@@ -248,7 +259,15 @@ async function validatePageForInjection(page, currentUrl, forceDebug) {
|
|
|
248
259
|
}
|
|
249
260
|
await Promise.race([
|
|
250
261
|
page.evaluate(() => document.readyState || 'loading'),
|
|
251
|
-
new Promise((_, reject) =>
|
|
262
|
+
new Promise((_, reject) => {
|
|
263
|
+
// unref so a still-pending race timer (page.evaluate won) doesn't
|
|
264
|
+
// hold the Node event loop alive for up to 1.5s past scan exit.
|
|
265
|
+
// Same pattern as the nettools timer unrefs in 83209d4 / 0c5d644;
|
|
266
|
+
// closes the last known node-side unref'd setTimeout in the
|
|
267
|
+
// fingerprint module.
|
|
268
|
+
const t = setTimeout(() => reject(new Error('Page evaluation timeout')), 1500);
|
|
269
|
+
if (typeof t.unref === 'function') t.unref();
|
|
270
|
+
})
|
|
252
271
|
]);
|
|
253
272
|
return true;
|
|
254
273
|
} catch (validationErr) {
|
|
@@ -506,6 +525,21 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
506
525
|
configurable: true,
|
|
507
526
|
enumerable: true
|
|
508
527
|
});
|
|
528
|
+
|
|
529
|
+
// Belt-and-suspenders: some older Chromium-driver setups leave a
|
|
530
|
+
// `webdriver` attribute on the <html> element (different surface
|
|
531
|
+
// from navigator.webdriver). DataDome and similar detectors
|
|
532
|
+
// check both. Modern Puppeteer with ignoreDefaultArgs:
|
|
533
|
+
// ['--enable-automation'] (set in nwss.js's createBrowser) doesn't
|
|
534
|
+
// emit this attribute, so this is defensive against rare edge
|
|
535
|
+
// cases. documentElement should exist by evaluateOnNewDocument
|
|
536
|
+
// time; wrapped in optional-chaining + try/catch for the contexts
|
|
537
|
+
// where it doesn't.
|
|
538
|
+
try {
|
|
539
|
+
if (document?.documentElement?.hasAttribute('webdriver')) {
|
|
540
|
+
document.documentElement.removeAttribute('webdriver');
|
|
541
|
+
}
|
|
542
|
+
} catch (_) {}
|
|
509
543
|
}, 'webdriver removal');
|
|
510
544
|
|
|
511
545
|
// Remove automation properties
|
|
@@ -567,7 +601,7 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
567
601
|
// consistent number. Was hardcoded "146.0.0.0" which lied
|
|
568
602
|
// any time the UA was rotated to a different Chrome major.
|
|
569
603
|
const m = userAgent.match(/Chrome\/(\d+)/);
|
|
570
|
-
const major = m ? m[1] : '
|
|
604
|
+
const major = m ? m[1] : '148';
|
|
571
605
|
return {
|
|
572
606
|
name: "Chrome",
|
|
573
607
|
version: `${major}.0.0.0`,
|
|
@@ -814,16 +848,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
814
848
|
safeExecute(() => {
|
|
815
849
|
let vendor = 'Google Inc.';
|
|
816
850
|
let product = 'Gecko';
|
|
817
|
-
|
|
851
|
+
// productSub is a legacy Mozilla-era property: '20030107' for
|
|
852
|
+
// every browser EXCEPT Firefox (which uses '20100101'). Real
|
|
853
|
+
// Chrome / Safari / etc. all report '20030107'. Real Firefox
|
|
854
|
+
// reports '20100101'. Common bot-detection signal because
|
|
855
|
+
// anti-detection libraries often spoof UA but forget this.
|
|
856
|
+
// vendorSub is always '' across all browsers (legacy/unused).
|
|
857
|
+
let productSub = '20030107';
|
|
858
|
+
const vendorSub = '';
|
|
859
|
+
|
|
818
860
|
if (userAgent.includes('Firefox')) {
|
|
819
861
|
vendor = '';
|
|
862
|
+
productSub = '20100101';
|
|
820
863
|
} else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
|
|
821
864
|
vendor = 'Apple Computer, Inc.';
|
|
822
865
|
}
|
|
823
|
-
|
|
866
|
+
|
|
824
867
|
const vendorProps = {
|
|
825
868
|
vendor: { get: () => vendor },
|
|
826
|
-
product: { get: () => product }
|
|
869
|
+
product: { get: () => product },
|
|
870
|
+
productSub: { get: () => productSub },
|
|
871
|
+
vendorSub: { get: () => vendorSub }
|
|
827
872
|
};
|
|
828
873
|
spoofNavigatorProperties(navigator, vendorProps);
|
|
829
874
|
}, 'vendor/product spoofing');
|
|
@@ -872,8 +917,15 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
872
917
|
platformVersion: platformVersion,
|
|
873
918
|
fullVersionList: [
|
|
874
919
|
{ brand: 'Not:A-Brand', version: '99.0.0.0' },
|
|
875
|
-
|
|
876
|
-
|
|
920
|
+
// Build number 7778.179 matches real Chrome 148 stable
|
|
921
|
+
// (per user-confirmed installed version as of late May 2026).
|
|
922
|
+
// Prior 7632.160 was Chrome 145's build — outdated for 148.
|
|
923
|
+
// If Chrome major bumps in future, update this build number
|
|
924
|
+
// too — fullVersionList being inconsistent with the major
|
|
925
|
+
// version is a fingerprint-detection signal (a real Chrome
|
|
926
|
+
// 148 install would never report a 7632.x build).
|
|
927
|
+
{ brand: 'Google Chrome', version: majorVersion + '.0.7778.179' },
|
|
928
|
+
{ brand: 'Chromium', version: majorVersion + '.0.7778.179' }
|
|
877
929
|
]
|
|
878
930
|
};
|
|
879
931
|
// Only return requested hints
|
|
@@ -1270,6 +1322,23 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1270
1322
|
if (window.Notification && Notification.requestPermission) {
|
|
1271
1323
|
Notification.requestPermission = () => Promise.resolve('default');
|
|
1272
1324
|
}
|
|
1325
|
+
|
|
1326
|
+
// Notification.permission STATIC PROPERTY — distinct from the
|
|
1327
|
+
// requestPermission() method patched above. DataDome and similar
|
|
1328
|
+
// detectors read Notification.permission directly. Real Chrome
|
|
1329
|
+
// with no granted permission returns 'default'; headless Chrome
|
|
1330
|
+
// returns 'denied'. Without this override the prior block (which
|
|
1331
|
+
// only patched the method) leaves the static property as a live
|
|
1332
|
+
// headless tell. Wrapped in try/catch because some embedded
|
|
1333
|
+
// contexts make Notification non-configurable.
|
|
1334
|
+
if (window.Notification) {
|
|
1335
|
+
try {
|
|
1336
|
+
Object.defineProperty(Notification, 'permission', {
|
|
1337
|
+
get: () => 'default',
|
|
1338
|
+
configurable: true
|
|
1339
|
+
});
|
|
1340
|
+
} catch (_) {}
|
|
1341
|
+
}
|
|
1273
1342
|
}, 'permissions API spoofing');
|
|
1274
1343
|
|
|
1275
1344
|
// Media Device Spoofing
|
|
@@ -1299,26 +1368,198 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1299
1368
|
const sY = Math.floor(Math.random() * 50) + 20;
|
|
1300
1369
|
Object.defineProperty(window, 'screenX', { get: () => sX });
|
|
1301
1370
|
Object.defineProperty(window, 'screenY', { get: () => sY });
|
|
1371
|
+
// screenLeft / screenTop are the legacy IE-era aliases for
|
|
1372
|
+
// screenX / screenY; real Chrome exposes them as identical
|
|
1373
|
+
// values. Headless / spoofers often leave them undefined or 0,
|
|
1374
|
+
// which is a tell since the screenX/Y patch above changed
|
|
1375
|
+
// those values to be non-zero. Mirror them explicitly so the
|
|
1376
|
+
// four properties stay consistent.
|
|
1377
|
+
Object.defineProperty(window, 'screenLeft', { get: () => sX });
|
|
1378
|
+
Object.defineProperty(window, 'screenTop', { get: () => sY });
|
|
1302
1379
|
}
|
|
1303
1380
|
}, 'window dimension spoofing');
|
|
1304
1381
|
|
|
1382
|
+
// Modern Chrome API stubs — these APIs exist in real desktop
|
|
1383
|
+
// Chrome but may be missing or wrong in headless. Detectors check
|
|
1384
|
+
// presence + minimal shape as a 'real browser?' signal. Each one
|
|
1385
|
+
// is wrapped individually so a failure on one doesn't break the
|
|
1386
|
+
// others, and the existence checks let real-Chrome paths
|
|
1387
|
+
// (where the property already exists with the right value) skip
|
|
1388
|
+
// the override entirely.
|
|
1389
|
+
safeExecute(() => {
|
|
1390
|
+
// Document.hasStorageAccess() — Storage Access API. Real Chrome
|
|
1391
|
+
// returns a Promise<boolean>; resolve with true to mimic the
|
|
1392
|
+
// common 'storage already accessible (top-level context)' state.
|
|
1393
|
+
if (document && typeof document.hasStorageAccess !== 'function') {
|
|
1394
|
+
try {
|
|
1395
|
+
Object.defineProperty(document, 'hasStorageAccess', {
|
|
1396
|
+
value: () => Promise.resolve(true),
|
|
1397
|
+
configurable: true,
|
|
1398
|
+
writable: true
|
|
1399
|
+
});
|
|
1400
|
+
} catch (_) {}
|
|
1401
|
+
}
|
|
1402
|
+
}, 'document.hasStorageAccess stub');
|
|
1403
|
+
|
|
1404
|
+
safeExecute(() => {
|
|
1405
|
+
// navigator.userActivation — UserActivation interface tracking
|
|
1406
|
+
// whether the page has had a user gesture. Real Chrome exposes
|
|
1407
|
+
// {hasBeenActive: bool, isActive: bool}. After interaction
|
|
1408
|
+
// simulation (interact / ghost-cursor) both should be true; we
|
|
1409
|
+
// default to true so a site checking before our synthetic
|
|
1410
|
+
// gestures fire still sees a 'user activated' page.
|
|
1411
|
+
//
|
|
1412
|
+
// ALWAYS override — the prior guard `if (!navigator.userActivation)`
|
|
1413
|
+
// meant this code only fired when the property was missing.
|
|
1414
|
+
// But navigator.userActivation has existed in Chrome since
|
|
1415
|
+
// version 88 (Jan 2021) — modern Chrome (the UA we spoof)
|
|
1416
|
+
// always has it natively. The guard therefore prevented the
|
|
1417
|
+
// spoof from ever firing in normal use; real headless Chrome's
|
|
1418
|
+
// hasBeenActive=false / isActive=false leaked through, an
|
|
1419
|
+
// easy bot signal that many modern detectors check
|
|
1420
|
+
// (DataDome, PerimeterX, the AdsCore-integrated popunder
|
|
1421
|
+
// loaders like blazyload.min.js which probe userActivation 6×
|
|
1422
|
+
// to gate window.open). Drop the guard so we always shadow
|
|
1423
|
+
// the prototype-level getter with our true/true values.
|
|
1424
|
+
if (navigator) {
|
|
1425
|
+
try {
|
|
1426
|
+
const userActivation = {
|
|
1427
|
+
hasBeenActive: true,
|
|
1428
|
+
isActive: true
|
|
1429
|
+
};
|
|
1430
|
+
Object.defineProperty(navigator, 'userActivation', {
|
|
1431
|
+
get: () => userActivation,
|
|
1432
|
+
configurable: true
|
|
1433
|
+
});
|
|
1434
|
+
} catch (_) {}
|
|
1435
|
+
}
|
|
1436
|
+
}, 'navigator.userActivation stub');
|
|
1437
|
+
|
|
1438
|
+
safeExecute(() => {
|
|
1439
|
+
// navigator.getInstalledRelatedApps() — Chrome-specific API
|
|
1440
|
+
// returning installed PWAs/native apps related to the current
|
|
1441
|
+
// origin. Real Chrome has this as a function; absence is a tell
|
|
1442
|
+
// for non-Chrome / headless. Return empty array (the common
|
|
1443
|
+
// no-related-apps case) — same as a fresh real-Chrome profile.
|
|
1444
|
+
if (navigator && typeof navigator.getInstalledRelatedApps !== 'function') {
|
|
1445
|
+
try {
|
|
1446
|
+
Object.defineProperty(navigator, 'getInstalledRelatedApps', {
|
|
1447
|
+
value: () => Promise.resolve([]),
|
|
1448
|
+
configurable: true,
|
|
1449
|
+
writable: true
|
|
1450
|
+
});
|
|
1451
|
+
} catch (_) {}
|
|
1452
|
+
}
|
|
1453
|
+
}, 'navigator.getInstalledRelatedApps stub');
|
|
1454
|
+
|
|
1455
|
+
// screen.orientation — modern browsers expose a ScreenOrientation
|
|
1456
|
+
// interface ({type, angle, addEventListener, lock, unlock, ...}).
|
|
1457
|
+
// Missing entirely in some headless contexts; presence + shape are
|
|
1458
|
+
// checked by DataDome and similar detectors as a "real browser"
|
|
1459
|
+
// signal. The values below mirror real desktop Chrome's landscape
|
|
1460
|
+
// primary orientation; lock()/unlock() match real-Chrome behaviour
|
|
1461
|
+
// when no fullscreen element is active (lock rejects with
|
|
1462
|
+
// NotSupportedError, unlock is a no-op). Object identity is stable
|
|
1463
|
+
// (hoisted out of the getter) so reference-equality checks pass.
|
|
1464
|
+
safeExecute(() => {
|
|
1465
|
+
if (!window.screen.orientation) {
|
|
1466
|
+
const orientation = {
|
|
1467
|
+
type: 'landscape-primary',
|
|
1468
|
+
angle: 0,
|
|
1469
|
+
onchange: null,
|
|
1470
|
+
addEventListener: () => {},
|
|
1471
|
+
removeEventListener: () => {},
|
|
1472
|
+
dispatchEvent: () => false,
|
|
1473
|
+
lock: () => Promise.reject(new Error('NotSupportedError')),
|
|
1474
|
+
unlock: () => {}
|
|
1475
|
+
};
|
|
1476
|
+
Object.defineProperty(window.screen, 'orientation', {
|
|
1477
|
+
get: () => orientation,
|
|
1478
|
+
configurable: true
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
}, 'screen.orientation spoofing');
|
|
1482
|
+
|
|
1305
1483
|
// navigator.connection — missing or incomplete in headless.
|
|
1306
1484
|
// Object literal hoisted out of the getter so identity is stable
|
|
1307
1485
|
// across reads. Real Chrome's NetworkInformation instance has
|
|
1308
1486
|
// `navigator.connection === navigator.connection`; previously
|
|
1309
1487
|
// the getter returned a new object on every read, which a
|
|
1310
1488
|
// tracker could detect by comparing references.
|
|
1489
|
+
//
|
|
1490
|
+
// Per-domain seeded values (FNV-1a hash of hostname): hardcoded
|
|
1491
|
+
// '4g/wifi/50/10' across every site was a cross-publisher
|
|
1492
|
+
// tracking axis -- a fingerprinter aggregating across N sites
|
|
1493
|
+
// running the same script saw identical connection values
|
|
1494
|
+
// everywhere, useful as a stable identity signal. Domain-seeding
|
|
1495
|
+
// breaks that while keeping values stable per-domain (real
|
|
1496
|
+
// navigator.connection IS stable per-session; per-navigation
|
|
1497
|
+
// randomness would be its own anomaly). Same pattern as the
|
|
1498
|
+
// Battery API fix in c1affe4.
|
|
1311
1499
|
safeExecute(() => {
|
|
1312
1500
|
if (!navigator.connection) {
|
|
1501
|
+
let h = 0x811c9dc5;
|
|
1502
|
+
const domain = (window.location && window.location.hostname) || '';
|
|
1503
|
+
for (let i = 0; i < domain.length; i++) {
|
|
1504
|
+
h = ((h ^ domain.charCodeAt(i)) * 0x01000193) >>> 0;
|
|
1505
|
+
}
|
|
1506
|
+
// 4g 75%, 3g 18%, 2g 5%, slow-2g 2% — distribution biased
|
|
1507
|
+
// toward modern broadband since most page loads happen there.
|
|
1508
|
+
const etRoll = (h >>> 4) % 100;
|
|
1509
|
+
const effectiveType = etRoll < 75 ? '4g'
|
|
1510
|
+
: etRoll < 93 ? '3g'
|
|
1511
|
+
: etRoll < 98 ? '2g'
|
|
1512
|
+
: 'slow-2g';
|
|
1513
|
+
// rtt + downlink MUST correlate with effectiveType — the
|
|
1514
|
+
// W3C Network Information API defines effectiveType as a
|
|
1515
|
+
// CLASSIFICATION of rtt/downlink ranges, so producing
|
|
1516
|
+
// slow-2g with 32.5 Mbps downlink (as the prior uncorrelated
|
|
1517
|
+
// version did) is physically impossible in real Chrome. A
|
|
1518
|
+
// detector cross-checking effectiveType against rtt/downlink
|
|
1519
|
+
// magnitudes catches that trivially. Ranges below match the
|
|
1520
|
+
// spec's boundaries:
|
|
1521
|
+
// slow-2g: rtt > 2000ms, downlink < 0.05 Mbps
|
|
1522
|
+
// 2g: rtt > 1400ms, downlink < 0.07 Mbps
|
|
1523
|
+
// 3g: rtt > 270ms, downlink < 0.7 Mbps
|
|
1524
|
+
// 4g: rtt < 270ms, downlink >= 0.7 Mbps
|
|
1525
|
+
const rttRange = effectiveType === '4g' ? [25, 250]
|
|
1526
|
+
: effectiveType === '3g' ? [275, 1400]
|
|
1527
|
+
: effectiveType === '2g' ? [1425, 2000]
|
|
1528
|
+
: /* slow-2g */ [2025, 5000];
|
|
1529
|
+
const dlRange = effectiveType === '4g' ? [1.0, 50.0]
|
|
1530
|
+
: effectiveType === '3g' ? [0.1, 0.7]
|
|
1531
|
+
: effectiveType === '2g' ? [0.05, 0.07]
|
|
1532
|
+
: /* slow-2g */ [0.01, 0.05];
|
|
1533
|
+
// 25ms-bucketed rtt (Chrome's privacy rounding granularity)
|
|
1534
|
+
const rttRaw = rttRange[0] + (((h >>> 8) % 1000) / 1000) * (rttRange[1] - rttRange[0]);
|
|
1535
|
+
const rtt = Math.round(rttRaw / 25) * 25;
|
|
1536
|
+
// 0.025-precision downlink (Chrome's privacy rounding)
|
|
1537
|
+
const dlRaw = dlRange[0] + (((h >>> 16) % 1000) / 1000) * (dlRange[1] - dlRange[0]);
|
|
1538
|
+
const downlink = Math.round(dlRaw * 40) / 40;
|
|
1539
|
+
// saveData ~5% — most users don't enable Data Saver
|
|
1540
|
+
const saveData = ((h >>> 24) & 0xff) < 13;
|
|
1541
|
+
// NetworkInformation extends EventTarget — real Chrome has
|
|
1542
|
+
// dispatchEvent in addition to add/removeEventListener.
|
|
1543
|
+
// Returning true per the EventTarget spec for the
|
|
1544
|
+
// no-listeners case (no preventDefault called).
|
|
1313
1545
|
const connectionInfoStable = {
|
|
1314
|
-
effectiveType
|
|
1315
|
-
rtt
|
|
1316
|
-
downlink
|
|
1317
|
-
saveData
|
|
1318
|
-
type: 'wifi',
|
|
1546
|
+
effectiveType,
|
|
1547
|
+
rtt,
|
|
1548
|
+
downlink,
|
|
1549
|
+
saveData,
|
|
1319
1550
|
addEventListener: () => {},
|
|
1320
|
-
removeEventListener: () => {}
|
|
1551
|
+
removeEventListener: () => {},
|
|
1552
|
+
dispatchEvent: () => true
|
|
1321
1553
|
};
|
|
1554
|
+
// NetworkInformation.type is DEPRECATED and only exposed on
|
|
1555
|
+
// mobile Chrome. On desktop Chrome (Windows/Mac/Linux) it's
|
|
1556
|
+
// not present — `'type' in navigator.connection` returns
|
|
1557
|
+
// false. Only include it when spoofing a mobile UA, else
|
|
1558
|
+
// its presence is a fingerprint tell against the desktop
|
|
1559
|
+
// platform our UA collection currently advertises.
|
|
1560
|
+
if (/Android|iPhone|iPad|iPod|Mobile/i.test(userAgent || '')) {
|
|
1561
|
+
connectionInfoStable.type = ((h >>> 12) % 10) < 7 ? 'wifi' : 'cellular';
|
|
1562
|
+
}
|
|
1322
1563
|
Object.defineProperty(navigator, 'connection', {
|
|
1323
1564
|
get: () => connectionInfoStable
|
|
1324
1565
|
});
|
|
@@ -1354,29 +1595,140 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1354
1595
|
}
|
|
1355
1596
|
}, 'speechSynthesis spoofing');
|
|
1356
1597
|
|
|
1357
|
-
// AudioContext
|
|
1598
|
+
// AudioContext fingerprint spoofing — intercept the actual READ
|
|
1599
|
+
// surface fingerprinters care about.
|
|
1600
|
+
//
|
|
1601
|
+
// Previous spoof modified default frequency / threshold values on
|
|
1602
|
+
// OscillatorNode / DynamicsCompressor at create time. That was
|
|
1603
|
+
// essentially useless: real audio fingerprinters do
|
|
1604
|
+
// const osc = ctx.createOscillator();
|
|
1605
|
+
// osc.frequency.value = 1000; // ← OVERWRITES our noise
|
|
1606
|
+
// ... render ... ctx.startRendering().then(buf => hash(buf.getChannelData(0)));
|
|
1607
|
+
// The noise we put on the default 440 Hz was blown away the
|
|
1608
|
+
// moment the probe set its own value, and never affected the
|
|
1609
|
+
// rendered output the fingerprinter actually hashes.
|
|
1610
|
+
//
|
|
1611
|
+
// Correct attack surface: AudioBuffer.prototype.getChannelData.
|
|
1612
|
+
// EVERY audio fingerprinter ends up reading the rendered buffer
|
|
1613
|
+
// via this method (it's the only way to get Float32Array data
|
|
1614
|
+
// out). Wrap it at the prototype so all AudioBuffers (from
|
|
1615
|
+
// OfflineAudioContext.startRendering, from AudioBufferSourceNode,
|
|
1616
|
+
// from decodeAudioData, etc.) get the same treatment.
|
|
1617
|
+
//
|
|
1618
|
+
// Noise design:
|
|
1619
|
+
// - sparse (every 100th sample) so audio remains perceptually
|
|
1620
|
+
// identical if actually played
|
|
1621
|
+
// - tiny magnitude (±0.00005) so the hash differs but the wave
|
|
1622
|
+
// shape doesn't
|
|
1623
|
+
// - deterministic per session (audioSeed) so repeated renders
|
|
1624
|
+
// of the same audio produce the same noised output
|
|
1625
|
+
// - per-(buffer, channel) idempotency via WeakMap: calling
|
|
1626
|
+
// getChannelData(0) twice on the same buffer returns the
|
|
1627
|
+
// same data (real Chrome's getChannelData returns a stable
|
|
1628
|
+
// Float32Array view; double-noising on second call would be
|
|
1629
|
+
// detectable)
|
|
1358
1630
|
safeExecute(() => {
|
|
1359
|
-
if (
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1631
|
+
if (typeof AudioBuffer === 'undefined' || !AudioBuffer.prototype || typeof AudioBuffer.prototype.getChannelData !== 'function') return;
|
|
1632
|
+
|
|
1633
|
+
const audioSeed = Math.floor(Math.random() * 2147483647);
|
|
1634
|
+
const noisedChannels = new WeakMap(); // buffer -> Set<channel>
|
|
1635
|
+
|
|
1636
|
+
// Shared noise function so getChannelData and copyFromChannel
|
|
1637
|
+
// produce noise at the SAME source-channel positions
|
|
1638
|
+
// (mod 100). A detector comparing a value from one method
|
|
1639
|
+
// against the same source position read via the other method
|
|
1640
|
+
// sees consistent noised values — no cross-method anomaly.
|
|
1641
|
+
const noiseAt = (channel, srcIdx) => {
|
|
1642
|
+
const n = ((audioSeed ^ srcIdx ^ (channel * 31)) * 1103515245 + 12345) & 0x7fffffff;
|
|
1643
|
+
return (n / 0x7fffffff) * 0.0001 - 0.00005;
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
const origGetChannelData = AudioBuffer.prototype.getChannelData;
|
|
1647
|
+
const wrappedGetChannelData = function(channel) {
|
|
1648
|
+
const data = origGetChannelData.call(this, channel);
|
|
1649
|
+
// Cap large buffers — per-sample loop on multi-MB Float32Arrays
|
|
1650
|
+
// is too slow. 1M samples ≈ 22.6s of 44.1kHz mono audio; way
|
|
1651
|
+
// beyond what fingerprinters use (typically 44k = 1s).
|
|
1652
|
+
if (data.length > 1000000) return data;
|
|
1653
|
+
|
|
1654
|
+
let channelSet = noisedChannels.get(this);
|
|
1655
|
+
if (!channelSet) {
|
|
1656
|
+
channelSet = new Set();
|
|
1657
|
+
noisedChannels.set(this, channelSet);
|
|
1658
|
+
}
|
|
1659
|
+
if (!channelSet.has(channel)) {
|
|
1660
|
+
for (let i = 0; i < data.length; i += 100) {
|
|
1661
|
+
data[i] = Math.max(-1, Math.min(1, data[i] + noiseAt(channel, i)));
|
|
1662
|
+
}
|
|
1663
|
+
channelSet.add(channel);
|
|
1664
|
+
}
|
|
1665
|
+
return data;
|
|
1666
|
+
};
|
|
1667
|
+
maskAsNative(wrappedGetChannelData, 'getChannelData');
|
|
1668
|
+
AudioBuffer.prototype.getChannelData = wrappedGetChannelData;
|
|
1669
|
+
|
|
1670
|
+
// copyFromChannel — alternative read API. Without wrapping it,
|
|
1671
|
+
// a fingerprinter calling `buffer.copyFromChannel(dest, 0, 0)`
|
|
1672
|
+
// instead of `buffer.getChannelData(0)` gets the unmodified
|
|
1673
|
+
// canonical Chrome data, fully bypassing our getChannelData
|
|
1674
|
+
// noise. Same defense logic applied here; noise aligned by
|
|
1675
|
+
// SOURCE channel position (startInChannel + destination
|
|
1676
|
+
// offset) so cross-method consistency holds.
|
|
1677
|
+
if (typeof AudioBuffer.prototype.copyFromChannel === 'function') {
|
|
1678
|
+
const origCopyFromChannel = AudioBuffer.prototype.copyFromChannel;
|
|
1679
|
+
const wrappedCopyFromChannel = function(destination, channelNumber, startInChannel) {
|
|
1680
|
+
origCopyFromChannel.call(this, destination, channelNumber, startInChannel);
|
|
1681
|
+
if (!destination || destination.length > 1000000) return;
|
|
1682
|
+
// If getChannelData has ALREADY noised this (buffer,
|
|
1683
|
+
// channel) in-place, origCopyFromChannel just copied the
|
|
1684
|
+
// already-noised data into `destination`. Adding our own
|
|
1685
|
+
// noise on top would produce 2× noise -- detectable via
|
|
1686
|
+
// cross-method consistency probes
|
|
1687
|
+
// (data[i] === dest[i] should hold). Skip when previously
|
|
1688
|
+
// noised.
|
|
1689
|
+
const channelSet = noisedChannels.get(this);
|
|
1690
|
+
if (channelSet && channelSet.has(channelNumber)) return;
|
|
1691
|
+
const startSrc = startInChannel || 0;
|
|
1692
|
+
// Align noise positions to the same mod-100 source-channel
|
|
1693
|
+
// grid getChannelData uses. firstNoisedSrc is the smallest
|
|
1694
|
+
// multiple of 100 >= startSrc; map back to destination index.
|
|
1695
|
+
const firstNoisedSrc = Math.ceil(startSrc / 100) * 100;
|
|
1696
|
+
const firstNoisedDest = firstNoisedSrc - startSrc;
|
|
1697
|
+
for (let destI = firstNoisedDest; destI < destination.length; destI += 100) {
|
|
1698
|
+
const srcIdx = startSrc + destI;
|
|
1699
|
+
destination[destI] = Math.max(-1, Math.min(1, destination[destI] + noiseAt(channelNumber, srcIdx)));
|
|
1700
|
+
}
|
|
1373
1701
|
};
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1702
|
+
maskAsNative(wrappedCopyFromChannel, 'copyFromChannel');
|
|
1703
|
+
AudioBuffer.prototype.copyFromChannel = wrappedCopyFromChannel;
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// copyToChannel — page WRITES to the buffer, overwriting any
|
|
1707
|
+
// existing contents (including our in-place noise from a prior
|
|
1708
|
+
// getChannelData call). Without this wrap, the noisedChannels
|
|
1709
|
+
// flag remained set but the underlying data was fresh -- next
|
|
1710
|
+
// getChannelData would SKIP noise (channelSet.has(ch) === true)
|
|
1711
|
+
// and return the page's probe pattern unnoised. A fingerprinter
|
|
1712
|
+
// priming the buffer with a known pattern then re-reading via
|
|
1713
|
+
// getChannelData would see the canonical (unspoofed) values,
|
|
1714
|
+
// confirming our spoof isn't actually noising.
|
|
1715
|
+
// Fix: clear the noisedChannels flag for the written channel
|
|
1716
|
+
// before forwarding the write, so the next read re-noises.
|
|
1717
|
+
if (typeof AudioBuffer.prototype.copyToChannel === 'function') {
|
|
1718
|
+
const origCopyToChannel = AudioBuffer.prototype.copyToChannel;
|
|
1719
|
+
const wrappedCopyToChannel = function(source, channelNumber, startInChannel) {
|
|
1720
|
+
// Forward FIRST. If the original throws (invalid args,
|
|
1721
|
+
// detached buffer, etc.) the buffer is unchanged and we
|
|
1722
|
+
// must NOT clear the noisedChannels flag — otherwise the
|
|
1723
|
+
// next getChannelData would re-noise already-noised data,
|
|
1724
|
+
// stacking 2x noise that drifts on subsequent reads.
|
|
1725
|
+
const result = origCopyToChannel.call(this, source, channelNumber, startInChannel);
|
|
1726
|
+
const channelSet = noisedChannels.get(this);
|
|
1727
|
+
if (channelSet) channelSet.delete(channelNumber);
|
|
1728
|
+
return result;
|
|
1379
1729
|
};
|
|
1730
|
+
maskAsNative(wrappedCopyToChannel, 'copyToChannel');
|
|
1731
|
+
AudioBuffer.prototype.copyToChannel = wrappedCopyToChannel;
|
|
1380
1732
|
}
|
|
1381
1733
|
}, 'AudioContext fingerprint spoofing');
|
|
1382
1734
|
|
|
@@ -1447,50 +1799,143 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1447
1799
|
}
|
|
1448
1800
|
}, 'image loading obfuscation');
|
|
1449
1801
|
|
|
1450
|
-
// CSS Media Query Spoofing
|
|
1802
|
+
// CSS Media Query Spoofing — specifically the hardware-presence
|
|
1803
|
+
// queries that distinguish a real desktop browser from headless.
|
|
1451
1804
|
//
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1805
|
+
// Headless Chrome reports NO hover device and NO fine pointer
|
|
1806
|
+
// (there's no mouse hardware attached). matchMedia('(any-hover:
|
|
1807
|
+
// hover)') returns matches=false in headless, matches=true in
|
|
1808
|
+
// real desktop. CreepJS and DataDome both probe these queries
|
|
1809
|
+
// explicitly as a hard binary 'is this real browser hardware?'
|
|
1810
|
+
// signal — one of the biggest single contributors to CreepJS's
|
|
1811
|
+
// headless-detection score.
|
|
1812
|
+
//
|
|
1813
|
+
// Pass-through for all OTHER queries (max-width, prefers-color-
|
|
1814
|
+
// scheme, etc.) so legitimate responsive-design checks still work.
|
|
1815
|
+
// Screen-dimension queries naturally reflect the already-spoofed
|
|
1816
|
+
// screen.width/height — no extra handling needed for those.
|
|
1817
|
+
safeExecute(() => {
|
|
1818
|
+
if (typeof window.matchMedia !== 'function') return;
|
|
1819
|
+
const origMatchMedia = window.matchMedia;
|
|
1820
|
+
// Make a fake MediaQueryList that quacks like the real thing.
|
|
1821
|
+
// Real Chrome's MediaQueryList implements EventTarget, so the
|
|
1822
|
+
// listener methods need to exist as callable no-ops.
|
|
1823
|
+
const fakeMql = (query, matches) => ({
|
|
1824
|
+
matches,
|
|
1825
|
+
media: query,
|
|
1826
|
+
onchange: null,
|
|
1827
|
+
addEventListener: () => {},
|
|
1828
|
+
removeEventListener: () => {},
|
|
1829
|
+
addListener: () => {}, // deprecated alias but still present in Chrome
|
|
1830
|
+
removeListener: () => {},
|
|
1831
|
+
dispatchEvent: () => false
|
|
1832
|
+
});
|
|
1833
|
+
window.matchMedia = function(query) {
|
|
1834
|
+
const q = String(query || '').toLowerCase();
|
|
1835
|
+
// Spoof to "yes, mouse + hover hardware present" — the real
|
|
1836
|
+
// desktop answer. Match both `(any-hover: hover)` and the
|
|
1837
|
+
// legacy `(hover: hover)`; same for pointer.
|
|
1838
|
+
if (q.includes('any-hover: hover') || q.includes('hover: hover')) {
|
|
1839
|
+
return fakeMql(query, true);
|
|
1840
|
+
}
|
|
1841
|
+
if (q.includes('any-hover: none') || q.includes('hover: none')) {
|
|
1842
|
+
return fakeMql(query, false);
|
|
1843
|
+
}
|
|
1844
|
+
if (q.includes('any-pointer: fine') || q.includes('pointer: fine')) {
|
|
1845
|
+
return fakeMql(query, true);
|
|
1846
|
+
}
|
|
1847
|
+
if (q.includes('any-pointer: none') || q.includes('pointer: none')) {
|
|
1848
|
+
return fakeMql(query, false);
|
|
1849
|
+
}
|
|
1850
|
+
if (q.includes('any-pointer: coarse') || q.includes('pointer: coarse')) {
|
|
1851
|
+
return fakeMql(query, false); // desktop = no coarse touch pointer
|
|
1852
|
+
}
|
|
1853
|
+
// Anything else falls through to real matchMedia (responsive
|
|
1854
|
+
// queries, color-scheme, reduced-motion, etc. all behave normally).
|
|
1855
|
+
return origMatchMedia.call(this, query);
|
|
1856
|
+
};
|
|
1857
|
+
}, 'matchMedia hover/pointer spoofing');
|
|
1455
1858
|
|
|
1456
1859
|
// Enhanced WebRTC Spoofing
|
|
1457
1860
|
//
|
|
1861
|
+
// Previously stripped only RFC1918 private IPs from ICE candidates,
|
|
1862
|
+
// which leaked the STUN-discovered public IP (`typ srflx`) — visible
|
|
1863
|
+
// in CreepJS's WebRTC section and trivially also probed by DataDome
|
|
1864
|
+
// and other modern fingerprint suites. STUN traffic is UDP, so it
|
|
1865
|
+
// bypasses the SOCKS5 proxy entirely, meaning the real host IP
|
|
1866
|
+
// reaches the fingerprinter regardless of proxy config.
|
|
1867
|
+
//
|
|
1868
|
+
// Fix: strip EVERY ICE candidate (host / srflx / prflx / relay /
|
|
1869
|
+
// mDNS). The scanner never needs functional WebRTC peer connections,
|
|
1870
|
+
// so complete suppression is the right trade-off — the page sees
|
|
1871
|
+
// ICE gathering complete with zero candidates, indistinguishable
|
|
1872
|
+
// from a real browser with no usable network interfaces. The
|
|
1873
|
+
// null-candidate sentinel (end-of-gathering signal) still fires so
|
|
1874
|
+
// calling code that awaits ICE-complete doesn't hang.
|
|
1458
1875
|
safeExecute(() => {
|
|
1459
1876
|
if (window.RTCPeerConnection) {
|
|
1460
1877
|
const OriginalRTC = window.RTCPeerConnection;
|
|
1878
|
+
// Filter helper hoisted so both addEventListener and the
|
|
1879
|
+
// property-handler paths apply identical filtering.
|
|
1880
|
+
const stripCandidate = (event) => !event.candidate;
|
|
1881
|
+
|
|
1461
1882
|
window.RTCPeerConnection = function(...args) {
|
|
1462
1883
|
const pc = new OriginalRTC(...args);
|
|
1463
|
-
|
|
1464
|
-
// Intercept onicecandidate to strip local IP addresses
|
|
1884
|
+
|
|
1465
1885
|
const origAddEventListener = pc.addEventListener.bind(pc);
|
|
1466
|
-
|
|
1886
|
+
// Named function (not anonymous) so maskAsNative gets a sensible
|
|
1887
|
+
// 'addEventListener' name when reporting [native code]. The
|
|
1888
|
+
// bulk-mask block at end of applyFingerprintProtection masks
|
|
1889
|
+
// window-level functions ONCE; per-instance functions created
|
|
1890
|
+
// inside this factory (one new closure per `new RTCPeerConnection()`)
|
|
1891
|
+
// would slip through unmasked — detectable via
|
|
1892
|
+
// pc.addEventListener.toString(). Mask each per-instance to
|
|
1893
|
+
// close that.
|
|
1894
|
+
const addEventListenerWrap = function(type, listener, ...rest) {
|
|
1467
1895
|
if (type === 'icecandidate') {
|
|
1468
1896
|
const wrappedListener = function(event) {
|
|
1469
|
-
if (event.
|
|
1470
|
-
// Strip candidates containing local/private IPs
|
|
1471
|
-
const c = event.candidate.candidate;
|
|
1472
|
-
if (c.includes('.local') || /(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168)\./.test(c)) {
|
|
1473
|
-
return; // suppress local IP candidates
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
listener.call(this, event);
|
|
1897
|
+
if (stripCandidate(event)) listener.call(this, event);
|
|
1477
1898
|
};
|
|
1478
1899
|
return origAddEventListener(type, wrappedListener, ...rest);
|
|
1479
1900
|
}
|
|
1480
1901
|
return origAddEventListener(type, listener, ...rest);
|
|
1481
1902
|
};
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1903
|
+
maskAsNative(addEventListenerWrap, 'addEventListener');
|
|
1904
|
+
pc.addEventListener = addEventListenerWrap;
|
|
1905
|
+
|
|
1906
|
+
// Property-based handler. Previously this just stored the
|
|
1907
|
+
// handler in a local variable and never wired it up — pages
|
|
1908
|
+
// setting `pc.onicecandidate = fn` got NO events at all,
|
|
1909
|
+
// detectable as a mismatch vs addEventListener (which DID
|
|
1910
|
+
// fire). Now we forward the wrapped handler to the underlying
|
|
1911
|
+
// setter so both paths behave identically: the page sees only
|
|
1912
|
+
// the null-candidate sentinel. Both get/set extracted to named
|
|
1913
|
+
// consts so maskAsNative can wrap them — without this, a probe
|
|
1914
|
+
// doing `Object.getOwnPropertyDescriptor(pc, 'onicecandidate').get.toString()`
|
|
1915
|
+
// would see the arrow-function source instead of [native code].
|
|
1916
|
+
let _userHandler = null;
|
|
1917
|
+
const getOnIceCandidate = function() { return _userHandler; };
|
|
1918
|
+
const setOnIceCandidate = function(handler) {
|
|
1919
|
+
_userHandler = handler;
|
|
1920
|
+
// Use the prototype setter so we don't infinite-loop on
|
|
1921
|
+
// our own defineProperty. The wrapped handler applies
|
|
1922
|
+
// the same filter as addEventListener.
|
|
1923
|
+
const proto = Object.getPrototypeOf(pc);
|
|
1924
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'onicecandidate');
|
|
1925
|
+
if (desc && desc.set) {
|
|
1926
|
+
desc.set.call(pc, typeof handler === 'function'
|
|
1927
|
+
? function(event) { if (stripCandidate(event)) handler.call(this, event); }
|
|
1928
|
+
: handler);
|
|
1929
|
+
}
|
|
1930
|
+
};
|
|
1931
|
+
maskAsNative(getOnIceCandidate, 'get onicecandidate');
|
|
1932
|
+
maskAsNative(setOnIceCandidate, 'set onicecandidate');
|
|
1485
1933
|
Object.defineProperty(pc, 'onicecandidate', {
|
|
1486
|
-
get:
|
|
1487
|
-
set:
|
|
1488
|
-
_onicecandidateHandler = handler;
|
|
1489
|
-
// No-op — the addEventListener wrapper above handles filtering
|
|
1490
|
-
},
|
|
1934
|
+
get: getOnIceCandidate,
|
|
1935
|
+
set: setOnIceCandidate,
|
|
1491
1936
|
configurable: true
|
|
1492
1937
|
});
|
|
1493
|
-
|
|
1938
|
+
|
|
1494
1939
|
return pc;
|
|
1495
1940
|
};
|
|
1496
1941
|
Object.setPrototypeOf(window.RTCPeerConnection, OriginalRTC);
|
|
@@ -1606,6 +2051,109 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1606
2051
|
};
|
|
1607
2052
|
}, 'performance timing obfuscation');
|
|
1608
2053
|
|
|
2054
|
+
// PerformanceNavigationTiming jitter — wrap entries returned by
|
|
2055
|
+
// performance.getEntriesByType('navigation') with a Proxy that adds
|
|
2056
|
+
// ±0.5ms jitter to each timing field. Headless Chrome's navigation
|
|
2057
|
+
// timings are suspiciously deterministic (no UI competing for the
|
|
2058
|
+
// main thread, no GC stalls from background rendering); adding
|
|
2059
|
+
// small jitter makes the per-phase durations look human-like.
|
|
2060
|
+
// Probed by some ad-network fingerprinters (e.g. ct.captcha-delivery,
|
|
2061
|
+
// popunder loaders sampling for fraud heuristics) as a 'robotic
|
|
2062
|
+
// timing' signal. Per-entry jitter offsets cached via WeakMap so
|
|
2063
|
+
// repeated reads on the same entry stay consistent (real navigation
|
|
2064
|
+
// timing values are stable per navigation).
|
|
2065
|
+
//
|
|
2066
|
+
// Scope: navigation entries only. 'resource' entries (per-subresource
|
|
2067
|
+
// timing) and PerformanceObserver-delivered entries are NOT wrapped
|
|
2068
|
+
// — significant additional complexity for marginal gain since
|
|
2069
|
+
// typical fingerprinters check only navigation timing.
|
|
2070
|
+
safeExecute(() => {
|
|
2071
|
+
if (typeof performance === 'undefined' || typeof performance.getEntriesByType !== 'function') return;
|
|
2072
|
+
|
|
2073
|
+
const TIMING_FIELDS = new Set([
|
|
2074
|
+
'fetchStart', 'startTime', 'duration', 'redirectStart', 'redirectEnd',
|
|
2075
|
+
'workerStart', // service-worker startup timing (was missing — partial-coverage tell)
|
|
2076
|
+
'domainLookupStart', 'domainLookupEnd',
|
|
2077
|
+
'connectStart', 'connectEnd', 'secureConnectionStart',
|
|
2078
|
+
'requestStart', 'responseStart', 'responseEnd',
|
|
2079
|
+
'domInteractive', 'domContentLoadedEventStart', 'domContentLoadedEventEnd',
|
|
2080
|
+
'domComplete', 'loadEventStart', 'loadEventEnd'
|
|
2081
|
+
]);
|
|
2082
|
+
const wrappedEntries = new WeakMap();
|
|
2083
|
+
|
|
2084
|
+
const wrapEntry = (entry) => {
|
|
2085
|
+
if (entry == null || typeof entry !== 'object') return entry;
|
|
2086
|
+
const cached = wrappedEntries.get(entry);
|
|
2087
|
+
if (cached) return cached.proxy;
|
|
2088
|
+
|
|
2089
|
+
// Per-(entry, field) jitter cache so repeated reads of the
|
|
2090
|
+
// same timing field return the SAME jittered value (real
|
|
2091
|
+
// PerformanceNavigationTiming values are immutable per
|
|
2092
|
+
// navigation; jitter-noise on every read would be detectable
|
|
2093
|
+
// as anomalous). Don't jitter 0 -- those mean 'event never
|
|
2094
|
+
// occurred' (e.g. secureConnectionStart=0 for non-HTTPS);
|
|
2095
|
+
// adding noise would invent a connection that didn't happen.
|
|
2096
|
+
const jitterCache = new Map();
|
|
2097
|
+
const getJittered = (field, value) => {
|
|
2098
|
+
if (typeof value !== 'number' || value === 0) return value;
|
|
2099
|
+
let v = jitterCache.get(field);
|
|
2100
|
+
if (v === undefined) {
|
|
2101
|
+
v = value + (Math.random() - 0.5); // ±0.5ms
|
|
2102
|
+
jitterCache.set(field, v);
|
|
2103
|
+
}
|
|
2104
|
+
return v;
|
|
2105
|
+
};
|
|
2106
|
+
|
|
2107
|
+
// Per-property bound-method cache so accessing the same method
|
|
2108
|
+
// twice returns the same function identity (`p.toJSON === p.toJSON`
|
|
2109
|
+
// is true on real Chrome; without caching, Proxy returns a new
|
|
2110
|
+
// bound function every access — strict-equality probes catch us).
|
|
2111
|
+
// Bound methods also maskAsNative'd so their .toString() reports
|
|
2112
|
+
// '[native code]' rather than the bound-fn source.
|
|
2113
|
+
const methodCache = new Map();
|
|
2114
|
+
|
|
2115
|
+
const proxy = new Proxy(entry, {
|
|
2116
|
+
get(target, prop) {
|
|
2117
|
+
const value = target[prop];
|
|
2118
|
+
if (TIMING_FIELDS.has(prop)) return getJittered(prop, value);
|
|
2119
|
+
if (typeof value === 'function') {
|
|
2120
|
+
let m = methodCache.get(prop);
|
|
2121
|
+
if (m === undefined) {
|
|
2122
|
+
if (prop === 'toJSON') {
|
|
2123
|
+
// toJSON drives JSON.stringify; apply same per-field
|
|
2124
|
+
// jitter cache to the serialised object so direct-read
|
|
2125
|
+
// and JSON-roundtrip yield identical values.
|
|
2126
|
+
m = function() {
|
|
2127
|
+
const json = value.call(target);
|
|
2128
|
+
for (const f of TIMING_FIELDS) {
|
|
2129
|
+
if (f in json) json[f] = getJittered(f, json[f]);
|
|
2130
|
+
}
|
|
2131
|
+
return json;
|
|
2132
|
+
};
|
|
2133
|
+
} else {
|
|
2134
|
+
m = value.bind(target);
|
|
2135
|
+
}
|
|
2136
|
+
maskAsNative(m, prop);
|
|
2137
|
+
methodCache.set(prop, m);
|
|
2138
|
+
}
|
|
2139
|
+
return m;
|
|
2140
|
+
}
|
|
2141
|
+
return value;
|
|
2142
|
+
}
|
|
2143
|
+
});
|
|
2144
|
+
wrappedEntries.set(entry, { proxy, jitterCache, methodCache });
|
|
2145
|
+
return proxy;
|
|
2146
|
+
};
|
|
2147
|
+
|
|
2148
|
+
const origGetByType = performance.getEntriesByType;
|
|
2149
|
+
performance.getEntriesByType = function(type) {
|
|
2150
|
+
const entries = origGetByType.call(this, type);
|
|
2151
|
+
if (type === 'navigation') return entries.map(wrapEntry);
|
|
2152
|
+
return entries;
|
|
2153
|
+
};
|
|
2154
|
+
maskAsNative(performance.getEntriesByType, 'getEntriesByType');
|
|
2155
|
+
}, 'PerformanceNavigationTiming jitter');
|
|
2156
|
+
|
|
1609
2157
|
// Canvas fingerprinting protection
|
|
1610
2158
|
//
|
|
1611
2159
|
safeExecute(() => {
|
|
@@ -1635,29 +2183,46 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1635
2183
|
return imageData;
|
|
1636
2184
|
};
|
|
1637
2185
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
2186
|
+
// Cache of canvases already noised in their current bytes. toDataURL
|
|
2187
|
+
// and toBlob both do a getImageData + putImageData round-trip to
|
|
2188
|
+
// bake noise into the canvas before the actual export. Each
|
|
2189
|
+
// round-trip is O(width × height) -- ~2M iterations for a 1920x1080
|
|
2190
|
+
// canvas, capped at 500k pixels via the size guard below.
|
|
2191
|
+
//
|
|
2192
|
+
// Repeated calls on the SAME canvas don't need re-noising: the
|
|
2193
|
+
// first call already wrote noised pixel data into the canvas via
|
|
2194
|
+
// putImageData. Skip the round-trip for subsequent calls; the
|
|
2195
|
+
// canvas backing store still has the noised content. Trade-off:
|
|
2196
|
+
// if a page redraws between calls (canvas.drawImage, fillRect,
|
|
2197
|
+
// etc.), the new content won't be re-noised. Acceptable for the
|
|
2198
|
+
// common fingerprinter pattern (draw probe content -> single
|
|
2199
|
+
// toDataURL -> compare to known signature); pathological for
|
|
2200
|
+
// animated canvases that re-toDataURL per frame. WeakMap so a
|
|
2201
|
+
// GC'd canvas drops its noise-cache entry automatically.
|
|
2202
|
+
const noisedCanvases = new WeakMap();
|
|
2203
|
+
|
|
2204
|
+
const applyCanvasNoise = function(canvas) {
|
|
2205
|
+
if (noisedCanvases.has(canvas)) return;
|
|
1641
2206
|
try {
|
|
1642
|
-
const ctx =
|
|
1643
|
-
if (ctx &&
|
|
1644
|
-
const imageData = ctx.getImageData(0, 0,
|
|
2207
|
+
const ctx = canvas.getContext('2d');
|
|
2208
|
+
if (ctx && canvas.width > 0 && canvas.height > 0 && canvas.width * canvas.height < 500000) {
|
|
2209
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
1645
2210
|
ctx.putImageData(imageData, 0, 0);
|
|
2211
|
+
noisedCanvases.set(canvas, true);
|
|
1646
2212
|
}
|
|
1647
2213
|
} catch (e) {} // WebGL or other context — skip
|
|
2214
|
+
};
|
|
2215
|
+
|
|
2216
|
+
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
|
2217
|
+
HTMLCanvasElement.prototype.toDataURL = function(...args) {
|
|
2218
|
+
applyCanvasNoise(this);
|
|
1648
2219
|
return originalToDataURL.apply(this, args);
|
|
1649
2220
|
};
|
|
1650
2221
|
|
|
1651
2222
|
const originalToBlob = HTMLCanvasElement.prototype.toBlob;
|
|
1652
2223
|
if (originalToBlob) {
|
|
1653
2224
|
HTMLCanvasElement.prototype.toBlob = function(callback, ...args) {
|
|
1654
|
-
|
|
1655
|
-
const ctx = this.getContext('2d');
|
|
1656
|
-
if (ctx && this.width > 0 && this.height > 0 && this.width * this.height < 500000) {
|
|
1657
|
-
const imageData = ctx.getImageData(0, 0, this.width, this.height);
|
|
1658
|
-
ctx.putImageData(imageData, 0, 0);
|
|
1659
|
-
}
|
|
1660
|
-
} catch (e) {}
|
|
2225
|
+
applyCanvasNoise(this);
|
|
1661
2226
|
return originalToBlob.call(this, callback, ...args);
|
|
1662
2227
|
};
|
|
1663
2228
|
}
|
|
@@ -1665,13 +2230,36 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1665
2230
|
|
|
1666
2231
|
// Battery API spoofing
|
|
1667
2232
|
//
|
|
2233
|
+
// Previously: `Math.random()` for every field, fired on every
|
|
2234
|
+
// evaluateOnNewDocument injection (i.e. every page load). Battery
|
|
2235
|
+
// 'level' jumping from 0.42 to 0.87 to 0.31 across navigations on
|
|
2236
|
+
// the same site is anomalous -- real battery state changes slowly
|
|
2237
|
+
// (minutes, not seconds). Detector noting battery delta across
|
|
2238
|
+
// reloads catches us.
|
|
2239
|
+
//
|
|
2240
|
+
// Now: FNV-1a hash of window.location.hostname seeds all four
|
|
2241
|
+
// fields. Stable per-domain across navigations; varies across
|
|
2242
|
+
// sites (so cross-publisher correlation isn't trivial via this
|
|
2243
|
+
// signal). Also corrects two real-Chrome invariants the old
|
|
2244
|
+
// spoof violated:
|
|
2245
|
+
// - charging=true -> chargingTime finite, dischargingTime = Infinity
|
|
2246
|
+
// - charging=false -> chargingTime = Infinity, dischargingTime finite
|
|
2247
|
+
// The old spoof could produce 'both finite' which never happens
|
|
2248
|
+
// in real Chrome.
|
|
1668
2249
|
safeExecute(() => {
|
|
1669
2250
|
if (navigator.getBattery) {
|
|
2251
|
+
// FNV-1a 32-bit hash of the hostname -- cheap, deterministic.
|
|
2252
|
+
let h = 0x811c9dc5;
|
|
2253
|
+
const domain = (window.location && window.location.hostname) || '';
|
|
2254
|
+
for (let i = 0; i < domain.length; i++) {
|
|
2255
|
+
h = ((h ^ domain.charCodeAt(i)) * 0x01000193) >>> 0;
|
|
2256
|
+
}
|
|
2257
|
+
const charging = (h & 1) === 1;
|
|
1670
2258
|
const batteryState = {
|
|
1671
|
-
charging
|
|
1672
|
-
chargingTime:
|
|
1673
|
-
dischargingTime:
|
|
1674
|
-
level: Math.round((
|
|
2259
|
+
charging,
|
|
2260
|
+
chargingTime: charging ? (((h >>> 4) % 3540) + 60) : Infinity, // 60..3600s when charging
|
|
2261
|
+
dischargingTime: charging ? Infinity : (((h >>> 8) % 6600) + 600), // 600..7200s when on battery
|
|
2262
|
+
level: Math.round((((h >>> 16) % 70) + 25)) / 100, // 0.25..0.94, 2-decimal precision
|
|
1675
2263
|
addEventListener: () => {},
|
|
1676
2264
|
removeEventListener: () => {},
|
|
1677
2265
|
dispatchEvent: () => true
|
|
@@ -1685,10 +2273,20 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1685
2273
|
// Enhanced Mouse/Pointer Spoofing
|
|
1686
2274
|
//
|
|
1687
2275
|
safeExecute(() => {
|
|
1688
|
-
// Spoof pointer capabilities
|
|
1689
|
-
|
|
2276
|
+
// Spoof pointer capabilities — UA-consistent. Previous code did
|
|
2277
|
+
// Math.random() > 0.7 ? 0 : Math.floor(Math.random() * 5) + 1
|
|
2278
|
+
// which randomly told the page 'this Windows Chrome has 3 touch
|
|
2279
|
+
// points' -- a fingerprinter cross-checking userAgent against
|
|
2280
|
+
// maxTouchPoints catches the invariant violation:
|
|
2281
|
+
// Windows/Mac/Linux Chrome -> maxTouchPoints = 0
|
|
2282
|
+
// iOS Safari, Android Chrome -> maxTouchPoints = 5 (typical)
|
|
2283
|
+
// Touch-laptop hybrid -> maxTouchPoints = 10 (rare)
|
|
2284
|
+
// Use the spoofed UA (userAgent variable in this closure) to
|
|
2285
|
+
// pick the right value. Mobile UAs get 5; desktop UAs get 0.
|
|
2286
|
+
const isMobileUA = /Android|iPhone|iPad|iPod|Mobile/i.test(userAgent || '');
|
|
2287
|
+
const spoofedTouchPoints = isMobileUA ? 5 : 0;
|
|
1690
2288
|
if (navigator.maxTouchPoints !== undefined) {
|
|
1691
|
-
safeDefinePropertyLocal(navigator, 'maxTouchPoints', {
|
|
2289
|
+
safeDefinePropertyLocal(navigator, 'maxTouchPoints', {
|
|
1692
2290
|
get: () => spoofedTouchPoints
|
|
1693
2291
|
});
|
|
1694
2292
|
}
|
|
@@ -1810,21 +2408,24 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1810
2408
|
|
|
1811
2409
|
}, 'console error suppression');
|
|
1812
2410
|
|
|
1813
|
-
//
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2411
|
+
// NOTE: The previous `location URL masking` Proxy was removed.
|
|
2412
|
+
// It wrapped window.location in a Proxy to return 'about:blank' when
|
|
2413
|
+
// location.href.includes('data:') — a bookmarklet/extension-injection
|
|
2414
|
+
// hider. nwss.js always navigates to real http(s) URLs via page.goto(),
|
|
2415
|
+
// so location.href never contains 'data:' and the spoof was dead code
|
|
2416
|
+
// for every real scan path. Costs it imposed before removal:
|
|
2417
|
+
// - `configurable: false` on window.location blocked page-side
|
|
2418
|
+
// navigation guards (Cloudflare rocket-loader, ad-script location
|
|
2419
|
+
// manipulation, popunder teardown) from redefining window.location.
|
|
2420
|
+
// - Proxy boundary visible via Object.getOwnPropertyDescriptor
|
|
2421
|
+
// (Proxy's value-as-non-spec-shape is a detectable surface).
|
|
2422
|
+
// - Page-side Object.defineProperty(location, 'href', ...) calls
|
|
2423
|
+
// hit the Proxy first, then forwarded to a spec-non-configurable
|
|
2424
|
+
// property, throwing "Cannot redefine property: href" — log noise
|
|
2425
|
+
// and a contributor to post-popup browser-unresponsive states on
|
|
2426
|
+
// popunder sites (eztv1.xyz scan reproduced this).
|
|
2427
|
+
// If a future use case actually loads via data: URL, reintroduce as a
|
|
2428
|
+
// get-trap-only Proxy with configurable:true so pages can override.
|
|
1828
2429
|
|
|
1829
2430
|
// Bulk-mask all spoofed prototype methods so toString() returns "[native code]"
|
|
1830
2431
|
// Must run AFTER all overrides are applied
|
|
@@ -1856,10 +2457,17 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1856
2457
|
if (typeof window.fetch === 'function') maskAsNative(window.fetch, 'fetch');
|
|
1857
2458
|
if (typeof window.PointerEvent === 'function') maskAsNative(window.PointerEvent, 'PointerEvent');
|
|
1858
2459
|
if (typeof console.debug === 'function') maskAsNative(console.debug, 'debug');
|
|
2460
|
+
// Methods added in this session — same toString-tampering concern.
|
|
2461
|
+
if (typeof window.matchMedia === 'function') maskAsNative(window.matchMedia, 'matchMedia');
|
|
2462
|
+
if (typeof document.hasStorageAccess === 'function') maskAsNative(document.hasStorageAccess, 'hasStorageAccess');
|
|
2463
|
+
if (typeof navigator.getInstalledRelatedApps === 'function') maskAsNative(navigator.getInstalledRelatedApps, 'getInstalledRelatedApps');
|
|
1859
2464
|
|
|
1860
2465
|
// Mask property getters on navigator
|
|
2466
|
+
// 'userActivation' added in this session; 'productSub'/'vendorSub'
|
|
2467
|
+
// are this commit's additions alongside the existing vendor/product.
|
|
1861
2468
|
const navProps = ['userAgentData', 'connection', 'pdfViewerEnabled', 'webdriver',
|
|
1862
|
-
'hardwareConcurrency', 'deviceMemory', 'platform', 'maxTouchPoints'
|
|
2469
|
+
'hardwareConcurrency', 'deviceMemory', 'platform', 'maxTouchPoints',
|
|
2470
|
+
'userActivation', 'vendor', 'product', 'productSub', 'vendorSub'];
|
|
1863
2471
|
navProps.forEach(prop => {
|
|
1864
2472
|
// Check both instance and prototype (webdriver lives on prototype)
|
|
1865
2473
|
const desc = Object.getOwnPropertyDescriptor(navigator, prop)
|
|
@@ -1867,8 +2475,18 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1867
2475
|
if (desc?.get) maskAsNative(desc.get, 'get ' + prop);
|
|
1868
2476
|
});
|
|
1869
2477
|
|
|
1870
|
-
// Mask
|
|
1871
|
-
|
|
2478
|
+
// Mask Notification.permission getter (static property, added this session).
|
|
2479
|
+
if (typeof Notification !== 'undefined') {
|
|
2480
|
+
const npDesc = Object.getOwnPropertyDescriptor(Notification, 'permission');
|
|
2481
|
+
if (npDesc?.get) maskAsNative(npDesc.get, 'get permission');
|
|
2482
|
+
}
|
|
2483
|
+
|
|
2484
|
+
// Mask screen.orientation getter (added this session).
|
|
2485
|
+
const orientDesc = Object.getOwnPropertyDescriptor(window.screen, 'orientation');
|
|
2486
|
+
if (orientDesc?.get) maskAsNative(orientDesc.get, 'get orientation');
|
|
2487
|
+
|
|
2488
|
+
// Mask window property getters — screenLeft/Top added this session.
|
|
2489
|
+
['screenX', 'screenY', 'screenLeft', 'screenTop', 'outerWidth', 'outerHeight'].forEach(prop => {
|
|
1872
2490
|
const desc = Object.getOwnPropertyDescriptor(window, prop);
|
|
1873
2491
|
if (desc?.get) maskAsNative(desc.get, 'get ' + prop);
|
|
1874
2492
|
});
|
|
@@ -2053,19 +2671,22 @@ async function applyFingerprintProtection(page, siteConfig, forceDebug, currentU
|
|
|
2053
2671
|
// Platform, memory, and hardware spoofing combined for better V8 optimization
|
|
2054
2672
|
// (moved into navigatorProps above);
|
|
2055
2673
|
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2674
|
+
// navigator.connection spoof DELETED -- was dead code.
|
|
2675
|
+
// The UA-spoof block (applyUserAgentSpoofing's eOND, ~line 1452)
|
|
2676
|
+
// already defines navigator.connection earlier in the eOND
|
|
2677
|
+
// registration order, with Object.defineProperty (default
|
|
2678
|
+
// configurable: false). The previous re-spoof here used
|
|
2679
|
+
// safeDefinePropertyLocal which has an `existing?.configurable
|
|
2680
|
+
// === false` guard, so it silently failed every time the UA spoof
|
|
2681
|
+
// ran. Net effect: per-navigation random connection values
|
|
2682
|
+
// generated here NEVER reached navigator.connection -- the
|
|
2683
|
+
// hardcoded '4g'/wifi/50/10 from the UA block always won.
|
|
2684
|
+
// Removing this block has zero behavioural change in the typical
|
|
2685
|
+
// (UA + fingerprint_protection) case, and makes the code's actual
|
|
2686
|
+
// behaviour match what reading it would suggest. Per-domain
|
|
2687
|
+
// realistic-randomization of connection is a future improvement;
|
|
2688
|
+
// do it ONCE in the UA-spoof block, not twice with one of them dead.
|
|
2063
2689
|
|
|
2064
|
-
// Connection type spoofing
|
|
2065
|
-
safeDefinePropertyLocal(navigator, 'connection', {
|
|
2066
|
-
get: () => connectionInfo
|
|
2067
|
-
});
|
|
2068
|
-
|
|
2069
2690
|
// Screen properties spoofing
|
|
2070
2691
|
const screenSpoofProps = {};
|
|
2071
2692
|
['width', 'height', 'availWidth', 'availHeight', 'colorDepth', 'pixelDepth'].forEach(prop => {
|