Skip to main content

browser_commander/fingerprint/
init_script.rs

1//! Page init script for the fingerprint surfaces CDP cannot override.
2//!
3//! Everything here is strictly worse than a browser-enforced override: a
4//! JavaScript patch is visible to anyone who inspects the property descriptor
5//! carefully enough, and it does not reach workers or HTTP headers. It exists
6//! only for fields the `Emulation` domain has no command for, and for
7//! connecting to a browser somebody else launched, where the switches can no
8//! longer be changed.
9//!
10//! The payload itself is not written here. `init_payload.js` next to this
11//! module is a byte-for-byte copy of `js/src/fingerprint/init-payload.js`, kept
12//! in step by `scripts/check-shared-fingerprint-assets.sh`, so all three
13//! implementations send Chrome the same script rather than three hand-written
14//! translations of it.
15
16use serde_json::{json, Map, Value};
17
18use super::profile::FingerprintProfile;
19
20/// The shared payload source, embedded at compile time.
21pub const FINGERPRINT_PAYLOAD_SOURCE: &str = include_str!("init_payload.js");
22
23/// What the init script still has to patch after the CDP overrides.
24#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25pub struct InitScriptOptions {
26    /// Force `navigator.webdriver` to `false` from JavaScript.
27    ///
28    /// Only needed when the browser was launched by somebody else and
29    /// `--disable-blink-features=AutomationControlled` can no longer be passed.
30    pub patch_webdriver: bool,
31    /// Also patch `navigator.languages`, which the browser already sets from
32    /// `acceptLanguage`.
33    pub patch_languages: bool,
34}
35
36/// Decide what the init script still has to do after the CDP overrides.
37///
38/// Returns `None` when the browser-side overrides already cover everything.
39pub fn build_init_script_config(
40    profile: &FingerprintProfile,
41    options: InitScriptOptions,
42) -> Option<Value> {
43    let mut config = Map::new();
44
45    if options.patch_webdriver {
46        config.insert("webdriver".to_string(), json!(false));
47    }
48    if let Some(device_memory) = profile.device_memory {
49        config.insert("deviceMemory".to_string(), json!(device_memory));
50    }
51    if let Some(vendor) = &profile.vendor {
52        config.insert("vendor".to_string(), json!(vendor));
53    }
54    if let Some(do_not_track) = &profile.do_not_track {
55        config.insert("doNotTrack".to_string(), json!(do_not_track));
56    }
57    if options.patch_languages {
58        if let Some(languages) = &profile.languages {
59            config.insert("languages".to_string(), json!(languages));
60        }
61    }
62    if let Some(webgl) = &profile.webgl {
63        config.insert("webgl".to_string(), json!(webgl));
64    }
65    if let Some(screen) = &profile.screen {
66        // width and height are already enforced by setDeviceMetricsOverride;
67        // the avail*/depth fields are not, so only those need patching.
68        let mut patched = Map::new();
69        for (name, value) in [
70            ("availWidth", screen.avail_width),
71            ("availHeight", screen.avail_height),
72            ("colorDepth", screen.color_depth),
73            ("pixelDepth", screen.pixel_depth),
74        ] {
75            if let Some(value) = value {
76                patched.insert(name.to_string(), json!(value));
77            }
78        }
79        if !patched.is_empty() {
80            config.insert("screen".to_string(), Value::Object(patched));
81        }
82    }
83
84    if config.is_empty() {
85        None
86    } else {
87        Some(Value::Object(config))
88    }
89}
90
91/// Serialize the init script for a profile, or `None` when none is needed.
92pub fn build_fingerprint_init_script(
93    profile: &FingerprintProfile,
94    options: InitScriptOptions,
95) -> Option<String> {
96    let config = build_init_script_config(profile, options)?;
97    // The payload is wrapped in an IIFE so the declaration never becomes a
98    // property of the page's global object; a stray `fingerprintPayload` global
99    // would be a far louder signal than anything the payload hides.
100    Some(format!(
101        "(() => {{\n{FINGERPRINT_PAYLOAD_SOURCE}\nfingerprintPayload({config});\n}})();"
102    ))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::fingerprint::profile::{ScreenProfile, WebglProfile};
109
110    fn config(profile: FingerprintProfile, options: InitScriptOptions) -> Option<Value> {
111        build_init_script_config(&profile.resolve().expect("profile resolves"), options)
112    }
113
114    fn script(profile: FingerprintProfile, options: InitScriptOptions) -> Option<String> {
115        build_fingerprint_init_script(&profile.resolve().expect("profile resolves"), options)
116    }
117
118    #[test]
119    fn returns_nothing_when_the_browser_side_overrides_cover_everything() {
120        let profile = FingerprintProfile::default()
121            .user_agent("Mozilla/5.0 (X11; Linux x86_64) Chrome/140.0.0.0")
122            .timezone_id("UTC")
123            .hardware_concurrency(8)
124            .max_touch_points(0);
125
126        assert_eq!(config(profile.clone(), InitScriptOptions::default()), None);
127        assert_eq!(script(profile, InitScriptOptions::default()), None);
128    }
129
130    #[test]
131    fn patches_only_the_fields_the_emulation_domain_has_no_command_for() {
132        // hardwareConcurrency and timezoneId are browser-enforced, so they must
133        // not appear in the weaker JavaScript patch.
134        let config = config(
135            FingerprintProfile::default()
136                .device_memory(8.0)
137                .vendor("Google Inc.")
138                .do_not_track("1")
139                .hardware_concurrency(8)
140                .timezone_id("UTC"),
141            InitScriptOptions::default(),
142        )
143        .expect("something is left to patch");
144        let mut keys: Vec<_> = config
145            .as_object()
146            .expect("object")
147            .keys()
148            .map(String::as_str)
149            .collect();
150        keys.sort_unstable();
151
152        assert_eq!(keys, vec!["deviceMemory", "doNotTrack", "vendor"]);
153    }
154
155    #[test]
156    fn adds_webdriver_only_when_the_caller_asks_for_it() {
157        let profile = FingerprintProfile::default().vendor("Google Inc.");
158
159        assert_eq!(
160            config(profile.clone(), InitScriptOptions::default()).expect("config")["webdriver"],
161            Value::Null
162        );
163        assert_eq!(
164            config(
165                profile,
166                InitScriptOptions {
167                    patch_webdriver: true,
168                    ..InitScriptOptions::default()
169                }
170            )
171            .expect("config")["webdriver"],
172            json!(false)
173        );
174    }
175
176    #[test]
177    fn patches_webdriver_even_for_an_otherwise_empty_profile() {
178        let script = script(
179            FingerprintProfile::default(),
180            InitScriptOptions {
181                patch_webdriver: true,
182                ..InitScriptOptions::default()
183            },
184        )
185        .expect("a script is produced");
186
187        assert!(script.contains("\"webdriver\":false"));
188    }
189
190    #[test]
191    fn leaves_languages_to_the_browser_unless_explicitly_asked() {
192        let profile = FingerprintProfile::default().languages(["fr-FR", "fr"]);
193
194        assert_eq!(config(profile.clone(), InitScriptOptions::default()), None);
195        assert_eq!(
196            config(
197                profile,
198                InitScriptOptions {
199                    patch_languages: true,
200                    ..InitScriptOptions::default()
201                }
202            )
203            .expect("config")["languages"],
204            json!(["fr-FR", "fr"])
205        );
206    }
207
208    #[test]
209    fn drops_the_screen_dimensions_set_device_metrics_override_already_enforces() {
210        let config = config(
211            FingerprintProfile::default().screen(ScreenProfile {
212                width: Some(1920),
213                height: Some(1080),
214                avail_width: Some(1920),
215                avail_height: Some(1032),
216                color_depth: Some(24),
217                pixel_depth: Some(24),
218            }),
219            InitScriptOptions::default(),
220        )
221        .expect("config");
222
223        assert_eq!(
224            config["screen"],
225            json!({
226                "availWidth": 1920,
227                "availHeight": 1032,
228                "colorDepth": 24,
229                "pixelDepth": 24,
230            })
231        );
232    }
233
234    #[test]
235    fn skips_the_screen_patch_when_only_width_and_height_are_given() {
236        assert_eq!(
237            config(
238                FingerprintProfile::default().screen(ScreenProfile {
239                    width: Some(1920),
240                    height: Some(1080),
241                    ..ScreenProfile::default()
242                }),
243                InitScriptOptions::default()
244            ),
245            None
246        );
247    }
248
249    #[test]
250    fn wraps_the_shared_payload_and_calls_it_with_the_config() {
251        let script = script(
252            FingerprintProfile::default().webgl(WebglProfile {
253                unmasked_vendor: Some("Google Inc. (NVIDIA)".to_string()),
254                ..WebglProfile::default()
255            }),
256            InitScriptOptions::default(),
257        )
258        .expect("a script is produced");
259
260        assert!(script.starts_with("(() => {\n"));
261        assert!(script.ends_with("\n})();"));
262        assert!(script.contains(FINGERPRINT_PAYLOAD_SOURCE));
263        assert!(script.contains("fingerprintPayload({"));
264        assert!(script.contains("Google Inc. (NVIDIA)"));
265    }
266
267    // The payload is one asset shared with the JavaScript and Python packages;
268    // it is source text for a classic script, so module syntax would be a
269    // syntax error in the page rather than a failure here.
270    #[test]
271    fn embeds_the_shared_payload_asset_verbatim() {
272        assert!(FINGERPRINT_PAYLOAD_SOURCE.contains("function fingerprintPayload(config) {"));
273        for line in FINGERPRINT_PAYLOAD_SOURCE.lines() {
274            assert!(
275                !line.starts_with("import ") && !line.starts_with("export "),
276                "the payload must stay a classic script: {line}"
277            );
278        }
279    }
280}