Files
stealthii/STEALTH_PATCHES.md
T
retoorandClaude Sonnet 5 423170e1bb Fail fast with a clear error on event loop reuse instead of hanging
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
2026-09-10 01:58:55 +02:00

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.request fast 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.