Skip to content
Cascading Labs QScrape VoidCrawl Yosoi

Stealth Mode

VoidCrawl uses a minimal-footprint stealth strategy inspired by undetected-chromedriver, zendriver, and nodriver. Stealth is enabled by default. The guiding rule: present a real browser consistently — don’t fake things.

At a glance

LayerWhat VoidCrawl doesWhy
Launch flagsDrop chromiumoxide’s --enable-automation / --disable-extensions; add --disable-blink-features=AutomationControlled + zendriver flagsThe automation signal is in the flags, not JS. AutomationControlled makes navigator.webdriver a native false — we do not patch it in JS.
No JS injectionaddScriptToEvaluateOnNewDocument is emptyEvery injected script is itself a fingerprint. We patch nothing in page-world JS.
UA / Client HintsReal UA (any Headless token stripped), with navigator.platform + userAgentData derived from that UA so they agreeA Linux UA with platform === "Win32", or empty brands, is a bot tell.
GPU--headless=new + ANGLE + --disable-gpu-sandbox -> hardware WebGLLegacy headless renders WebGL with SwiftShader (software) — a strong bot signal.

Against managed Cloudflare Turnstile (the hard case): headful passes non-interactively (verified server-side, siteverify success:true, interactive:false); headless is still gated. Every default below is overridable — see Overriding the defaults.

Philosophy: less is more

Most browser automation tools try to spoof every fingerprint signal — fake plugins, fake WebGL, fake user-agent strings. This backfires against modern WAFs (Akamai, Cloudflare, PerimeterX) because:

  1. Spoofed values are inconsistent. A hardcoded Chrome/131 user-agent on a system running Chromium 148 is an instant flag. A fake WebGL renderer that doesn’t match the real GPU is trivially detected.

  2. The spoofing itself is detectable. Each Page.addScriptToEvaluateOnNewDocument CDP call is a fingerprint. Overriding navigator.plugins with a Proxy/getter behaves differently from the real PluginArray prototype.

  3. The automation signal isn’t in JS — it’s in Chrome’s launch flags. chromiumoxide’s default flags include --enable-automation, which tells every WAF “I’m automated” before any page loads.

VoidCrawl’s approach: don’t fake anything. Launch with clean flags, let Chrome report its real values, and only ensure those values are internally consistent.

Lesson learned the hard way

VoidCrawl used to inject two JS patches — and both were removed because they hurt:

  • Deleting navigator.webdriver made it undefined. Real Chrome reports false; undefined is itself the tell.
  • Force-opening shadow DOMs broke Cloudflare Turnstile, which renders its challenge in a closed shadow root and tamper-checks it. Forcing it open failed the challenge with ERROR 600010.

VoidCrawl injects zero page-world JS today.

The automation signal is in the launch flags

After disable_default_args() (which strips chromiumoxide’s toxic defaults) VoidCrawl re-adds a curated set.

Removed (toxic flags)

FlagWhy it’s bad
--enable-automationLiterally opts in to automation detection
--disable-extensionsNormal Chrome always has extensions support

Added (zendriver flags)

FlagPurpose
--disable-blink-features=AutomationControlledRemoves the automation-controlled blink feature -> navigator.webdriver is a native false
--disable-features=IsolateOrigins,site-per-process,TranslateUIDisables isolation/UI signals WAFs fingerprint on
--no-pings, --disable-component-update, --disable-session-crashed-bubble, --disable-search-engine-choice-screen, --homepage=about:blankSuppress automation-ish background behavior and UI

Plus the safe noise-reducers (--disable-background-networking, --disable-breakpad, --disable-dev-shm-usage, --no-first-run, …).

UA / platform / Client-Hints consistency

The only override VoidCrawl applies is via CDP Emulation.setUserAgentOverride (not page-world JS). It probes the browser’s real UA, strips any Headless token, and derives a coherent identity from that one string so UA, navigator.platform, and navigator.userAgentData all agree:

SignalValue (for a real Linux/Chrome UA)
navigator.userAgentreal build, HeadlessChrome -> Chrome
navigator.platformLinux x86_64 (Win32 / MacIntel for those UAs)
Sec-CH-UA-PlatformLinux / Windows / macOS
userAgentData.brands / fullVersionListChromium / Google Chrome at the UA’s version + a GREASE entry

A mismatch here — e.g. a hardcoded platform: "Win32" on a Linux UA, or empty brands — is itself a bot signal. Both are flagged by the rebrowser bot-detector, and now pass clean.

GPU acceleration (hardware WebGL)

A headless browser that renders WebGL with SwiftShader (Chrome’s software fallback) advertises itself: WEBGL_debug_renderer_info returns "ANGLE (... SwiftShader ...)", which Cloudflare and others weigh as “no real GPU -> likely a bot/VM.”

VoidCrawl forces hardware rendering:

  • --headless=new — the legacy --headless forces SwiftShader; the new mode runs the full browser stack and can use a real GPU.
  • --use-angle=vulkan + --enable-gpu + --ignore-gpu-blocklist — route WebGL through ANGLE on the real GPU.
  • --disable-gpu-sandbox — lets the GPU process reach the DRM render node. This is the lever — on a host with a working driver it’s usually all you need.

The defaults are vendor-generic: ANGLE uses whatever Intel/AMD/NVIDIA driver the machine has. Verified on AMD, the renderer becomes ANGLE (AMD, Vulkan ... RADV ...), radv) — hardware, not SwiftShader.

GPU in Docker

