2026-09-09 13:56:28 +02:00
# 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. |
2026-09-10 01:58:55 +02:00
| `--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. |
2026-09-09 13:56:28 +02:00
## 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
2026-09-10 01:58:55 +02:00
`default` - a mismatch a real browser never produces. The query is
2026-09-09 13:56:28 +02:00
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
2026-09-10 01:58:55 +02:00
array reassigned `PluginArray.prototype` - including
2026-09-09 13:56:28 +02:00
`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` )
2026-09-10 01:58:55 +02:00
containing the literal string `SwiftShader` - an unambiguous headless
2026-09-09 13:56:28 +02:00
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
2026-09-10 01:58:55 +02:00
created, same-origin iframe rather than the top-level document - since
2026-09-09 13:56:28 +02:00
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
2026-09-10 01:58:55 +02:00
instead of the native-code marker - a check used by several stealth-
2026-09-09 13:56:28 +02:00
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
2026-09-10 01:58:55 +02:00
- The `context.request` fast path negotiates HTTP/1.1, not HTTP/2 - a
2026-09-09 13:56:28 +02:00
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.