Skip to main content

browser_commander/fingerprint/
cdp_overrides.rs

1//! Translate a fingerprint profile into CDP `Emulation` commands.
2//!
3//! These are the overrides Chrome itself enforces. They apply to workers and to
4//! outgoing HTTP headers, not only to the main world, which is what makes them
5//! strictly better than patching JavaScript properties. Anything that has no
6//! command here needs a page init script instead; [`super::init_script`]
7//! carries the weaker half and `docs/case-studies/issue-79/requirements.md`
8//! records why.
9//!
10//! This is the Rust side of `js/src/fingerprint/cdp-overrides.js`; the command
11//! list is asserted field by field in every language so the three cannot drift.
12
13use serde::Serialize;
14use serde_json::{json, Map, Value};
15
16use super::profile::{FingerprintProfile, UserAgentData};
17
18/// One protocol call: a method name and its parameters.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
20pub struct CdpCommand {
21    /// The CDP method, for example `Emulation.setUserAgentOverride`.
22    pub method: &'static str,
23    /// The parameters, shaped exactly as the protocol expects them.
24    pub params: Value,
25}
26
27impl CdpCommand {
28    fn new(method: &'static str, params: Value) -> Self {
29        Self { method, params }
30    }
31}
32
33fn insert_if_some<T: Serialize>(params: &mut Map<String, Value>, key: &str, value: &Option<T>) {
34    if let Some(value) = value {
35        params.insert(key.to_string(), json!(value));
36    }
37}
38
39fn user_agent_metadata(data: &UserAgentData) -> Value {
40    // platform, platformVersion, architecture, model and mobile are required by
41    // the protocol; Chrome rejects the command when any of them is missing.
42    let mut metadata = Map::new();
43    metadata.insert(
44        "platform".to_string(),
45        json!(data.platform.clone().unwrap_or_default()),
46    );
47    metadata.insert(
48        "platformVersion".to_string(),
49        json!(data.platform_version.clone().unwrap_or_default()),
50    );
51    metadata.insert(
52        "architecture".to_string(),
53        json!(data.architecture.clone().unwrap_or_default()),
54    );
55    metadata.insert(
56        "model".to_string(),
57        json!(data.model.clone().unwrap_or_default()),
58    );
59    metadata.insert("mobile".to_string(), json!(data.mobile.unwrap_or(false)));
60    insert_if_some(&mut metadata, "brands", &data.brands);
61    insert_if_some(&mut metadata, "fullVersionList", &data.full_version_list);
62    insert_if_some(&mut metadata, "bitness", &data.bitness);
63    // Deprecated in the protocol, but `fullVersionList` does not cover the
64    // `uaFullVersion` hint: without this the page still reads the real Chrome
65    // build number.
66    insert_if_some(&mut metadata, "fullVersion", &data.full_version);
67    insert_if_some(&mut metadata, "wow64", &data.wow64);
68    insert_if_some(&mut metadata, "formFactors", &data.form_factors);
69    Value::Object(metadata)
70}
71
72fn user_agent_command(profile: &FingerprintProfile) -> Option<CdpCommand> {
73    if profile.user_agent.is_none() && profile.accept_language.is_none() {
74        return None;
75    }
76    let mut params = Map::new();
77    // userAgent is a required parameter even when only the language changes.
78    params.insert(
79        "userAgent".to_string(),
80        json!(profile.user_agent.clone().unwrap_or_default()),
81    );
82    insert_if_some(&mut params, "acceptLanguage", &profile.accept_language);
83    insert_if_some(&mut params, "platform", &profile.platform);
84    if let Some(data) = &profile.user_agent_data {
85        params.insert("userAgentMetadata".to_string(), user_agent_metadata(data));
86    }
87    Some(CdpCommand::new(
88        "Emulation.setUserAgentOverride",
89        Value::Object(params),
90    ))
91}
92
93fn device_metrics(profile: &FingerprintProfile) -> Option<Value> {
94    if profile.viewport.is_none() && profile.screen.is_none() {
95        return None;
96    }
97    let viewport = profile.viewport.clone().unwrap_or_default();
98    let mut params = Map::new();
99    // 0 means "no override" for the viewport, so a profile that only sets
100    // screen dimensions still leaves the real window size alone.
101    params.insert("width".to_string(), json!(viewport.width.unwrap_or(0)));
102    params.insert("height".to_string(), json!(viewport.height.unwrap_or(0)));
103    params.insert(
104        "deviceScaleFactor".to_string(),
105        json!(viewport.device_scale_factor.unwrap_or(0.0)),
106    );
107    params.insert(
108        "mobile".to_string(),
109        json!(viewport.mobile.unwrap_or(false)),
110    );
111    // The profile guarantees width and height come as a pair, so the page never
112    // sees a screen with one dimension overridden and the other real.
113    if let Some(screen) = &profile.screen {
114        if let (Some(width), Some(height)) = (screen.width, screen.height) {
115            params.insert("screenWidth".to_string(), json!(width));
116            params.insert("screenHeight".to_string(), json!(height));
117        }
118    }
119    Some(Value::Object(params))
120}
121
122fn emulated_media_features(profile: &FingerprintProfile) -> Vec<Value> {
123    let mut features = Vec::new();
124    if let Some(value) = &profile.reduced_motion {
125        features.push(json!({"name": "prefers-reduced-motion", "value": value}));
126    }
127    if let Some(value) = &profile.forced_colors {
128        features.push(json!({"name": "forced-colors", "value": value}));
129    }
130    if let Some(value) = &profile.color_scheme {
131        features.push(json!({"name": "prefers-color-scheme", "value": value}));
132    }
133    features
134}
135
136/// Build the ordered CDP command list for a normalized profile.
137pub fn build_cdp_emulation_commands(profile: &FingerprintProfile) -> Vec<CdpCommand> {
138    let mut commands = Vec::new();
139
140    if let Some(command) = user_agent_command(profile) {
141        commands.push(command);
142    }
143
144    if let Some(timezone_id) = &profile.timezone_id {
145        commands.push(CdpCommand::new(
146            "Emulation.setTimezoneOverride",
147            json!({ "timezoneId": timezone_id }),
148        ));
149    }
150
151    if let Some(locale) = &profile.locale {
152        commands.push(CdpCommand::new(
153            "Emulation.setLocaleOverride",
154            json!({ "locale": locale }),
155        ));
156    }
157
158    if let Some(hardware_concurrency) = profile.hardware_concurrency {
159        commands.push(CdpCommand::new(
160            "Emulation.setHardwareConcurrencyOverride",
161            json!({ "hardwareConcurrency": hardware_concurrency }),
162        ));
163    }
164
165    if let Some(metrics) = device_metrics(profile) {
166        commands.push(CdpCommand::new(
167            "Emulation.setDeviceMetricsOverride",
168            metrics,
169        ));
170    }
171
172    if let Some(max_touch_points) = profile.max_touch_points {
173        commands.push(CdpCommand::new(
174            "Emulation.setTouchEmulationEnabled",
175            json!({
176                "enabled": max_touch_points > 0,
177                "maxTouchPoints": max_touch_points.max(1),
178            }),
179        ));
180    }
181
182    let features = emulated_media_features(profile);
183    if !features.is_empty() {
184        commands.push(CdpCommand::new(
185            "Emulation.setEmulatedMedia",
186            json!({ "features": features }),
187        ));
188    }
189
190    if let Some(geolocation) = &profile.geolocation {
191        commands.push(CdpCommand::new(
192            "Emulation.setGeolocationOverride",
193            json!(geolocation),
194        ));
195    }
196
197    commands
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::fingerprint::presets::create_default_fingerprint_preset;
204    use crate::fingerprint::profile::{
205        ColorScheme, ForcedColors, GeolocationProfile, ReducedMotion, ScreenProfile,
206        ViewportProfile,
207    };
208
209    const WINDOWS_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) \
210         AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.7000.55 Safari/537.36";
211
212    fn commands(profile: FingerprintProfile) -> Vec<CdpCommand> {
213        build_cdp_emulation_commands(&profile.resolve().expect("profile resolves"))
214    }
215
216    fn methods(profile: FingerprintProfile) -> Vec<&'static str> {
217        commands(profile)
218            .into_iter()
219            .map(|command| command.method)
220            .collect()
221    }
222
223    fn params(profile: FingerprintProfile, method: &str) -> Value {
224        commands(profile)
225            .into_iter()
226            .find(|command| command.method == method)
227            .unwrap_or_else(|| panic!("{method} was not sent"))
228            .params
229    }
230
231    #[test]
232    fn emits_nothing_for_an_empty_profile() {
233        assert!(commands(FingerprintProfile::default()).is_empty());
234    }
235
236    #[test]
237    fn sends_only_the_commands_the_profile_asks_for() {
238        assert_eq!(
239            methods(FingerprintProfile::default().timezone_id("Europe/Berlin")),
240            vec!["Emulation.setTimezoneOverride"]
241        );
242        assert_eq!(
243            methods(FingerprintProfile::default().hardware_concurrency(4)),
244            vec!["Emulation.setHardwareConcurrencyOverride"]
245        );
246    }
247
248    #[test]
249    fn keeps_a_stable_command_order_for_a_full_profile() {
250        let profile = FingerprintProfile::default()
251            .user_agent(WINDOWS_USER_AGENT)
252            .timezone_id("Europe/Berlin")
253            .locale("de-DE")
254            .hardware_concurrency(12)
255            .screen(ScreenProfile {
256                width: Some(2560),
257                height: Some(1440),
258                ..ScreenProfile::default()
259            })
260            .viewport(ViewportProfile {
261                width: Some(1280),
262                height: Some(720),
263                ..ViewportProfile::default()
264            })
265            .max_touch_points(0)
266            .color_scheme(ColorScheme::Dark)
267            .geolocation(GeolocationProfile {
268                latitude: 52.52,
269                longitude: 13.405,
270                accuracy: Some(20.0),
271            });
272
273        assert_eq!(
274            methods(profile),
275            vec![
276                "Emulation.setUserAgentOverride",
277                "Emulation.setTimezoneOverride",
278                "Emulation.setLocaleOverride",
279                "Emulation.setHardwareConcurrencyOverride",
280                "Emulation.setDeviceMetricsOverride",
281                "Emulation.setTouchEmulationEnabled",
282                "Emulation.setEmulatedMedia",
283                "Emulation.setGeolocationOverride",
284            ]
285        );
286    }
287
288    #[test]
289    fn supplies_the_required_empty_user_agent_when_only_the_language_changes() {
290        let params = params(
291            FingerprintProfile::default().languages(["fr-FR", "fr"]),
292            "Emulation.setUserAgentOverride",
293        );
294
295        // userAgent is a required protocol parameter; an empty string means
296        // "leave it alone" while acceptLanguage still takes effect.
297        assert_eq!(params["userAgent"], json!(""));
298        assert_eq!(params["acceptLanguage"], json!("fr-FR,fr"));
299    }
300
301    #[test]
302    fn carries_the_client_hints_including_the_deprecated_full_version() {
303        let params = params(
304            FingerprintProfile::default()
305                .user_agent(WINDOWS_USER_AGENT)
306                .platform("Win32"),
307            "Emulation.setUserAgentOverride",
308        );
309        let metadata = &params["userAgentMetadata"];
310
311        assert_eq!(params["platform"], json!("Win32"));
312        assert_eq!(metadata["platform"], json!("Windows"));
313        // fullVersionList does not cover the uaFullVersion hint, so the
314        // deprecated fullVersion field has to travel with it.
315        assert_eq!(metadata["fullVersion"], json!("140.0.7000.55"));
316        assert_eq!(metadata["bitness"], json!("64"));
317        assert_eq!(metadata["wow64"], json!(false));
318        assert_eq!(metadata["formFactors"], json!(["Desktop"]));
319    }
320
321    #[test]
322    fn always_fills_the_protocol_required_metadata_fields() {
323        let params = params(
324            FingerprintProfile::default()
325                .user_agent("custom agent")
326                .user_agent_data(UserAgentData {
327                    brands: Some(vec![crate::fingerprint::profile::BrandVersion {
328                        brand: "Custom".to_string(),
329                        version: "1".to_string(),
330                    }]),
331                    ..UserAgentData::default()
332                }),
333            "Emulation.setUserAgentOverride",
334        );
335        let metadata = params["userAgentMetadata"]
336            .as_object()
337            .expect("metadata object");
338
339        // Chrome rejects setUserAgentOverride when any of these is missing.
340        for field in [
341            "platform",
342            "platformVersion",
343            "architecture",
344            "model",
345            "mobile",
346        ] {
347            assert!(metadata.contains_key(field), "{field} must be present");
348        }
349        assert_eq!(metadata["mobile"], json!(false));
350        assert!(!metadata.contains_key("bitness"));
351    }
352
353    #[test]
354    fn leaves_the_window_size_alone_when_only_the_screen_is_described() {
355        let params = params(
356            FingerprintProfile::default().screen(ScreenProfile {
357                width: Some(2560),
358                height: Some(1440),
359                ..ScreenProfile::default()
360            }),
361            "Emulation.setDeviceMetricsOverride",
362        );
363
364        // Zeroes mean "no override" for the viewport, so a screen-only profile
365        // does not resize the window it was applied to.
366        assert_eq!(
367            params,
368            json!({
369                "width": 0,
370                "height": 0,
371                "deviceScaleFactor": 0.0,
372                "mobile": false,
373                "screenWidth": 2560,
374                "screenHeight": 1440,
375            })
376        );
377    }
378
379    #[test]
380    fn sends_the_viewport_without_screen_dimensions_when_no_screen_is_set() {
381        let params = params(
382            FingerprintProfile::default().viewport(ViewportProfile {
383                width: Some(1280),
384                height: Some(720),
385                device_scale_factor: Some(2.0),
386                mobile: Some(true),
387            }),
388            "Emulation.setDeviceMetricsOverride",
389        );
390
391        assert_eq!(
392            params,
393            json!({
394                "width": 1280,
395                "height": 720,
396                "deviceScaleFactor": 2.0,
397                "mobile": true,
398            })
399        );
400    }
401
402    #[test]
403    fn disables_touch_emulation_for_a_profile_that_names_zero_touch_points() {
404        // maxTouchPoints must stay at least 1 because the protocol rejects 0,
405        // so "no touch" is expressed through enabled instead.
406        assert_eq!(
407            params(
408                FingerprintProfile::default().max_touch_points(0),
409                "Emulation.setTouchEmulationEnabled"
410            ),
411            json!({ "enabled": false, "maxTouchPoints": 1 })
412        );
413        assert_eq!(
414            params(
415                FingerprintProfile::default().max_touch_points(5),
416                "Emulation.setTouchEmulationEnabled"
417            ),
418            json!({ "enabled": true, "maxTouchPoints": 5 })
419        );
420    }
421
422    #[test]
423    fn collects_every_media_preference_into_a_single_command() {
424        assert_eq!(
425            params(
426                FingerprintProfile::default()
427                    .color_scheme(ColorScheme::Dark)
428                    .reduced_motion(ReducedMotion::Reduce)
429                    .forced_colors(ForcedColors::Active),
430                "Emulation.setEmulatedMedia"
431            ),
432            json!({
433                "features": [
434                    { "name": "prefers-reduced-motion", "value": "reduce" },
435                    { "name": "forced-colors", "value": "active" },
436                    { "name": "prefers-color-scheme", "value": "dark" },
437                ]
438            })
439        );
440    }
441
442    #[test]
443    fn passes_geolocation_through_as_the_protocol_spells_it() {
444        assert_eq!(
445            params(
446                FingerprintProfile::default().geolocation(GeolocationProfile {
447                    latitude: 48.85,
448                    longitude: 2.35,
449                    accuracy: Some(10.0),
450                }),
451                "Emulation.setGeolocationOverride"
452            ),
453            json!({ "latitude": 48.85, "longitude": 2.35, "accuracy": 10.0 })
454        );
455    }
456
457    // A preset is the shape most callers send, so the whole command list for one
458    // is worth pinning: a field that silently stops being emulated is exactly
459    // the regression this module exists to prevent.
460    #[test]
461    fn sends_every_browser_enforced_field_of_a_preset() {
462        let profile = create_default_fingerprint_preset("android-chrome").expect("preset builds");
463        let commands = build_cdp_emulation_commands(&profile);
464        let methods: Vec<_> = commands.iter().map(|command| command.method).collect();
465
466        assert_eq!(
467            methods,
468            vec![
469                "Emulation.setUserAgentOverride",
470                "Emulation.setTimezoneOverride",
471                "Emulation.setLocaleOverride",
472                "Emulation.setHardwareConcurrencyOverride",
473                "Emulation.setDeviceMetricsOverride",
474                "Emulation.setTouchEmulationEnabled",
475            ]
476        );
477        assert_eq!(
478            commands[0].params["userAgentMetadata"]["mobile"],
479            json!(true)
480        );
481    }
482}