Files
stealthii/STEALTH_PATCHES.md
T
retoorandClaude Sonnet 5 86c3a95cb8 Add PUT/PATCH/DELETE/HEAD, full aiohttp/httpx param parity, screenshots,
async-with tab claiming, and full documentation

- get/post/put/patch/delete/head now accept the same request shape as
  aiohttp/httpx: data/json/files/cookies/auth/allow_redirects/
  follow_redirects/max_redirects, plus the existing session= for named
  cookie-persisting contexts.
- screenshot(url, path=None) captures a PNG, saved to disk or returned
  as base64.
- `async with stealth as page` claims a tab directly, task-safe for
  concurrent use on one shared instance (keyed off asyncio.current_task,
  not instance state).
- Strip all docstrings/comments from the source (author line excepted),
  per house style.
- README.md rewritten as the complete documentation: rationale, JA3/JA4
  comparison, dependencies, construction/laziness/lifetime, every method
  with examples, named sessions, configuration, error handling, testing.
- STEALTH_PATCHES.md: a literal, patch-by-patch account of every launch
  argument and JS patch applied, with rationale and verification method.
- 20 new tests: full HTTP-method coverage, cookies, auth, multipart,
  download, render, screenshot, named-session isolation, retry/error
  paths, and guaranteed page/context cleanup under exception.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0116i7dYLNZTXNR7Lqf439bV
2026-09-09 13:56:28 +02:00

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.