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.

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 (returns "Google Inc. (Intel)" / "ANGLE (Intel, Mesa Intel(R) UHD Graphics, OpenGL 4.6)" by default).
  • 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 + --lang flag
    .timezone("America/New_York");

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

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.

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.