Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Stealth

zendriver-rs ships with three stealth profiles selecting different tradeoffs between launch overhead, detectability, and CSP compatibility. Pick the profile that matches your target site's detection layer; tweak the fingerprint with builder methods when you need to pin a specific identity.

None of these profiles is a guarantee of invisibility — no automation stack has one against a determined, adaptively-defended site. What they provide is a coherent, plausible browser identity plus explicit controls over the specific tells that mainstream fingerprint checks probe for.

The three profiles

ProfileLaunch flagsUA scrubEmulation overridesJS bootstrapBypass CSPUse case
off()nonenonononenoReproducing issues in vanilla Chrome.
native()yesyesyesnonenoMost sites. Default recommendation.
spoofed()yesyesyesNavigator JSyes (on)Sites with active fingerprint detection.

StealthProfile::off()

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let browser = Browser::builder()
    .stealth(StealthProfile::off())
    .launch()
    .await?;
Ok(()) }
}

No launch flags. No UA scrub. No CDP overrides. Page.setBypassCSP is not called. This is what you get from a stock chromiumoxide launch. Use this when you're debugging whether a bug reproduces in a vanilla Chrome — if it does, the cause is unrelated to zendriver's stealth machinery.

StealthProfile::native()

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let browser = Browser::builder()
    .stealth(StealthProfile::native())
    .launch()
    .await?;
Ok(()) }
}

The default recommendation. Patches the layer that protocol-level fingerprinters see, without touching JS object prototypes:

  • Launch flags--disable-blink-features=AutomationControlled, --disable-features=IsolateOrigins,site-per-process (toggleable), and a curated list of flags that turn off the "this browser is controlled by automation" infobar plus various leaks.
  • UA scrub — strips the HeadlessChrome segment from the User-Agent string and the Sec-CH-UA brand list.
  • Emulation overridesEmulation.setUserAgentOverride / Emulation.setHardwareConcurrencyOverride / Emulation.setDeviceMetricsOverride set a coherent identity.

Safe against Function.prototype.toString detection because it patches nothing at the JS level — there's no [native code] mismatch to detect. Passes most consumer site detectors. Doesn't pass sannysoft's deeper Navigator-prototype checks.

StealthProfile::spoofed()

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let browser = Browser::builder()
    .stealth(StealthProfile::spoofed())
    .launch()
    .await?;
Ok(()) }
}

native() plus Navigator-prototype JS patches injected via Page.addScriptToEvaluateOnNewDocument. Restores or overrides:

  • navigator.webdriver (deletes it so 'webdriver' in navigator is false).
  • navigator.permissions.query({ name: "notifications" }) (returns "prompt" instead of the headless-Chrome "denied").
  • navigator.plugins + navigator.mimeTypes (returns plausible-length arrays).
  • navigator.chrome (installs the runtime object headless Chrome doesn't ship).
  • WebGL vendor / renderer, plus every other readable WebGL value, resolved from one capability tier (defaults to a captured Apple Metal device: "Google Inc. (Apple)" / "ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Pro, Unspecified Version)"). The WebGPU adapter is derived from that same renderer string, so the two APIs never name different GPUs.
  • ChunkSplit + iframe-contentWindow guards so the patches survive cross-realm escape attempts.

Toggles Page.setBypassCSP on by default so the bootstrap script can install on pages with strict CSP headers. Pass .bypass_csp(false) to opt out when you want to test against a real CSP-restricted page.

Passes sannysoft, areyouheadless, and most active detectors. Pays a small per-navigation cost (the JS bootstrap runs on every new document).

Customizing the fingerprint

All three profiles return a builder that lets you override individual fingerprint fields. The values are validated and clamped at resolve time (e.g. memory_gb is clamped to a plausible W3C-rounded value; cpu_count is clamped to 2..=32).

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};
use zendriver::stealth::Platform;

async fn ex() -> zendriver::Result<()> {
let profile = StealthProfile::spoofed()
    .memory_gb(8)               // navigator.deviceMemory
    .cpu_count(8)               // navigator.hardwareConcurrency
    .chrome_version(126)        // Chrome major in UA + Sec-CH-UA
    .platform(Platform::Win32)  // navigator.platform + OS in UA
    .locale("en-US")            // navigator.language + Accept-Language + --lang
    .timezone("America/New_York");

let browser = Browser::builder()
    .stealth(profile)
    .launch()
    .await?;
Ok(()) }
}

locale alone is usually enough. It sets the --lang launch flag, the JS-visible locale that navigator.language and Intl read, and an Accept-Language derived from it (en-US gives en-US,en;q=0.9). Reach for .languages([...]) when you need the header to advertise a longer or differently-weighted list. Its first entry then becomes navigator.language as well, because Chrome always reports that as navigator.languages[0], and a locale sitting outside the advertised list is a mismatch no real browser produces.

A Persona carries the same timezone / locale / languages / screen fields, and whichever ones it sets take precedence over the profile's. Anything it leaves unset falls through to the values above, so a persona can pin one axis without restating the rest.

You can also override the User-Agent string verbatim — useful when you need an exact UA that doesn't match the auto-composed one:

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let profile = StealthProfile::native()
    .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ...");

let browser = Browser::builder()
    .stealth(profile)
    .launch()
    .await?;
Ok(()) }
}

user_agent() skips the auto-composition step entirely — prefer platform() + chrome_version() unless you need a bit-for-bit specific UA.

