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
| Profile | Launch flags | UA scrub | Emulation overrides | JS bootstrap | Bypass CSP | Use case |
|---|---|---|---|---|---|---|
off() | none | no | no | none | no | Reproducing issues in vanilla Chrome. |
native() | yes | yes | yes | none | no | Most sites. Default recommendation. |
spoofed() | yes | yes | yes | Navigator JS | yes (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
HeadlessChromesegment from the User-Agent string and the Sec-CH-UA brand list. - Emulation overrides —
Emulation.setUserAgentOverride/Emulation.setHardwareConcurrencyOverride/Emulation.setDeviceMetricsOverrideset 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 navigatorisfalse).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 thecloudflareCargo feature when you specifically need Turnstile bypass — see Cloudflare. - Sites with strict CSP —
spoofed()defaults tobypass_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.toStringlooking for[native code]mismatches —native()rather thanspoofed(), becausespoofed()'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.