A Stealthii instance binds its lock, browser, and contexts to whichever asyncio event loop is running the first time it is used. Using it again from a different loop previously hung forever (the lock/semaphore/ Playwright transport are all bound to the dead first loop and never wake the waiting coroutine). Detected in rsearch's test suite, where pytest-asyncio's default per-test-function event loop broke the module-level Stealthii singleton shared across tests. Now raises StealthUnavailableError immediately with an actionable message. README documents the constraint and the pytest-asyncio config fix (asyncio_default_test_loop_scope = session). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0116i7dYLNZTXNR7Lqf439bV
6.8 KiB
Stealth Patches
retoor <retoor@molodetz.nl>
This document enumerates every modification stealthii applies to the
underlying Chromium/Playwright stack and the rationale for each. It is
maintained as living documentation: any change to stealth_js.py or the
launch arguments in client.py must be reflected here in the same commit.
Launch-level patches
Applied in Stealthii._ensure_browser_locked() at browser launch.
| Flag | Effect |
|---|---|
--disable-blink-features=AutomationControlled |
Removes the navigator.webdriver flag and associated automation-only code paths at the engine level, before any JS patch runs. |
ignore_default_args=['--enable-automation'] |
Playwright passes --enable-automation to Chromium by default, which enables the automation infobar and related detectable behaviour. Suppressed. |
--no-sandbox |
Required to run headless Chromium as a non-root, unprivileged process in this deployment environment. Not a stealth measure. |
--disable-dev-shm-usage |
Avoids /dev/shm exhaustion under containerized/limited-memory environments. Not a stealth measure. |
--disk-cache-size=<bytes> / --media-cache-size=<bytes> |
Bounds Chromium's own on-disk HTTP/media cache, configurable via disk_cache_bytes (default near-zero). Not a stealth measure - an operational safeguard against unbounded disk growth in a long-lived process. |
JS-level patches
Applied via context.add_init_script(STEALTH_INIT_JS) on every context
before any page script runs, so the patched state is present from the very
first document, including cross-origin iframes.
navigator.webdriver
Redefined to return undefined. This flag is true on any
CDP-automated Chromium and is the single most widely checked automation
signal.
window.chrome
A stock headless Chromium launched via Playwright does not expose
window.chrome at all, unlike a real Chrome install. When absent, a
window.chrome object is constructed with runtime, loadTimes, csi,
and app members shaped like the real object's public surface.
navigator.permissions.query
A stock headless Chromium reports denied for a notifications
permission query while Notification.permission itself still reports
default - a mismatch a real browser never produces. The query is
patched to always return the same value as Notification.permission
for that one permission name; all other permission names are passed
through to the original implementation unmodified.
navigator.plugins / navigator.mimeTypes
A stock headless Chromium reports an empty plugin list and a
navigator.plugins value that is a plain array rather than a
PluginArray. Five entries matching Chrome's real built-in PDF-plugin
set (PDF Viewer, Chrome PDF Viewer, Chromium PDF Viewer,
Microsoft Edge PDF Viewer, WebKit built-in PDF) are constructed, each
individually reassigned the real Plugin.prototype, and the containing
array reassigned PluginArray.prototype - including
Symbol.toStringTag, so both a property count check and a
Object.prototype.toString.call(...) type check pass. navigator.mimeTypes
is patched the same way for the corresponding application/pdf MIME type.
navigator.deviceMemory, navigator.hardwareConcurrency
Set to 8 each. Present mainly for consistency with the spoofed WebGL
GPU (below) and a realistic desktop profile; a stock headless Chromium
already reports plausible values here in most cases.
WebGLRenderingContext.prototype.getParameter / WebGL2RenderingContext.prototype.getParameter
A stock headless Chromium renders WebGL through SwiftShader (a software
rasterizer), which reports UNMASKED_VENDOR_WEBGL (constant 37445) as
Google Inc. (Google) and UNMASKED_RENDERER_WEBGL (constant 37446)
containing the literal string SwiftShader - an unambiguous headless
signal checked by essentially every fingerprinting script. Both
constants are intercepted and rewritten to report a real discrete GPU
(Google Inc. (NVIDIA) / an ANGLE-wrapped NVIDIA GeForce RTX 3060
string); every other parameter is passed through unmodified.
HTMLIFrameElement.prototype.contentWindow
A known secondary check reads window.chrome from inside a freshly
created, same-origin iframe rather than the top-level document - since
each document gets its own JS globals, an iframe's window.chrome can
be absent even when the top-level one was patched. The contentWindow
getter is wrapped so that, whenever the returned window is missing
chrome, it is assigned from the outer patched value before being
returned.
Function.prototype.toString spoofing on every patch above
Every function or getter installed by the patches above is wrapped in a
Proxy whose own toString is overridden to return the exact string a
real native function produces (function name() { [native code] }).
Without this, a page can distinguish a patched native method from a real
one by calling .toString() on it and observing actual JavaScript source
instead of the native-code marker - a check used by several stealth-
detection scripts specifically to unmask other stealth patches.
Verification
Patches are verified against
bot.sannysoft.com's full detection table
(tests/test_stealth_detection.py::test_sannysoft_detection_suite_is_clean),
run on every change. As of the current STEALTH_INIT_JS, every row on
that page passes, including WebDriver (New), Chrome (New),
Plugins is of type PluginArray, WebGL Vendor/WebGL Renderer, and
the HEADCHR_*/PHANTOM_*/SELENIUM_DRIVER probe blocks.
The get/post/put/patch/delete/head/download fast path (no
tab, context.request) is separately verified
(test_fast_path_uses_real_chromium_fingerprint) to carry a JA3/JA4
hash distinct from a plain Python aiohttp client, confirming it uses
the browser's own TLS stack rather than Python's.
Known limitations
- The
context.requestfast path negotiates HTTP/1.1, not HTTP/2 - a real Chrome page load always uses HTTP/2 against an HTTP/2-capable server. A system that cross-checks the TLS fingerprint against the negotiated protocol can detect this specific inconsistency.render()(full page navigation) does not have this limitation. - None of the above defeats IP-reputation-based rate limiting, nor the behavioural layer of systems such as DataDome or a Cloudflare managed challenge. Those require a clean source IP and, for the hardest targets, human-like interaction; stealthii's patches remove fingerprint-level tells, not behavioural ones.
- Verified against Chromium's stock headless build as shipped with Playwright at the time of writing. A Chromium upgrade can change default values the patches assume (plugin list shape, WebGL constants, permission defaults); re-run the sannysoft regression test after any Playwright/Chromium version bump.