Skip to main content

rustenium_identity/
cdp.rs

1use crate::error::IdentityError;
2use crate::identity::{Browser, Identity, Os};
3
4use crate::script;
5use crate::ua;
6
7use rustenium::browsers::chrome::browser::ChromeBrowser;
8use rustenium::browsers::cdp_browser::CdpBrowser;
9use rustenium::cdp::target_manager::InitScript;
10use rustenium_cdp_definitions::browser_protocol::emulation::commands::{
11    SetDeviceMetricsOverride, SetEmitTouchEventsForMouse, SetEmitTouchEventsForMouseConfiguration,
12    SetHardwareConcurrencyOverride, SetLocaleOverride, SetTimezoneOverride,
13    SetTouchEmulationEnabled, SetUserAgentOverride,
14};
15use rustenium_cdp_definitions::browser_protocol::emulation::types::{
16    UserAgentBrandVersion, UserAgentMetadata,
17};
18use rustenium_cdp_definitions::browser_protocol::network::commands::SetUserAgentOverride as NetworkSetUserAgentOverride;
19
20/// The GREASE brand a given Chromium major sends, and the version it carries.
21///
22/// Both rotate with the major, so any fixed pair is wrong for all but one
23/// release — `Not;A=Brand`/`24`, the value this used to hardcode, is Chrome
24/// 150's brand paired with Chrome 152's version, which is a combination no
25/// browser has ever sent. The point of a greasey brand is that a parser must
26/// tolerate junk; a *stale* one is a version claim that contradicts the version
27/// claimed everywhere else.
28///
29/// Chromium builds it by indexing two tables with the major version. Verified
30/// against Chrome 151 (`Not=A?Brand`/`99`) and Chrome for Testing 152
31/// (`Not?A_Brand`/`24`), both read off a running browser.
32fn grease(major: u16) -> (String, &'static str) {
33    const CHARS: [&str; 11] = [" ", "(", ":", "-", ".", "/", ")", ";", "=", "?", "_"];
34    const VERSIONS: [&str; 3] = ["8", "99", "24"];
35    let seed = major as usize;
36    (
37        format!(
38            "Not{}A{}Brand",
39            CHARS[seed % CHARS.len()],
40            CHARS[(seed + 1) % CHARS.len()]
41        ),
42        VERSIONS[seed % VERSIONS.len()],
43    )
44}
45
46/// Build UserAgentMetadata (Client Hints) for Chrome/Edge identities.
47fn build_client_hints(identity: &Identity) -> UserAgentMetadata {
48    let major_number = identity.browser_version.first().copied().unwrap_or(0);
49    let major = major_number.to_string();
50    let full_version = identity
51        .browser_version
52        .iter()
53        .map(|v| v.to_string())
54        .collect::<Vec<_>>()
55        .join(".");
56    let is_mobile = identity.device_model.is_some();
57
58    let (brand_name, brand_name_full) = match identity.browser {
59        Browser::Edge => ("Microsoft Edge", "Microsoft Edge"),
60        _ => ("Google Chrome", "Google Chrome"),
61    };
62
63    let (grease_brand, grease_version) = grease(major_number);
64
65    // Greasey brand first, then the branded entry, then Chromium — the order a
66    // real Chrome was measured to send. Chromium permutes the list by the same
67    // seed; that table is not replicated here, so this is one observed layout
68    // rather than a derivation.
69    let brands = vec![
70        UserAgentBrandVersion::new(&grease_brand, grease_version),
71        UserAgentBrandVersion::new(brand_name, &major),
72        UserAgentBrandVersion::new("Chromium", &major),
73    ];
74
75    let full_version_list = vec![
76        UserAgentBrandVersion::new(&grease_brand, &format!("{grease_version}.0.0.0")),
77        UserAgentBrandVersion::new(brand_name_full, &full_version),
78        UserAgentBrandVersion::new("Chromium", &full_version),
79    ];
80
81    let platform = match identity.os {
82        Os::Windows => "Windows",
83        Os::Macintosh => "macOS",
84        Os::Linux => "Linux",
85        Os::Android => "Android",
86        Os::Ios => "iOS",
87    };
88
89    // On mobile, architecture and bitness should be empty
90    let (architecture, bitness) = if is_mobile {
91        (String::new(), String::new())
92    } else {
93        (
94            identity.platform.architecture.clone().unwrap_or_else(|| "x86".into()),
95            identity.platform.bitness.clone().unwrap_or_else(|| "64".into()),
96        )
97    };
98    let model = identity.device_model.clone().unwrap_or_default();
99
100    UserAgentMetadata::builder()
101        .brands(brands)
102        .full_version_lists(full_version_list)
103        .platform(platform)
104        .platform_version(&identity.platform.version)
105        .architecture(&architecture)
106        .model(&model)
107        .mobile(is_mobile)
108        .bitness(bitness)
109        .build()
110        .unwrap()
111}
112
113/// Apply all CDP emulation commands and register the stealth script.
114pub async fn apply_identity(
115    browser: &mut ChromeBrowser,
116    identity: &Identity,
117    timezone: &str,
118) -> Result<(), IdentityError> {
119    let user_agent = ua::build_user_agent(identity)?;
120    let lang0 = identity
121        .language
122        .first()
123        .cloned()
124        .unwrap_or_else(|| "en-US".into());
125    let is_mobile = identity.device_model.is_some();
126
127    // Build client hints for Chrome/Edge (skip iOS — all iOS browsers are WebKit-based)
128    let client_hints = if matches!(identity.browser, Browser::Chrome | Browser::Edge)
129        && !matches!(identity.os, Os::Ios)
130    {
131        Some(build_client_hints(identity))
132    } else {
133        None
134    };
135
136    // Note the emulation overrides below are session-scoped: rustenium replicates
137    // them to every target automatically, so a worker's timezone, locale and UA-CH
138    // stay consistent with the page's rather than leaking the host's.
139
140    // Emulation.setUserAgentOverride
141    let mut ua_builder = SetUserAgentOverride::builder()
142        .user_agent(&user_agent)
143        .accept_language(&lang0)
144        .platform(identity.platform.navigator_platform.as_str());
145    if let Some(ref metadata) = client_hints {
146        ua_builder = ua_builder.user_agent_metadata(metadata.clone());
147    }
148    let ua_cmd = ua_builder.build().unwrap();
149
150    // `uaFullVersion` has no non-deprecated way to be set. Chrome derives it from
151    // the legacy scalar `fullVersion`, not from `fullVersionList`, and CDP dropped
152    // that field from the published protocol — so the generated UserAgentMetadata
153    // cannot express it and Chrome falls back to the *real* browser version. Left
154    // alone, getHighEntropyValues(['uaFullVersion']) reports the host's Chrome next
155    // to the persona's everywhere else.
156    //
157    // Measured: the browser still honours the field, so send it alongside.
158    let full_version = identity
159        .browser_version
160        .iter()
161        .map(|v| v.to_string())
162        .collect::<Vec<_>>()
163        .join(".");
164    let ua_extensions = serde_json::json!({
165        "userAgentMetadata": { "fullVersion": full_version }
166    });
167    browser
168        .send_command_extended(ua_cmd.into(), ua_extensions.clone())
169        .await
170        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
171
172    // Network.setUserAgentOverride (belt-and-suspenders, also with client hints)
173    let mut net_builder = NetworkSetUserAgentOverride::builder()
174        .user_agent(&user_agent)
175        .accept_language(&lang0);
176    if let Some(ref metadata) = client_hints {
177        net_builder = net_builder.user_agent_metadata(metadata.clone());
178    }
179    let net_ua_cmd = net_builder.build().unwrap();
180    // Carries its own copy of the metadata, so it needs the same extension —
181    // otherwise it lands second and resets `fullVersion` to the real version.
182    browser
183        .send_command_extended(net_ua_cmd.into(), ua_extensions.clone())
184        .await
185        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
186
187    // Emulation.setDeviceMetricsOverride — only spoof display dimensions for mobile
188    if is_mobile {
189        let screen_width = (identity.screen.logical_width as f64 * identity.screen.density_pixel_ratio as f64) as i64;
190        let screen_height = (identity.screen.logical_height as f64 * identity.screen.density_pixel_ratio as f64) as i64;
191        let device_cmd = SetDeviceMetricsOverride::builder()
192            .width(identity.screen.logical_width as i64)
193            .height(identity.screen.logical_height as i64)
194            .device_scale_factor(identity.screen.density_pixel_ratio as f64)
195            .mobile(true)
196            .screen_width(screen_width)
197            .screen_height(screen_height)
198            .build()
199            .unwrap();
200        browser
201            .send_command(device_cmd.into())
202            .await
203            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
204    }
205
206    // Emulation.setTouchEmulationEnabled
207    if identity.has_touch {
208        let touch_cmd = SetTouchEmulationEnabled::builder()
209            .enabled(true)
210            .max_touch_points(5i64)
211            .build()
212            .unwrap();
213        browser
214            .send_command(touch_cmd.into())
215            .await
216            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
217
218        // Emulation.setEmitTouchEventsForMouse — translate the automation's mouse
219        // input into synthetic touch events so a touch device receives touchstart/
220        // move/end (as a real finger would) instead of mouse events.
221        let configuration = if is_mobile {
222            SetEmitTouchEventsForMouseConfiguration::Mobile
223        } else {
224            SetEmitTouchEventsForMouseConfiguration::Desktop
225        };
226        let emit_touch_cmd = SetEmitTouchEventsForMouse::builder()
227            .enabled(true)
228            .configuration(configuration)
229            .build()
230            .unwrap();
231        browser
232            .send_command(emit_touch_cmd.into())
233            .await
234            .map_err(|e| IdentityError::CdpError(e.to_string()))?;
235    }
236
237    // Emulation.setLocaleOverride
238    let locale_cmd = SetLocaleOverride::builder()
239        .locale(&lang0)
240        .build();
241    browser
242        .send_command(locale_cmd.into())
243        .await
244        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
245
246    // Emulation.setTimezoneOverride
247    let tz_cmd = SetTimezoneOverride::builder()
248        .timezone_id(timezone)
249        .build()
250        .unwrap();
251    browser
252        .send_command(tz_cmd.into())
253        .await
254        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
255
256    // Emulation.setHardwareConcurrencyOverride
257    let hc_cmd = SetHardwareConcurrencyOverride::builder()
258        .hardware_concurrency(identity.hardware_concurrency as i64)
259        .build()
260        .unwrap();
261    browser
262        .send_command(hc_cmd.into())
263        .await
264        .map_err(|e| IdentityError::CdpError(e.to_string()))?;
265
266    // One registration covers every realm. The target manager applies it to each
267    // target already attached and to every one that appears later, installing it
268    // while the target is still frozen — before it has run a statement.
269    //
270    // This is what reaches workers and cross-origin iframes. They are separate
271    // targets with their own realms, so without it they report the host's real
272    // values while the page reports the persona, and comparing the two is a
273    // one-line detection. Service workers especially: they belong to no tab, so
274    // only a browser-scoped registration can reach them at all.
275    //
276    // Two sources because a worker realm has no window or document and uses
277    // WorkerNavigator, so the document script throws on its first statement there.
278    browser
279        .add_init_script(InitScript {
280            page: Some(script::build_stealth_script(identity)),
281            worker: Some(script::build_worker_script(identity)),
282        })
283        .await;
284    Ok(())
285}
286
287#[cfg(test)]
288mod tests {
289    use super::grease;
290
291    /// Both pairs were read off a running browser. They are the evidence that
292    /// the brand is derived from the major rather than fixed, and they pin the
293    /// two tables: change either and these stop matching what Chrome sends.
294    #[test]
295    fn grease_matches_the_browsers_it_was_measured_against() {
296        assert_eq!(grease(151), ("Not=A?Brand".to_string(), "99"));
297        assert_eq!(grease(152), ("Not?A_Brand".to_string(), "24"));
298    }
299
300    /// The value this used to hardcode belongs to 150, paired with 152's
301    /// version — a combination no release has sent.
302    #[test]
303    fn the_old_hardcoded_pair_was_never_a_real_release() {
304        let (brand, version) = grease(150);
305        assert_eq!(brand, "Not;A=Brand");
306        assert_ne!(version, "24");
307    }
308}