Inside a container, hardware GPU also needs Mesa drivers in the image and /dev/dri passthrough — see Docker. Without a GPU passed through, the container falls back to SwiftShader. NVIDIA needs the nvidia-container-toolkit instead of /dev/dri.

What VoidCrawl does not touch

It injects no page-world JS, and leaves these alone:

SignalWhy
navigator.webdriverThe launch flag already yields a native false. A JS patch (delete -> undefined, or a redefined getter) is itself detectable.
navigator.pluginsReal Chrome populates it; faking creates inconsistencies.
navigator.userAgentReal UA (Headless stripped) — no version mismatch.
WebGL vendor/rendererThe real (hardware) GPU string beats any fake.
window.chrome.runtime, navigator.permissions, canvasDefault behavior is already correct; spoofing adds detectable noise.
Shadow DOM modeWe do not force-open it (it broke Turnstile). Clicking a challenge widget works via real compositor clicks at pixel coordinates regardless of shadow mode.

Headful vs headless (and managed Turnstile)

WAF and managed-Turnstile targets require headful mode

Headless Chrome has fundamental differences sophisticated WAFs detect regardless of any patch:

  • Different rendering/compositing pipeline.
  • Missing / non-default screen, media, and input properties.
  • A lower managed-challenge score.

For Akamai, Cloudflare managed Turnstile, and similar, use headful via Docker & VNC.

Verified against managed Cloudflare Turnstile with a real sitekey (server-side siteverify):

ModeOutcome
HeadfulPass, non-interactive (success:true, interactive:false)
HeadlessStalls at before-interactive; no token
from voidcrawl import BrowserConfig, BrowserPool, PoolConfig
# WAF / managed-Turnstile targets -- use headful
config = PoolConfig(browser=BrowserConfig(headless=False))
async with BrowserPool(config) as pool:
async with pool.acquire() as tab:
await tab.navigate("https://waf-protected-site.com")
await tab.wait_for_network_idle(timeout=15.0)
html = await tab.content()

For a headless farm that still needs to clear Turnstile, run the headful GPU container (Docker headful) rather than headless.

Overriding the defaults

Every default flag is overridable — force a GPU backend, disable acceleration, add a proxy bypass. Caller extra_args are merged by switch key: a caller value replaces the matching default (VoidCrawl does not emit duplicate switches, since Chrome’s per-switch precedence is inconsistent).

from voidcrawl import BrowserConfig
# Force software rendering (e.g. to compare, or on a GPU-less box):
BrowserConfig(extra_args=["--use-angle=swiftshader"])
# Disable the GPU entirely:
BrowserConfig(extra_args=["--disable-gpu"])

The same extra_args flow through the MCP server and pool config.

Waiting for readiness (event-driven)

JS-heavy sites and challenge pages aren’t ready at page load. Instead of a blind sleep(), use an event-driven wait — no polling:

async with pool.acquire() as tab:
await tab.navigate(url)
# Chrome's networkIdle lifecycle event (returns event name, or None on timeout):
await tab.wait_for_network_idle(timeout=15.0)
# ...or wait for a specific element via an in-page MutationObserver:
await tab.wait_for_selector("#results", timeout=15.0)

networkIdle fires after zero in-flight requests for 500ms — but WebSockets, SSE/long-polling, analytics beacons, and lazy-loading keep the network active, so on many SPAs it never fires. Prefer wait_for_selector("<the element you actually care about>") for those.

Disabling Stealth

from voidcrawl import BrowserConfig, BrowserSession
async with BrowserSession(BrowserConfig(stealth=False)) as session:
page = await session.new_page("https://trusted-site.com")

Note that the clean launch flags (including the GPU and anti-automation set) apply regardless of stealth; the toggle only governs the per-page UA/Client-Hints override.

Real-World Results

TargetApproachResult
Akamai WAF (BusinessWire)chromiumoxide defaults (--enable-automation)403 Access Denied
Akamai WAF (BusinessWire)+ heavy JS spoofing + fake UA403 Access Denied
Akamai WAF (BusinessWire)clean flags + real UA, no JS injectionSuccess (600K chars)
Managed Cloudflare Turnstile (real sitekey)headful, hardware GPU, consistent UA, no JSPass (siteverify success:true)
Managed Cloudflare TurnstileheadlessGated (before-interactive)

The lesson, twice over: the flags plus a consistent real browser matter more than JS patches — and a wrong JS patch is worse than none.

FAQs

Will stealth mode bypass all bot detection?

No. VoidCrawl (headful) clears common WAFs and managed Cloudflare Turnstile. Headless is still gated by managed Turnstile, and protections relying on IP reputation, residential-only access, or interactive CAPTCHAs may still require a proxy or a human.

Does stealth add latency?

Negligible. There is no per-tab JS injection anymore; the only per-tab step is a single CDP setUserAgentOverride. Flag changes are applied at Chrome launch with zero runtime cost.

Why not use a custom user-agent?

A custom UA that doesn’t match the actual Chrome version is an instant detection signal. VoidCrawl uses Chrome’s real user-agent (stripping only any Headless token) and makes navigator.platform + Client Hints agree with it.

References

zendriver. cdpdriver. Async Chrome automation with stealth, successor to undetected-chromedriver. https://github.com/cdpdriver/zendriver

nodriver. ultrafunkamsterdam. Predecessor to zendriver. https://github.com/ultrafunkamsterdam/nodriver

undetected-chromedriver. ultrafunkamsterdam. Patched ChromeDriver to bypass bot detection. https://github.com/ultrafunkamsterdam/undetected-chromedriver

rebrowser bot-detector. CDP/automation leak test harness used to verify the fingerprint. https://bot-detector.rebrowser.net/