v3.0.3 (2026-05-26)
Improved
-
3 DataDome-targeted gaps closed in
lib/fingerprint.js(insideapplyFingerprintProtection, so gated onsiteConfig.fingerprint_protectionlike every other spoof in that function):Notification.permissionstatic property now returns'default'(real Chrome's no-granted-permission state). Previously onlyNotification.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.screen.orientationinterface 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.<html>webdriverDOM attribute stripped if present. Defensive — modern Puppeteer withignoreDefaultArgs: ['--enable-automation']doesn't emit this, but older driver setups do, and detectors check bothnavigator.webdriverANDdocumentElement.getAttribute('webdriver'). Appended to the existing'webdriver removal'safeExecute block so all webdriver cleanup lives together.
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 sannysoftregression smoke holds at 29 passed / 1 warn / 0 failed (the warn isCHR_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 andinteract/ghost-cursorconfig.
Added
-
scripts/test-stealth.jsnow reports warn-row labels for sannysoft, not just failure-row labels. Previously a cell moving frompassed→warnbetween 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_TOOLSdirectly. Schema additive: result object gains awarnings: string[]array alongside the existingfailures: string[]. -
scripts/test-stealth.jsextracts CreepJS's actual current metrics instead of staleTrust Scoreregex that returnedn/afor 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. -
Additional headless-mode spoofs in
lib/fingerprint.js(all insideapplyFingerprintProtection, gated onsiteConfig.fingerprint_protection):matchMediahover/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.).screenLeft/screenTopmirrorscreenX/screenY. Real Chrome exposes these as identical-value legacy aliases; spoofers often leave them undefined or 0, which is inconsistent with the non-zeroscreenX/Your existing patch produces.- 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.
Honest measurement: CreepJS's specific
headless scoredid 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.
Security
- 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), missingsrflx(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 onicecandidateevents. Caught bytest-stealth.js creepjswhich surfaced the candidate string122.252.155.250 typ srflxand the correspondingip:field in its WebRTC panel. Fix: strip EVERY ICE candidate; deliver only the null-candidate sentinel (end-of-gathering signal). Side note: the property-basedpc.onicecandidate = fnsetter 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.
Stealth hardening (toString masking)
- Added 8 session-introduced spoofs to
Function.prototype.toStringbulk masking (matchMedia,hasStorageAccess,getInstalledRelatedApps,userActivationgetter,Notification.permissiongetter,screen.orientationgetter,screenLeft/screenTopgetters). Without this, each new spoof was detectable via.toString()returning the override source instead of[native code]. - Masked per-instance WebRTC
onicecandidategetter/setter +addEventListenerwrap. The bulk-mask block only runs once at injection; per-RTCPeerConnection closures created inside the factory weren't covered. A site doingObject.getOwnPropertyDescriptor(pc, 'onicecandidate').get.toString()could see the spoof. - Spoofed
navigator.productSub+vendorSub(UA-aware:'20030107'for Chrome/Safari/etc.,'20100101'for Firefox;vendorSubalways''). Companion legacy properties to the already-spoofedvendor/product. Common bot-detection signal since anti-detection libraries often spoof UA but forget these.vendor/productgetters also added to the maskAsNative list (pre-existing oversight folded in).
Fixed
validatePageForInjection's 1.5s race timer is nowunref'd. Last remaining Node-sidesetTimeoutthat wasn't unref'd; could hold the event loop alive for up to 1.5s past scan completion. All Node-side timers inlib/fingerprint.js,lib/nettools.js, andlib/socks-relay.jsare now unref'd.
Performance
- Canvas noise application now cached per
HTMLCanvasElementvia WeakMap.toDataURLandtoBlobpreviously did agetImageData+putImageDataround-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).