Opting into real site isolation + real WebGL (native_isolation)

native() and spoofed() both disable Chrome's render-process site isolation (--disable-features=IsolateOrigins,...,site-per-process), and spoofed() additionally patches WebGLRenderingContext.getParameter() /getSupportedExtensions() to report a coherent GPU identity — every readable parameter resolved from one capability tier, defaulting to an Apple Metal device — regardless of the host's actual GPU. .native_isolation(true) opts a profile out of both:

#![allow(unused)]
fn main() {
use zendriver::{Browser, StealthProfile};

async fn ex() -> zendriver::Result<()> {
let profile = StealthProfile::spoofed().native_isolation(true);

let browser = Browser::builder()
    .stealth(profile)
    .launch()
    .await?;
Ok(()) }
}

With this set:

  • The launch flags omit IsolateOrigins/site-per-process from --disable-features=... — Chrome runs with its normal render-process isolation boundary. (The unrelated DisableLoadExtensionCommandLineSwitch feature name stays disabled either way — it controls --load-extension, not isolation.)
  • For spoofed(), the bootstrap script omits the WebGL vendor/renderer patch entirely — getParameter(UNMASKED_VENDOR_WEBGL) etc. return the host's real values instead of the spoofed default.

This is a trade-off, not a strict stealth improvement. The defaults it opts out of exist as anti-detection measures: the WebGL patch in particular is an anti-WAF coherence defense — some WAFs (Imperva/Incapsula) cross-check the WebGL identity against the rest of the fingerprint and flag a real, host-specific renderer string as a bot tell when it doesn't match. Reach for native_isolation(true) when you need the host's actual GPU behavior (WebGL-heavy rendering, screenshot fidelity, visual regression testing) or want Chrome's stock process-isolation security boundary, and evasion isn't the priority — not because it's "more stealthy." It isn't; it removes a defense.

It's off by default on every profile, so existing native() / spoofed() callers see no behavior change unless they opt in explicitly.

WebGL and WebGPU stay coherent. When native_isolation drops the WebGL patch, the WebGPU value adapter spoof (navigator.gpu, driven by the Persona webgpu surface) is skipped along with it, so navigator.gpu reports the real host adapter instead of one derived from a renderer the WebGL patch no longer applies — no cross-API mismatch. The same holds for the per-surface Strategy::Native on Webgl, which leaves the real renderer in place for one persona rather than the whole profile. An explicit Webgpu Block (hiding navigator.gpu) is renderer-neutral, so it is still honored if you set it.

End-to-end example

This example launches with a custom UA, locale, and platform, then reads them back via navigator.* to prove the overrides took:

//! Port of `zendriver/examples/set_user_agent.py`.
//!
//! Launch Chrome with a custom User-Agent, locale, and platform configured
//! via [`StealthProfile`], then read them back via `navigator.*` to verify
//! the override took effect.
//!
//! Python `tab.set_user_agent("...", accept_language="de", platform="Win32")`
//! is a single-call helper that internally drives
//! `Emulation.setUserAgentOverride`. zendriver-rs lifts that into the
//! `StealthProfile` builder because the launcher already wires UA overrides
//! through `StealthObserver`, so per-tab mutation has no equivalent yet.
//! Setting the UA at launch matches the spec's "no JS-visible drift between
//! launch and first frame" stealth property.
//!
//! `navigator.platform` reads as `Win32` once `Platform::Win32` is set.

use zendriver::Browser;
use zendriver::stealth::{Platform, StealthProfile};

#[tokio::main]
#[allow(clippy::result_large_err)] // example boundary; users wrap in their own Error
async fn main() -> zendriver::Result<()> {
    tracing_subscriber::fmt::init();

    let profile = StealthProfile::native()
        .user_agent("My user agent")
        .locale("de")
        .platform(Platform::Win32);

    let browser = Browser::builder()
        .headless(true)
        .stealth(profile)
        .launch()
        .await?;
    let tab = browser.main_tab();
    tab.goto("https://example.com").await?;
    tab.wait_for_load().await?;

    let ua: String = tab.evaluate("navigator.userAgent").await?;
    let lang: String = tab.evaluate("navigator.language").await?;
    let platform: String = tab.evaluate("navigator.platform").await?;

    println!("{ua}"); // My user agent
    println!("{lang}"); // de
    println!("{platform}"); // Win32

    browser.close().await?;
    Ok(())
}

Expected output:

My user agent
de
Win32

The override surface is intentionally narrow — anything that would let you set incoherent values (e.g. Linux UA + navigator.platform = Win32) goes through the Fingerprint resolver, which composes a coherent identity.

When to use which

  • Headless scraping of public sites — start with native(). Most sites don't actively probe Navigator prototypes; the cheaper profile is plenty.
  • Sites with active bot detection (Cloudflare, PerimeterX, DataDome, Akamai) — use spoofed(). Pair with the cloudflare Cargo feature when you specifically need Turnstile bypass — see Cloudflare.
  • Sites with strict CSPspoofed() defaults to bypass_csp = true, which is normally what you want. If you're testing a real CSP, override it with .bypass_csp(false) (and expect the JS bootstrap to fail to install).
  • Sites that read Function.prototype.toString looking for [native code] mismatchesnative() rather than spoofed(), because spoofed()'s prototype patches leave detectable fingerprints in the function-source readouts. zendriver's bootstrap papers over the obvious patches, but a determined adversary will still find drift.