@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.
@@ -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
- ['chrome', "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
32
- ['chrome_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
33
- ['chrome_linux', "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"],
34
- ['firefox', "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:148.0) Gecko/20100101 Firefox/148.0"],
35
- ['firefox_mac', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:148.0) Gecko/20100101 Firefox/148.0"],
36
- ['firefox_linux', "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"],
37
- ['safari', "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15"]
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) => setTimeout(() => reject(new Error('Page evaluation timeout')), 1500))
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] : '146';
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
- { brand: 'Google Chrome', version: majorVersion + '.0.7632.160' },
876
- { brand: 'Chromium', version: majorVersion + '.0.7632.160' }
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: '4g',
1315
- rtt: 50,
1316
- downlink: 10,
1317
- saveData: false,
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 headless has distinct audio processing fingerprint
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 (window.AudioContext || window.webkitAudioContext) {
1360
- const OrigAudioContext = window.AudioContext || window.webkitAudioContext;
1361
- const origCreateOscillator = OrigAudioContext.prototype.createOscillator;
1362
- const origCreateDynamicsCompressor = OrigAudioContext.prototype.createDynamicsCompressor;
1363
-
1364
- // Inject deterministic noise into audio output consistent per session
1365
- const audioNoiseSeed = Math.random() * 0.01 - 0.005;
1366
- const compNoiseSeed = Math.random() * 0.1 - 0.05;
1367
-
1368
- OrigAudioContext.prototype.createOscillator = function() {
1369
- const osc = origCreateOscillator.call(this);
1370
- const origFreq = osc.frequency.value;
1371
- osc.frequency.value = origFreq + audioNoiseSeed;
1372
- return osc;
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
- OrigAudioContext.prototype.createDynamicsCompressor = function() {
1375
- const comp = origCreateDynamicsCompressor.call(this);
1376
- const origThreshold = comp.threshold.value;
1377
- comp.threshold.value = origThreshold + compNoiseSeed;
1378
- return comp;
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
- // CSS media query pass through normally. Screen dimensions are already spoofed
1453
- // so matchMedia results will naturally reflect the spoofed values.
1454
- // No modification needed here.
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
- pc.addEventListener = function(type, listener, ...rest) {
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.candidate && event.candidate.candidate) {
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
- // Also intercept the property-based handler
1484
- let _onicecandidateHandler = null;
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: () => _onicecandidateHandler,
1487
- set: (handler) => {
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
- const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
1639
- HTMLCanvasElement.prototype.toDataURL = function(...args) {
1640
- // Apply same deterministic noise by reading through spoofed getImageData
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 = this.getContext('2d');
1643
- if (ctx && this.width > 0 && this.height > 0 && this.width * this.height < 500000) {
1644
- const imageData = ctx.getImageData(0, 0, this.width, this.height);
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
- try {
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: Math.random() > 0.5,
1672
- chargingTime: Math.random() > 0.5 ? Infinity : Math.floor(Math.random() * 3600),
1673
- dischargingTime: Math.floor(Math.random() * 7200),
1674
- level: Math.round((Math.random() * 0.7 + 0.25) * 100) / 100,
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
- const spoofedTouchPoints = Math.random() > 0.7 ? 0 : Math.floor(Math.random() * 5) + 1;
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
- // Hide source URL indicators (data: URLs reveal script injection)
1814
- safeExecute(() => {
1815
- const originalLocation = window.location;
1816
- Object.defineProperty(window, 'location', {
1817
- value: new Proxy(originalLocation, {
1818
- get: function(target, prop) {
1819
- if (prop === 'href' && target[prop] && target[prop].includes('data:')) {
1820
- return 'about:blank';
1821
- }
1822
- return target[prop];
1823
- }
1824
- }),
1825
- configurable: false
1826
- });
1827
- }, 'location URL masking');
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 window property getters
1871
- ['screenX', 'screenY', 'outerWidth', 'outerHeight'].forEach(prop => {
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
- const connectionInfo = {
2057
- effectiveType: ['slow-2g', '2g', '3g', '4g'][Math.floor(Math.random() * 4)],
2058
- type: Math.random() > 0.5 ? 'cellular' : 'wifi',
2059
- saveData: Math.random() > 0.8,
2060
- downlink: 1.5 + Math.random() * 8,
2061
- rtt: 50 + Math.random() * 200
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 => {