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
133 lines
6.8 KiB
Markdown
133 lines
6.8 KiB
Markdown
# 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](https://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.
|