@fanboynz/network-scanner 3.0.1 → 3.0.3
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/.github/workflows/npm-publish.yml +4 -1
- package/CHANGELOG.md +68 -0
- package/lib/fingerprint.js +318 -44
- package/lib/nettools.js +28 -2
- package/lib/proxy.js +48 -21
- package/lib/socks-relay.js +242 -47
- package/nwss.js +71 -10
- package/package.json +1 -1
- package/scripts/test-stealth.js +39 -13
|
@@ -162,7 +162,10 @@ jobs:
|
|
|
162
162
|
uses: softprops/action-gh-release@v2
|
|
163
163
|
with:
|
|
164
164
|
tag_name: v${{ steps.version.outputs.version }}
|
|
165
|
-
|
|
165
|
+
# Date suffix matches the convention used by the backfilled
|
|
166
|
+
# historical releases (v2.0.10..v2.0.66) so the Releases page
|
|
167
|
+
# shows the release date inline without a click-through.
|
|
168
|
+
name: v${{ steps.version.outputs.version }} (${{ steps.version.outputs.date }})
|
|
166
169
|
body: ${{ inputs.release_notes_source == 'changelog' && steps.changelog_notes.outputs.notes || steps.manual_notes.outputs.notes }}
|
|
167
170
|
draft: false
|
|
168
171
|
prerelease: ${{ inputs.prerelease }}
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,74 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Network Scanner (nwss.js) project.
|
|
4
4
|
|
|
5
|
+
## [3.0.3] - 2026-05-26
|
|
6
|
+
|
|
7
|
+
### Improved
|
|
8
|
+
- **3 DataDome-targeted gaps closed in `lib/fingerprint.js`** (inside `applyFingerprintProtection`, so gated on `siteConfig.fingerprint_protection` like every other spoof in that function):
|
|
9
|
+
- **`Notification.permission` static property** now returns `'default'` (real Chrome's no-granted-permission state). Previously only `Notification.requestPermission()` (the method) was patched; the static property still returned the headless default `'denied'` — a live tell for DataDome and similar detectors that read it directly.
|
|
10
|
+
- **`screen.orientation` interface** is now provided as a stable `{type: 'landscape-primary', angle: 0, addEventListener, lock, unlock, ...}` object when missing. Modern browsers always expose ScreenOrientation; absence is a "real browser?" check signal.
|
|
11
|
+
- **`<html>` `webdriver` DOM attribute** stripped if present. Defensive — modern Puppeteer with `ignoreDefaultArgs: ['--enable-automation']` doesn't emit this, but older driver setups do, and detectors check both `navigator.webdriver` AND `documentElement.getAttribute('webdriver')`. Appended to the existing `'webdriver removal'` safeExecute block so all webdriver cleanup lives together.
|
|
12
|
+
|
|
13
|
+
Targeted at sites running DataDome's `ct.captcha-delivery.com/i.js` (and similar fingerprint suites: PerimeterX, Akamai Bot Manager). Most other surfaces these detectors probe were already covered (chrome.app/csi/loadTimes, userAgentData, maxTouchPoints, permissions.query, WebGL UNMASKED_VENDOR/RENDERER, etc.). `scripts/test-stealth.js sannysoft` regression smoke holds at 29 passed / 1 warn / 0 failed (the warn is `CHR_DEBUG_TOOLS`, a CDP-attached signal that's fundamental to Puppeteer and unrelated to these additions). JS-only spoofing can't address TLS fingerprint, HTTP/2 fingerprint, IP reputation, or behavioural analysis — those still depend on proxy choice and `interact` / `ghost-cursor` config.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **`scripts/test-stealth.js` now reports warn-row labels** for sannysoft, not just failure-row labels. Previously a cell moving from `passed` → `warn` between runs was invisible (only the count changed), making soft-regression debugging require `--headful`. Now the warn-row table contents print inline so you can see e.g. `warn rows: CHR_DEBUG_TOOLS` directly. Schema additive: result object gains a `warnings: string[]` array alongside the existing `failures: string[]`.
|
|
17
|
+
- **`scripts/test-stealth.js` extracts CreepJS's actual current metrics** instead of stale `Trust Score` regex that returned `n/a` for every field. New extracted fields: `fpId` (CreepJS's stable fingerprint hash, lets you A/B before/after a spoof change), `isChromium` (engine identification), `headlessPct` (HARD headless detection score, lower = better), `likeHeadlessPct` (SOFT headless signals), `stealthPct` (spoof-detection probes score, HIGHER = better since it means our spoofs LOOK convincing). Formatter prints all five with directionality hints inline. Excerpt now 40 lines / 2KB (was 15 / 400 bytes) so future UI rotations are debuggable from the output without `--headful`.
|
|
18
|
+
- **Additional headless-mode spoofs in `lib/fingerprint.js`** (all inside `applyFingerprintProtection`, gated on `siteConfig.fingerprint_protection`):
|
|
19
|
+
- **`matchMedia` hover/pointer queries**: `(any-hover: hover)`, `(any-hover: none)`, `(any-pointer: fine)`, `(any-pointer: none)`, `(any-pointer: coarse)` plus the legacy non-`any-` aliases. Headless Chrome reports no hover device and no fine pointer (no mouse hardware); detectors probe these as a binary 'real desktop hardware?' signal. Pass-through for all other queries (responsive, color-scheme, reduced-motion, etc.).
|
|
20
|
+
- **`screenLeft` / `screenTop` mirror `screenX` / `screenY`**. Real Chrome exposes these as identical-value legacy aliases; spoofers often leave them undefined or 0, which is inconsistent with the non-zero `screenX/Y` our existing patch produces.
|
|
21
|
+
- **Modern Chrome API stubs**: `document.hasStorageAccess()` → `Promise<true>`, `navigator.userActivation` → `{hasBeenActive: true, isActive: true}`, `navigator.getInstalledRelatedApps()` → `Promise<[]>`. Each gated on absence check so real-Chrome paths skip the override.
|
|
22
|
+
|
|
23
|
+
Honest measurement: CreepJS's specific `headless score` did NOT move after these additions (stayed at 67%). My prior estimate of '~-10 to -15 percentage points' was over-optimistic — CreepJS apparently doesn't weight matchMedia hover/pointer heavily in its headless calculation. The additions are still correct spoofs that close real fingerprint gaps and likely help against DataDome / PerimeterX which use different scoring; they're net-positive but score-neutral against CreepJS specifically. The remaining ~67% headless detection is architectural (CDP attachment, software-rasterizer GPU, no real mouse cursor) and can't be lowered without `--headful`.
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
- **WebRTC public-IP leak closed** in `lib/fingerprint.js` (`applyFingerprintProtection`). The previous local-IP filter only stripped RFC1918 private ranges (`10.x / 172.16-31.x / 192.168.x`), missing `srflx` (STUN-discovered PUBLIC IP), `prflx`, `relay`, and host candidates with non-RFC1918 addresses (CGNAT 100.64.0.0/10, link-local IPv6, real public IPs on bare-metal hosts). STUN traffic is UDP and **bypasses the SOCKS5 proxy entirely**, so the leaked IP was the real host IP regardless of proxy config — visible to any page that listened on `icecandidate` events. Caught by `test-stealth.js creepjs` which surfaced the candidate string `122.252.155.250 typ srflx` and the corresponding `ip:` field in its WebRTC panel. Fix: strip EVERY ICE candidate; deliver only the null-candidate sentinel (end-of-gathering signal). Side note: the property-based `pc.onicecandidate = fn` setter was also broken (stored handler but never wired it up); now mirrors the same filter as the addEventListener path. Side effect: any site that REQUIRES functional WebRTC peer connections sees ICE gathering produce zero candidates. For nwss.js's scanning use case this is correct.
|
|
27
|
+
|
|
28
|
+
### Stealth hardening (toString masking)
|
|
29
|
+
- **Added 8 session-introduced spoofs to `Function.prototype.toString` bulk masking** (`matchMedia`, `hasStorageAccess`, `getInstalledRelatedApps`, `userActivation` getter, `Notification.permission` getter, `screen.orientation` getter, `screenLeft`/`screenTop` getters). Without this, each new spoof was detectable via `.toString()` returning the override source instead of `[native code]`.
|
|
30
|
+
- **Masked per-instance WebRTC `onicecandidate` getter/setter + `addEventListener` wrap.** The bulk-mask block only runs once at injection; per-RTCPeerConnection closures created inside the factory weren't covered. A site doing `Object.getOwnPropertyDescriptor(pc, 'onicecandidate').get.toString()` could see the spoof.
|
|
31
|
+
- **Spoofed `navigator.productSub` + `vendorSub`** (UA-aware: `'20030107'` for Chrome/Safari/etc., `'20100101'` for Firefox; `vendorSub` always `''`). Companion legacy properties to the already-spoofed `vendor`/`product`. Common bot-detection signal since anti-detection libraries often spoof UA but forget these. `vendor`/`product` getters also added to the maskAsNative list (pre-existing oversight folded in).
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- **`validatePageForInjection`'s 1.5s race timer is now `unref`'d.** Last remaining Node-side `setTimeout` that wasn't unref'd; could hold the event loop alive for up to 1.5s past scan completion. All Node-side timers in `lib/fingerprint.js`, `lib/nettools.js`, and `lib/socks-relay.js` are now unref'd.
|
|
35
|
+
|
|
36
|
+
### Performance
|
|
37
|
+
- **Canvas noise application now cached per `HTMLCanvasElement`** via WeakMap. `toDataURL` and `toBlob` previously did a `getImageData` + `putImageData` round-trip on every call (~500k iterations for size-capped canvases) to bake noise into the export. Now the round-trip runs once per canvas; subsequent exports skip it (the canvas backing store still has the noised pixels from the first call). Trade-off: animated canvases that redraw between exports won't have new content re-noised — acceptable for the common fingerprinter pattern (single probe → single toDataURL).
|
|
38
|
+
|
|
39
|
+
## [3.0.2] - 2026-05-25
|
|
40
|
+
|
|
41
|
+
### Security
|
|
42
|
+
- **Credentials redacted in `lib/proxy.js` 'Invalid proxy URL' warn** — `getProxyArgs` echoed the raw user-configured `proxyUrl` when parseProxyUrl returned null. For a URL like `socks5://user:pass@host:port` that fails parse (mistyped protocol, port out of range, etc.) this emitted the full credentials to stderr. Regex-strips the `user:pass@` segment (handles both scheme-prefixed and bare host:port forms) before logging. Same redaction policy as `getProxyInfo()` and the socks-relay logs already fixed in 3.0.1. The new port-range validation in this release expanded the trigger surface (one more parse-failure path) which made me find the leak.
|
|
43
|
+
- **`applyProxyAuth` debug log redacted** — the `Auth set for USER@host:port` debug-only log line emitted the raw username. Now `[redacted]@host:port`. Same leak class as above, third site of the same kind.
|
|
44
|
+
|
|
45
|
+
### Added
|
|
46
|
+
- **`scripts/test-stealth.js --format=json`** (already shipped in 3.0.1, listed here only because the harness gained a real consumer via the next item) — `getRelayStats()` exposed from `lib/socks-relay.js`, returning `[{key, port, activeConnections, errors}]` per active relay (`key` with the username segment stripped for safety, IPv6-aware). Diagnostic surface for answering "is the proxy slow because the upstream is saturated or because the scan is opening too many parallel tunnels?" without enabling `forceDebug`.
|
|
47
|
+
- **`delay_uncapped: true` site-config flag** — lifts the 2s post-networkidle delay cap; honors the configured `delay` up to half the per-URL timeout. Targets sites with setTimeout-deferred lazy ad/tracker loaders (weather.com / cbssports.com class) where late requests fire well past the standard window. Default behavior unchanged (still 2s) so fast sites stay fast.
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
- **Race: late-completing dig/whois validations were orphaned.** Per-URL async nettools handlers were scheduled via fire-and-forget `setImmediate(() => netToolsHandler(...))`; if the handler's full async chain (dig spawn + match check + addMatchedDomain) resolved AFTER the result snapshot ran, the addMatchedDomain call landed in a Set that was no longer referenced by any in-flight result. Most visible symptom: domains appearing in the end-of-scan "Fresh dig:" list with no corresponding rule in the output. Now tracked via `trackNetToolsHandler` (closure over per-URL `pendingNetTools[]`) and drained via `drainPendingNetTools()` with a 3s hard cap (`TIMEOUTS.NETTOOLS_DRAIN_TIMEOUT`), called BEFORE `formatRules` at all three snapshot sites (dry-run, success, partial-success/catch path). All three setImmediate call sites (popup observer, main request handler, secondary request handler) migrated.
|
|
51
|
+
- **Race: scan-exit hang up to ~100s when a dig/whois lookup hung.** Four `setTimeout`s in `lib/nettools.js` (outer exec timer, overall 65s timer, whois progressive retry delay up to ~30s, whois server-switch delay ~8s) were not `unref`'d, so a genuinely-hung lookup that survived the new 3s drain could hold the Node event loop alive for the remainder. All four now `unref`'d with defensive `typeof timer.unref === 'function'` guards; the previously-unref'd inner SIGKILL tail-timer makes 5/5 setTimeout sites in the module now safe for scan-exit. Natural-completion paths still `clearTimeout` on resolution, so this only affects the hung-process case.
|
|
52
|
+
- **`parseProxyUrl` accepted ports > 65535.** Now rejects ports outside 1-65535 at parse time, surfacing misconfiguration immediately instead of passing an invalid value to Chromium and getting an opaque downstream error.
|
|
53
|
+
- **`@version 1.1.0` JSDoc** in `lib/proxy.js` was stale (const said `1.2.0`). Aligned to 1.2.0; the const + export then went away in the export trim — see Improved.
|
|
54
|
+
- **Site-config `delay` field was a no-op.** `nwss.js` per-URL handler hardcoded `const delayMs = DEFAULT_DELAY` regardless of `siteConfig.delay`. Now reads `siteConfig.delay || DEFAULT_DELAY`. Visible only with the new `delay_uncapped: true` flag (without it, the configured value is still capped at 2s as before).
|
|
55
|
+
- **"Something went wrong when opening your profile" popup in `--keep-open` headful mode.** `--disable-sync` was conditionally dropped when `--keep-open` was set, which let Chrome's sync subsystem initialise against our temp `userDataDir` (which has no real profile), error out, and pop a modal that blocked the page until dismissed. Three-flag fix: `--disable-sync` is now always-on (was the only one of five `--keep-open`-conditional flags actually causing user-visible breakage), plus `--allow-browser-signin=false` and `AccountConsistencyMirror,AccountConsistencyDice` appended to the existing `--disable-features=` list as defence in depth across Chromium's multiple account-subsystem entry points. The other four conditional-on-keep-open flags (`--disable-component-extensions-with-background-pages`, `--disable-component-update`, `--disable-background-networking`, `--disable-extensions`) stay conditional so user-loaded extensions and live inspection still work normally.
|
|
56
|
+
- **Race: `socks-relay.ensureRelay` concurrent-init created orphan servers.** Two concurrent callers for the same upstream both passed the `_relays.get(key)` check, both created `net.Server` listeners, both raced to `_relays.set` — second overwrote first, first server was orphaned (listening forever, never closed by `closeAllRelays`). Not triggered by current usage (proxy.js's `prepareSocksRelays` uses a sequential await loop) but a latent bug for future parallel-init paths. Fix: singleflight via new `_pendingRelays` Map; second caller for an in-flight upstream rides the existing promise. Cleanup uses `.finally()` on the returned promise (not try/finally inside the IIFE) so a hypothetical sync-throw in the init body can't leave a permanent rejected entry in `_pendingRelays`. Mirrors the `pendingDigLookups`/`pendingWhoisLookups` pattern in `lib/nettools.js`.
|
|
57
|
+
- **Race: handshake watchdog firing during upstream connect orphaned the upstream socket.** `HANDSHAKE_TIMEOUT_MS = 10000` vs `SocksClient.createConnection` timeout = `20000` left a 10-second window where the watchdog could fire mid-await, destroy the client, and set `settled = true`. When the upstream connect then resolved into a fresh socket, the subsequent `cleanup()` short-circuited via the settled guard, leaving an open TCP connection to the upstream that was never destroyed — held alive until OS-level timeout or remote close. Fix: disarm the watchdog at the `phase = 'connecting'` transition (client has completed its part of the handshake; `SocksClient`'s own 20s timeout covers the upstream connect), plus a defence-in-depth `if (settled) destroy + return` after `upstreamSock = info.socket` for any other path that could call cleanup before upstreamSock registers.
|
|
58
|
+
- **Race: `closeAllRelays` didn't wait for in-flight `ensureRelay` inits.** A relay whose `listen()` completed AFTER `closeAllRelays` snapshotted `_relays` landed in `_relays` unowned by the close pass — leaked until next call or process exit. Pre-existing, more visible after `_pendingRelays` became a separate Map for the singleflight. Fix: `await Promise.allSettled(Array.from(_pendingRelays.values()))` at the head of `closeAllRelays` so the snapshot is guaranteed-complete. `allSettled` (not `all`) because rejected inits have already cleaned up their `_pendingRelays` entries via `.finally()`.
|
|
59
|
+
|
|
60
|
+
### Improved
|
|
61
|
+
- **socks-relay handshake buffer cap** (`MAX_HANDSHAKE_BYTES = 4096`) on pre-piping growth. Prior code absorbed arbitrary bytes for the full 10s handshake watchdog window, letting a hostile/buggy local process pin memory by drip-feeding garbage. Sends a protocol-appropriate failure reply per phase before closing.
|
|
62
|
+
- **socks-relay TCP keep-alive on upstream socket** (`setKeepAlive(true, 60000)`). Catches silently-dead upstreams (NAT timeout, mobile-tower drop, proxy crash without FIN/RST) in ~12 minutes (60s idle + kernel-default 9 × 75s probes) instead of the Linux default ~2 hours. Comment is honest about the kernel-default probe math — `60000` is `TCP_KEEPIDLE` only, not the full detection time.
|
|
63
|
+
- **socks-relay auth-misconfig warn** — `ensureRelay` warns once per unique upstream when `username && !password`, since RFC 1929 auth will almost certainly fail. Surfaces the misconfiguration at relay start instead of as opaque per-request failures inside `forceDebug`-gated logs.
|
|
64
|
+
- **socks-relay `server.maxConnections = 256` cap** per relay. Sheds excess Chromium connections at the TCP-accept layer (where HTTP retry handles them cleanly) instead of letting all N tunnels open to the upstream and have the provider silently drop past-quota ones — which looks to the scan like random missed requests.
|
|
65
|
+
- **socks-relay per-relay error counter** tracked in `relayEntry.errors`, bumped on `SocksClient.createConnection` failures, surfaced via `getRelayStats()` as the `errors` field. Lets a post-scan reader see "X of N upstream connects failed" without re-running with forceDebug.
|
|
66
|
+
- **socks-relay graceful drain on `closeAllRelays`** — `DRAIN_TIMEOUT_MS = 2000` window via `Promise.race(closePromise, drainTimeout)` for in-flight tunnels to flush their last response bytes into Chromium / Puppeteer. Stragglers past 2s get force-destroyed (server.close callback then fires immediately). SIGINT mid-scan no longer amputates in-flight responses, but a hung tunnel can't block exit beyond 2s. Drain timer `unref`'d so it doesn't hold the event loop open when the close-promise wins the race.
|
|
67
|
+
- **`lib/proxy.js` exports trimmed 12 → 8** — removed `getModuleInfo`, `PROXY_MODULE_VERSION`, `SUPPORTED_PROTOCOLS`, `getConfiguredProxy` (zero external callers in each case, grep-verified). Mirrors the same trim already done in `lib/cloudflare.js`. `SUPPORTED_PROTOCOLS` and `getConfiguredProxy` stay as module-local since they're used internally.
|
|
68
|
+
- **`lib/proxy.js` code cleanup** — two `require('./socks-relay')` calls consolidated into one destructured import (with `closeAllRelays` renamed inline), `net` module require hoisted from `testProxy()` body to top of file, `applyProxyAuth` JSDoc enumerates the 5 distinct `false` return scenarios (caller treating false as "auth failed" would incorrectly retry on the SOCKS5 → relay handles it case).
|
|
69
|
+
|
|
70
|
+
### CI
|
|
71
|
+
- **GitHub Release names now include date suffix** (`v3.0.2 (YYYY-MM-DD)`), matching the convention used by the backfilled v2.0.10 through v2.0.66 releases. Auto-applied via the already-computed `steps.version.outputs.date` in `softprops/action-gh-release`.
|
|
72
|
+
|
|
5
73
|
## [3.0.1] - 2026-05-24
|
|
6
74
|
|
|
7
75
|
### Security
|
package/lib/fingerprint.js
CHANGED
|
@@ -248,7 +248,15 @@ async function validatePageForInjection(page, currentUrl, forceDebug) {
|
|
|
248
248
|
}
|
|
249
249
|
await Promise.race([
|
|
250
250
|
page.evaluate(() => document.readyState || 'loading'),
|
|
251
|
-
new Promise((_, reject) =>
|
|
251
|
+
new Promise((_, reject) => {
|
|
252
|
+
// unref so a still-pending race timer (page.evaluate won) doesn't
|
|
253
|
+
// hold the Node event loop alive for up to 1.5s past scan exit.
|
|
254
|
+
// Same pattern as the nettools timer unrefs in 83209d4 / 0c5d644;
|
|
255
|
+
// closes the last known node-side unref'd setTimeout in the
|
|
256
|
+
// fingerprint module.
|
|
257
|
+
const t = setTimeout(() => reject(new Error('Page evaluation timeout')), 1500);
|
|
258
|
+
if (typeof t.unref === 'function') t.unref();
|
|
259
|
+
})
|
|
252
260
|
]);
|
|
253
261
|
return true;
|
|
254
262
|
} catch (validationErr) {
|
|
@@ -506,6 +514,21 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
506
514
|
configurable: true,
|
|
507
515
|
enumerable: true
|
|
508
516
|
});
|
|
517
|
+
|
|
518
|
+
// Belt-and-suspenders: some older Chromium-driver setups leave a
|
|
519
|
+
// `webdriver` attribute on the <html> element (different surface
|
|
520
|
+
// from navigator.webdriver). DataDome and similar detectors
|
|
521
|
+
// check both. Modern Puppeteer with ignoreDefaultArgs:
|
|
522
|
+
// ['--enable-automation'] (set in nwss.js's createBrowser) doesn't
|
|
523
|
+
// emit this attribute, so this is defensive against rare edge
|
|
524
|
+
// cases. documentElement should exist by evaluateOnNewDocument
|
|
525
|
+
// time; wrapped in optional-chaining + try/catch for the contexts
|
|
526
|
+
// where it doesn't.
|
|
527
|
+
try {
|
|
528
|
+
if (document?.documentElement?.hasAttribute('webdriver')) {
|
|
529
|
+
document.documentElement.removeAttribute('webdriver');
|
|
530
|
+
}
|
|
531
|
+
} catch (_) {}
|
|
509
532
|
}, 'webdriver removal');
|
|
510
533
|
|
|
511
534
|
// Remove automation properties
|
|
@@ -814,16 +837,27 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
814
837
|
safeExecute(() => {
|
|
815
838
|
let vendor = 'Google Inc.';
|
|
816
839
|
let product = 'Gecko';
|
|
817
|
-
|
|
840
|
+
// productSub is a legacy Mozilla-era property: '20030107' for
|
|
841
|
+
// every browser EXCEPT Firefox (which uses '20100101'). Real
|
|
842
|
+
// Chrome / Safari / etc. all report '20030107'. Real Firefox
|
|
843
|
+
// reports '20100101'. Common bot-detection signal because
|
|
844
|
+
// anti-detection libraries often spoof UA but forget this.
|
|
845
|
+
// vendorSub is always '' across all browsers (legacy/unused).
|
|
846
|
+
let productSub = '20030107';
|
|
847
|
+
const vendorSub = '';
|
|
848
|
+
|
|
818
849
|
if (userAgent.includes('Firefox')) {
|
|
819
850
|
vendor = '';
|
|
851
|
+
productSub = '20100101';
|
|
820
852
|
} else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
|
|
821
853
|
vendor = 'Apple Computer, Inc.';
|
|
822
854
|
}
|
|
823
|
-
|
|
855
|
+
|
|
824
856
|
const vendorProps = {
|
|
825
857
|
vendor: { get: () => vendor },
|
|
826
|
-
product: { get: () => product }
|
|
858
|
+
product: { get: () => product },
|
|
859
|
+
productSub: { get: () => productSub },
|
|
860
|
+
vendorSub: { get: () => vendorSub }
|
|
827
861
|
};
|
|
828
862
|
spoofNavigatorProperties(navigator, vendorProps);
|
|
829
863
|
}, 'vendor/product spoofing');
|
|
@@ -1270,6 +1304,23 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1270
1304
|
if (window.Notification && Notification.requestPermission) {
|
|
1271
1305
|
Notification.requestPermission = () => Promise.resolve('default');
|
|
1272
1306
|
}
|
|
1307
|
+
|
|
1308
|
+
// Notification.permission STATIC PROPERTY — distinct from the
|
|
1309
|
+
// requestPermission() method patched above. DataDome and similar
|
|
1310
|
+
// detectors read Notification.permission directly. Real Chrome
|
|
1311
|
+
// with no granted permission returns 'default'; headless Chrome
|
|
1312
|
+
// returns 'denied'. Without this override the prior block (which
|
|
1313
|
+
// only patched the method) leaves the static property as a live
|
|
1314
|
+
// headless tell. Wrapped in try/catch because some embedded
|
|
1315
|
+
// contexts make Notification non-configurable.
|
|
1316
|
+
if (window.Notification) {
|
|
1317
|
+
try {
|
|
1318
|
+
Object.defineProperty(Notification, 'permission', {
|
|
1319
|
+
get: () => 'default',
|
|
1320
|
+
configurable: true
|
|
1321
|
+
});
|
|
1322
|
+
} catch (_) {}
|
|
1323
|
+
}
|
|
1273
1324
|
}, 'permissions API spoofing');
|
|
1274
1325
|
|
|
1275
1326
|
// Media Device Spoofing
|
|
@@ -1299,9 +1350,105 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1299
1350
|
const sY = Math.floor(Math.random() * 50) + 20;
|
|
1300
1351
|
Object.defineProperty(window, 'screenX', { get: () => sX });
|
|
1301
1352
|
Object.defineProperty(window, 'screenY', { get: () => sY });
|
|
1353
|
+
// screenLeft / screenTop are the legacy IE-era aliases for
|
|
1354
|
+
// screenX / screenY; real Chrome exposes them as identical
|
|
1355
|
+
// values. Headless / spoofers often leave them undefined or 0,
|
|
1356
|
+
// which is a tell since the screenX/Y patch above changed
|
|
1357
|
+
// those values to be non-zero. Mirror them explicitly so the
|
|
1358
|
+
// four properties stay consistent.
|
|
1359
|
+
Object.defineProperty(window, 'screenLeft', { get: () => sX });
|
|
1360
|
+
Object.defineProperty(window, 'screenTop', { get: () => sY });
|
|
1302
1361
|
}
|
|
1303
1362
|
}, 'window dimension spoofing');
|
|
1304
1363
|
|
|
1364
|
+
// Modern Chrome API stubs — these APIs exist in real desktop
|
|
1365
|
+
// Chrome but may be missing or wrong in headless. Detectors check
|
|
1366
|
+
// presence + minimal shape as a 'real browser?' signal. Each one
|
|
1367
|
+
// is wrapped individually so a failure on one doesn't break the
|
|
1368
|
+
// others, and the existence checks let real-Chrome paths
|
|
1369
|
+
// (where the property already exists with the right value) skip
|
|
1370
|
+
// the override entirely.
|
|
1371
|
+
safeExecute(() => {
|
|
1372
|
+
// Document.hasStorageAccess() — Storage Access API. Real Chrome
|
|
1373
|
+
// returns a Promise<boolean>; resolve with true to mimic the
|
|
1374
|
+
// common 'storage already accessible (top-level context)' state.
|
|
1375
|
+
if (document && typeof document.hasStorageAccess !== 'function') {
|
|
1376
|
+
try {
|
|
1377
|
+
Object.defineProperty(document, 'hasStorageAccess', {
|
|
1378
|
+
value: () => Promise.resolve(true),
|
|
1379
|
+
configurable: true,
|
|
1380
|
+
writable: true
|
|
1381
|
+
});
|
|
1382
|
+
} catch (_) {}
|
|
1383
|
+
}
|
|
1384
|
+
}, 'document.hasStorageAccess stub');
|
|
1385
|
+
|
|
1386
|
+
safeExecute(() => {
|
|
1387
|
+
// navigator.userActivation — UserActivation interface tracking
|
|
1388
|
+
// whether the page has had a user gesture. Real Chrome exposes
|
|
1389
|
+
// {hasBeenActive: bool, isActive: bool}. After interaction
|
|
1390
|
+
// simulation (interact / ghost-cursor) both should be true; we
|
|
1391
|
+
// default to true so a site checking before our synthetic
|
|
1392
|
+
// gestures fire still sees a 'user activated' page.
|
|
1393
|
+
if (navigator && !navigator.userActivation) {
|
|
1394
|
+
try {
|
|
1395
|
+
const userActivation = {
|
|
1396
|
+
hasBeenActive: true,
|
|
1397
|
+
isActive: true
|
|
1398
|
+
};
|
|
1399
|
+
Object.defineProperty(navigator, 'userActivation', {
|
|
1400
|
+
get: () => userActivation,
|
|
1401
|
+
configurable: true
|
|
1402
|
+
});
|
|
1403
|
+
} catch (_) {}
|
|
1404
|
+
}
|
|
1405
|
+
}, 'navigator.userActivation stub');
|
|
1406
|
+
|
|
1407
|
+
safeExecute(() => {
|
|
1408
|
+
// navigator.getInstalledRelatedApps() — Chrome-specific API
|
|
1409
|
+
// returning installed PWAs/native apps related to the current
|
|
1410
|
+
// origin. Real Chrome has this as a function; absence is a tell
|
|
1411
|
+
// for non-Chrome / headless. Return empty array (the common
|
|
1412
|
+
// no-related-apps case) — same as a fresh real-Chrome profile.
|
|
1413
|
+
if (navigator && typeof navigator.getInstalledRelatedApps !== 'function') {
|
|
1414
|
+
try {
|
|
1415
|
+
Object.defineProperty(navigator, 'getInstalledRelatedApps', {
|
|
1416
|
+
value: () => Promise.resolve([]),
|
|
1417
|
+
configurable: true,
|
|
1418
|
+
writable: true
|
|
1419
|
+
});
|
|
1420
|
+
} catch (_) {}
|
|
1421
|
+
}
|
|
1422
|
+
}, 'navigator.getInstalledRelatedApps stub');
|
|
1423
|
+
|
|
1424
|
+
// screen.orientation — modern browsers expose a ScreenOrientation
|
|
1425
|
+
// interface ({type, angle, addEventListener, lock, unlock, ...}).
|
|
1426
|
+
// Missing entirely in some headless contexts; presence + shape are
|
|
1427
|
+
// checked by DataDome and similar detectors as a "real browser"
|
|
1428
|
+
// signal. The values below mirror real desktop Chrome's landscape
|
|
1429
|
+
// primary orientation; lock()/unlock() match real-Chrome behaviour
|
|
1430
|
+
// when no fullscreen element is active (lock rejects with
|
|
1431
|
+
// NotSupportedError, unlock is a no-op). Object identity is stable
|
|
1432
|
+
// (hoisted out of the getter) so reference-equality checks pass.
|
|
1433
|
+
safeExecute(() => {
|
|
1434
|
+
if (!window.screen.orientation) {
|
|
1435
|
+
const orientation = {
|
|
1436
|
+
type: 'landscape-primary',
|
|
1437
|
+
angle: 0,
|
|
1438
|
+
onchange: null,
|
|
1439
|
+
addEventListener: () => {},
|
|
1440
|
+
removeEventListener: () => {},
|
|
1441
|
+
dispatchEvent: () => false,
|
|
1442
|
+
lock: () => Promise.reject(new Error('NotSupportedError')),
|
|
1443
|
+
unlock: () => {}
|
|
1444
|
+
};
|
|
1445
|
+
Object.defineProperty(window.screen, 'orientation', {
|
|
1446
|
+
get: () => orientation,
|
|
1447
|
+
configurable: true
|
|
1448
|
+
});
|
|
1449
|
+
}
|
|
1450
|
+
}, 'screen.orientation spoofing');
|
|
1451
|
+
|
|
1305
1452
|
// navigator.connection — missing or incomplete in headless.
|
|
1306
1453
|
// Object literal hoisted out of the getter so identity is stable
|
|
1307
1454
|
// across reads. Real Chrome's NetworkInformation instance has
|
|
@@ -1447,50 +1594,143 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1447
1594
|
}
|
|
1448
1595
|
}, 'image loading obfuscation');
|
|
1449
1596
|
|
|
1450
|
-
// CSS Media Query Spoofing
|
|
1597
|
+
// CSS Media Query Spoofing — specifically the hardware-presence
|
|
1598
|
+
// queries that distinguish a real desktop browser from headless.
|
|
1599
|
+
//
|
|
1600
|
+
// Headless Chrome reports NO hover device and NO fine pointer
|
|
1601
|
+
// (there's no mouse hardware attached). matchMedia('(any-hover:
|
|
1602
|
+
// hover)') returns matches=false in headless, matches=true in
|
|
1603
|
+
// real desktop. CreepJS and DataDome both probe these queries
|
|
1604
|
+
// explicitly as a hard binary 'is this real browser hardware?'
|
|
1605
|
+
// signal — one of the biggest single contributors to CreepJS's
|
|
1606
|
+
// headless-detection score.
|
|
1451
1607
|
//
|
|
1452
|
-
//
|
|
1453
|
-
//
|
|
1454
|
-
//
|
|
1608
|
+
// Pass-through for all OTHER queries (max-width, prefers-color-
|
|
1609
|
+
// scheme, etc.) so legitimate responsive-design checks still work.
|
|
1610
|
+
// Screen-dimension queries naturally reflect the already-spoofed
|
|
1611
|
+
// screen.width/height — no extra handling needed for those.
|
|
1612
|
+
safeExecute(() => {
|
|
1613
|
+
if (typeof window.matchMedia !== 'function') return;
|
|
1614
|
+
const origMatchMedia = window.matchMedia;
|
|
1615
|
+
// Make a fake MediaQueryList that quacks like the real thing.
|
|
1616
|
+
// Real Chrome's MediaQueryList implements EventTarget, so the
|
|
1617
|
+
// listener methods need to exist as callable no-ops.
|
|
1618
|
+
const fakeMql = (query, matches) => ({
|
|
1619
|
+
matches,
|
|
1620
|
+
media: query,
|
|
1621
|
+
onchange: null,
|
|
1622
|
+
addEventListener: () => {},
|
|
1623
|
+
removeEventListener: () => {},
|
|
1624
|
+
addListener: () => {}, // deprecated alias but still present in Chrome
|
|
1625
|
+
removeListener: () => {},
|
|
1626
|
+
dispatchEvent: () => false
|
|
1627
|
+
});
|
|
1628
|
+
window.matchMedia = function(query) {
|
|
1629
|
+
const q = String(query || '').toLowerCase();
|
|
1630
|
+
// Spoof to "yes, mouse + hover hardware present" — the real
|
|
1631
|
+
// desktop answer. Match both `(any-hover: hover)` and the
|
|
1632
|
+
// legacy `(hover: hover)`; same for pointer.
|
|
1633
|
+
if (q.includes('any-hover: hover') || q.includes('hover: hover')) {
|
|
1634
|
+
return fakeMql(query, true);
|
|
1635
|
+
}
|
|
1636
|
+
if (q.includes('any-hover: none') || q.includes('hover: none')) {
|
|
1637
|
+
return fakeMql(query, false);
|
|
1638
|
+
}
|
|
1639
|
+
if (q.includes('any-pointer: fine') || q.includes('pointer: fine')) {
|
|
1640
|
+
return fakeMql(query, true);
|
|
1641
|
+
}
|
|
1642
|
+
if (q.includes('any-pointer: none') || q.includes('pointer: none')) {
|
|
1643
|
+
return fakeMql(query, false);
|
|
1644
|
+
}
|
|
1645
|
+
if (q.includes('any-pointer: coarse') || q.includes('pointer: coarse')) {
|
|
1646
|
+
return fakeMql(query, false); // desktop = no coarse touch pointer
|
|
1647
|
+
}
|
|
1648
|
+
// Anything else falls through to real matchMedia (responsive
|
|
1649
|
+
// queries, color-scheme, reduced-motion, etc. all behave normally).
|
|
1650
|
+
return origMatchMedia.call(this, query);
|
|
1651
|
+
};
|
|
1652
|
+
}, 'matchMedia hover/pointer spoofing');
|
|
1455
1653
|
|
|
1456
1654
|
// Enhanced WebRTC Spoofing
|
|
1457
1655
|
//
|
|
1656
|
+
// Previously stripped only RFC1918 private IPs from ICE candidates,
|
|
1657
|
+
// which leaked the STUN-discovered public IP (`typ srflx`) — visible
|
|
1658
|
+
// in CreepJS's WebRTC section and trivially also probed by DataDome
|
|
1659
|
+
// and other modern fingerprint suites. STUN traffic is UDP, so it
|
|
1660
|
+
// bypasses the SOCKS5 proxy entirely, meaning the real host IP
|
|
1661
|
+
// reaches the fingerprinter regardless of proxy config.
|
|
1662
|
+
//
|
|
1663
|
+
// Fix: strip EVERY ICE candidate (host / srflx / prflx / relay /
|
|
1664
|
+
// mDNS). The scanner never needs functional WebRTC peer connections,
|
|
1665
|
+
// so complete suppression is the right trade-off — the page sees
|
|
1666
|
+
// ICE gathering complete with zero candidates, indistinguishable
|
|
1667
|
+
// from a real browser with no usable network interfaces. The
|
|
1668
|
+
// null-candidate sentinel (end-of-gathering signal) still fires so
|
|
1669
|
+
// calling code that awaits ICE-complete doesn't hang.
|
|
1458
1670
|
safeExecute(() => {
|
|
1459
1671
|
if (window.RTCPeerConnection) {
|
|
1460
1672
|
const OriginalRTC = window.RTCPeerConnection;
|
|
1673
|
+
// Filter helper hoisted so both addEventListener and the
|
|
1674
|
+
// property-handler paths apply identical filtering.
|
|
1675
|
+
const stripCandidate = (event) => !event.candidate;
|
|
1676
|
+
|
|
1461
1677
|
window.RTCPeerConnection = function(...args) {
|
|
1462
1678
|
const pc = new OriginalRTC(...args);
|
|
1463
|
-
|
|
1464
|
-
// Intercept onicecandidate to strip local IP addresses
|
|
1679
|
+
|
|
1465
1680
|
const origAddEventListener = pc.addEventListener.bind(pc);
|
|
1466
|
-
|
|
1681
|
+
// Named function (not anonymous) so maskAsNative gets a sensible
|
|
1682
|
+
// 'addEventListener' name when reporting [native code]. The
|
|
1683
|
+
// bulk-mask block at end of applyFingerprintProtection masks
|
|
1684
|
+
// window-level functions ONCE; per-instance functions created
|
|
1685
|
+
// inside this factory (one new closure per `new RTCPeerConnection()`)
|
|
1686
|
+
// would slip through unmasked — detectable via
|
|
1687
|
+
// pc.addEventListener.toString(). Mask each per-instance to
|
|
1688
|
+
// close that.
|
|
1689
|
+
const addEventListenerWrap = function(type, listener, ...rest) {
|
|
1467
1690
|
if (type === 'icecandidate') {
|
|
1468
1691
|
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);
|
|
1692
|
+
if (stripCandidate(event)) listener.call(this, event);
|
|
1477
1693
|
};
|
|
1478
1694
|
return origAddEventListener(type, wrappedListener, ...rest);
|
|
1479
1695
|
}
|
|
1480
1696
|
return origAddEventListener(type, listener, ...rest);
|
|
1481
1697
|
};
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1698
|
+
maskAsNative(addEventListenerWrap, 'addEventListener');
|
|
1699
|
+
pc.addEventListener = addEventListenerWrap;
|
|
1700
|
+
|
|
1701
|
+
// Property-based handler. Previously this just stored the
|
|
1702
|
+
// handler in a local variable and never wired it up — pages
|
|
1703
|
+
// setting `pc.onicecandidate = fn` got NO events at all,
|
|
1704
|
+
// detectable as a mismatch vs addEventListener (which DID
|
|
1705
|
+
// fire). Now we forward the wrapped handler to the underlying
|
|
1706
|
+
// setter so both paths behave identically: the page sees only
|
|
1707
|
+
// the null-candidate sentinel. Both get/set extracted to named
|
|
1708
|
+
// consts so maskAsNative can wrap them — without this, a probe
|
|
1709
|
+
// doing `Object.getOwnPropertyDescriptor(pc, 'onicecandidate').get.toString()`
|
|
1710
|
+
// would see the arrow-function source instead of [native code].
|
|
1711
|
+
let _userHandler = null;
|
|
1712
|
+
const getOnIceCandidate = function() { return _userHandler; };
|
|
1713
|
+
const setOnIceCandidate = function(handler) {
|
|
1714
|
+
_userHandler = handler;
|
|
1715
|
+
// Use the prototype setter so we don't infinite-loop on
|
|
1716
|
+
// our own defineProperty. The wrapped handler applies
|
|
1717
|
+
// the same filter as addEventListener.
|
|
1718
|
+
const proto = Object.getPrototypeOf(pc);
|
|
1719
|
+
const desc = Object.getOwnPropertyDescriptor(proto, 'onicecandidate');
|
|
1720
|
+
if (desc && desc.set) {
|
|
1721
|
+
desc.set.call(pc, typeof handler === 'function'
|
|
1722
|
+
? function(event) { if (stripCandidate(event)) handler.call(this, event); }
|
|
1723
|
+
: handler);
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
maskAsNative(getOnIceCandidate, 'get onicecandidate');
|
|
1727
|
+
maskAsNative(setOnIceCandidate, 'set onicecandidate');
|
|
1485
1728
|
Object.defineProperty(pc, 'onicecandidate', {
|
|
1486
|
-
get:
|
|
1487
|
-
set:
|
|
1488
|
-
_onicecandidateHandler = handler;
|
|
1489
|
-
// No-op — the addEventListener wrapper above handles filtering
|
|
1490
|
-
},
|
|
1729
|
+
get: getOnIceCandidate,
|
|
1730
|
+
set: setOnIceCandidate,
|
|
1491
1731
|
configurable: true
|
|
1492
1732
|
});
|
|
1493
|
-
|
|
1733
|
+
|
|
1494
1734
|
return pc;
|
|
1495
1735
|
};
|
|
1496
1736
|
Object.setPrototypeOf(window.RTCPeerConnection, OriginalRTC);
|
|
@@ -1635,29 +1875,46 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1635
1875
|
return imageData;
|
|
1636
1876
|
};
|
|
1637
1877
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1878
|
+
// Cache of canvases already noised in their current bytes. toDataURL
|
|
1879
|
+
// and toBlob both do a getImageData + putImageData round-trip to
|
|
1880
|
+
// bake noise into the canvas before the actual export. Each
|
|
1881
|
+
// round-trip is O(width × height) -- ~2M iterations for a 1920x1080
|
|
1882
|
+
// canvas, capped at 500k pixels via the size guard below.
|
|
1883
|
+
//
|
|
1884
|
+
// Repeated calls on the SAME canvas don't need re-noising: the
|
|
1885
|
+
// first call already wrote noised pixel data into the canvas via
|
|
1886
|
+
// putImageData. Skip the round-trip for subsequent calls; the
|
|
1887
|
+
// canvas backing store still has the noised content. Trade-off:
|
|
1888
|
+
// if a page redraws between calls (canvas.drawImage, fillRect,
|
|
1889
|
+
// etc.), the new content won't be re-noised. Acceptable for the
|
|
1890
|
+
// common fingerprinter pattern (draw probe content -> single
|
|
1891
|
+
// toDataURL -> compare to known signature); pathological for
|
|
1892
|
+
// animated canvases that re-toDataURL per frame. WeakMap so a
|
|
1893
|
+
// GC'd canvas drops its noise-cache entry automatically.
|
|
1894
|
+
const noisedCanvases = new WeakMap();
|
|
1895
|
+
|
|
1896
|
+
const applyCanvasNoise = function(canvas) {
|
|
1897
|
+
if (noisedCanvases.has(canvas)) return;
|
|
1641
1898
|
try {
|
|
1642
|
-
const ctx =
|
|
1643
|
-
if (ctx &&
|
|
1644
|
-
const imageData = ctx.getImageData(0, 0,
|
|
1899
|
+
const ctx = canvas.getContext('2d');
|
|
1900
|
+
if (ctx && canvas.width > 0 && canvas.height > 0 && canvas.width * canvas.height < 500000) {
|
|
1901
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
1645
1902
|
ctx.putImageData(imageData, 0, 0);
|
|
1903
|
+
noisedCanvases.set(canvas, true);
|
|
1646
1904
|
}
|
|
1647
1905
|
} catch (e) {} // WebGL or other context — skip
|
|
1906
|
+
};
|
|
1907
|
+
|
|
1908
|
+
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
|
1909
|
+
HTMLCanvasElement.prototype.toDataURL = function(...args) {
|
|
1910
|
+
applyCanvasNoise(this);
|
|
1648
1911
|
return originalToDataURL.apply(this, args);
|
|
1649
1912
|
};
|
|
1650
1913
|
|
|
1651
1914
|
const originalToBlob = HTMLCanvasElement.prototype.toBlob;
|
|
1652
1915
|
if (originalToBlob) {
|
|
1653
1916
|
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) {}
|
|
1917
|
+
applyCanvasNoise(this);
|
|
1661
1918
|
return originalToBlob.call(this, callback, ...args);
|
|
1662
1919
|
};
|
|
1663
1920
|
}
|
|
@@ -1856,10 +2113,17 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1856
2113
|
if (typeof window.fetch === 'function') maskAsNative(window.fetch, 'fetch');
|
|
1857
2114
|
if (typeof window.PointerEvent === 'function') maskAsNative(window.PointerEvent, 'PointerEvent');
|
|
1858
2115
|
if (typeof console.debug === 'function') maskAsNative(console.debug, 'debug');
|
|
2116
|
+
// Methods added in this session — same toString-tampering concern.
|
|
2117
|
+
if (typeof window.matchMedia === 'function') maskAsNative(window.matchMedia, 'matchMedia');
|
|
2118
|
+
if (typeof document.hasStorageAccess === 'function') maskAsNative(document.hasStorageAccess, 'hasStorageAccess');
|
|
2119
|
+
if (typeof navigator.getInstalledRelatedApps === 'function') maskAsNative(navigator.getInstalledRelatedApps, 'getInstalledRelatedApps');
|
|
1859
2120
|
|
|
1860
2121
|
// Mask property getters on navigator
|
|
2122
|
+
// 'userActivation' added in this session; 'productSub'/'vendorSub'
|
|
2123
|
+
// are this commit's additions alongside the existing vendor/product.
|
|
1861
2124
|
const navProps = ['userAgentData', 'connection', 'pdfViewerEnabled', 'webdriver',
|
|
1862
|
-
'hardwareConcurrency', 'deviceMemory', 'platform', 'maxTouchPoints'
|
|
2125
|
+
'hardwareConcurrency', 'deviceMemory', 'platform', 'maxTouchPoints',
|
|
2126
|
+
'userActivation', 'vendor', 'product', 'productSub', 'vendorSub'];
|
|
1863
2127
|
navProps.forEach(prop => {
|
|
1864
2128
|
// Check both instance and prototype (webdriver lives on prototype)
|
|
1865
2129
|
const desc = Object.getOwnPropertyDescriptor(navigator, prop)
|
|
@@ -1867,8 +2131,18 @@ async function applyUserAgentSpoofing(page, siteConfig, forceDebug, currentUrl)
|
|
|
1867
2131
|
if (desc?.get) maskAsNative(desc.get, 'get ' + prop);
|
|
1868
2132
|
});
|
|
1869
2133
|
|
|
1870
|
-
// Mask
|
|
1871
|
-
|
|
2134
|
+
// Mask Notification.permission getter (static property, added this session).
|
|
2135
|
+
if (typeof Notification !== 'undefined') {
|
|
2136
|
+
const npDesc = Object.getOwnPropertyDescriptor(Notification, 'permission');
|
|
2137
|
+
if (npDesc?.get) maskAsNative(npDesc.get, 'get permission');
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// Mask screen.orientation getter (added this session).
|
|
2141
|
+
const orientDesc = Object.getOwnPropertyDescriptor(window.screen, 'orientation');
|
|
2142
|
+
if (orientDesc?.get) maskAsNative(orientDesc.get, 'get orientation');
|
|
2143
|
+
|
|
2144
|
+
// Mask window property getters — screenLeft/Top added this session.
|
|
2145
|
+
['screenX', 'screenY', 'screenLeft', 'screenTop', 'outerWidth', 'outerHeight'].forEach(prop => {
|
|
1872
2146
|
const desc = Object.getOwnPropertyDescriptor(window, prop);
|
|
1873
2147
|
if (desc?.get) maskAsNative(desc.get, 'get ' + prop);
|
|
1874
2148
|
});
|
package/lib/nettools.js
CHANGED
|
@@ -418,6 +418,12 @@ function execFileWithTimeout(cmd, args, timeout = 10000) {
|
|
|
418
418
|
|
|
419
419
|
reject(new Error(`Command timeout after ${timeout}ms: ${cmd} ${args.join(' ')}`));
|
|
420
420
|
}, timeout);
|
|
421
|
+
// unref the outer timeout too — a hung dig/whois firing AFTER the
|
|
422
|
+
// per-URL drain (3s cap) already returned would otherwise hold the
|
|
423
|
+
// event loop alive for up to `timeout` (5-10s) on scan exit. The exec
|
|
424
|
+
// callback / 'error' handler still clear it via the existing
|
|
425
|
+
// clearTimeout, so this only matters for the genuinely-hung case.
|
|
426
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
421
427
|
|
|
422
428
|
// Handle child process errors
|
|
423
429
|
child.on('error', (err) => {
|
|
@@ -790,7 +796,17 @@ async function whoisLookupWithRetry(domain = '', timeout = 10000, whoisServer =
|
|
|
790
796
|
console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Adding ${actualDelay}ms progressive delay before retry ${retryCount + 1} (base: ${baseDelay}ms + extra: ${extraDelay}ms)...`));
|
|
791
797
|
}
|
|
792
798
|
}
|
|
793
|
-
|
|
799
|
+
// unref the retry-delay timer so a pending backoff (up to ~30s on
|
|
800
|
+
// late attempts) can't hold the event loop alive past scan exit
|
|
801
|
+
// when the per-URL drain has already returned. If the process is
|
|
802
|
+
// still otherwise busy, the timer fires normally; if it's the only
|
|
803
|
+
// thing left, the process exits and the now-pointless retry result
|
|
804
|
+
// never lands. Same pattern as the execFile/overall timers
|
|
805
|
+
// unref'd in 83209d4.
|
|
806
|
+
await new Promise(resolve => {
|
|
807
|
+
const t = setTimeout(resolve, actualDelay);
|
|
808
|
+
if (typeof t.unref === 'function') t.unref();
|
|
809
|
+
});
|
|
794
810
|
} else if (serverIndex > 0 && retryCount === 0 && whoisDelay > 0) {
|
|
795
811
|
// Add delay before trying a new server (but not the very first server)
|
|
796
812
|
if (debugMode) {
|
|
@@ -800,7 +816,11 @@ async function whoisLookupWithRetry(domain = '', timeout = 10000, whoisServer =
|
|
|
800
816
|
console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Adding ${whoisDelay}ms delay before trying new server...`));
|
|
801
817
|
}
|
|
802
818
|
}
|
|
803
|
-
|
|
819
|
+
// Same unref rationale as the retry-delay timer above.
|
|
820
|
+
await new Promise(resolve => {
|
|
821
|
+
const t = setTimeout(resolve, whoisDelay);
|
|
822
|
+
if (typeof t.unref === 'function') t.unref();
|
|
823
|
+
});
|
|
804
824
|
} else if (debugMode && whoisDelay === 0) {
|
|
805
825
|
// Log when delay is skipped due to whoisDelay being 0
|
|
806
826
|
if (logFunc) {
|
|
@@ -1232,6 +1252,12 @@ function createNetToolsHandler(config) {
|
|
|
1232
1252
|
})(),
|
|
1233
1253
|
new Promise((_, reject) => {
|
|
1234
1254
|
overallTimeoutId = setTimeout(() => reject(new Error('NetTools overall timeout')), 65000);
|
|
1255
|
+
// unref so a still-pending overall timeout (handler returned via
|
|
1256
|
+
// drain at 3s but the lookup is technically still in-flight) can't
|
|
1257
|
+
// hold the event loop alive for the full 65s on scan exit. The
|
|
1258
|
+
// finally on the inner promise still clearTimeouts on natural
|
|
1259
|
+
// completion, so this only matters for the genuinely-hung case.
|
|
1260
|
+
if (typeof overallTimeoutId.unref === 'function') overallTimeoutId.unref();
|
|
1235
1261
|
})
|
|
1236
1262
|
]).catch(err => {
|
|
1237
1263
|
if (forceDebug) {
|