Skip to content

v3.0.3 (2026-05-26)

Choose a tag to compare

@github-actions github-actions released this 26 May 06:56
· 227 commits to main since this release

Improved

  • 3 DataDome-targeted gaps closed in lib/fingerprint.js (inside applyFingerprintProtection, so gated on siteConfig.fingerprint_protection like every other spoof in that function):

    • 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.
    • 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.
    • <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.

    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.

Added

  • scripts/test-stealth.js now reports warn-row labels for sannysoft, not just failure-row labels. Previously a cell moving from passedwarn 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[].

  • 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.

  • Additional headless-mode spoofs in lib/fingerprint.js (all inside applyFingerprintProtection, gated on siteConfig.fingerprint_protection):

    • 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.).
    • 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.
    • 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 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.

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), 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.

Stealth hardening (toString masking)

  • 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].
  • 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.
  • 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).

Fixed

  • 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.

Performance

  • 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).