Skip to main content

stygian_browser/
navigator_coherence.rs

1//! Comprehensive navigator property coherence injection.
2//!
3//! Overrides all navigator properties that anti-bot systems cross-reference to
4//! ensure they are consistent with a single [`FingerprintProfile`]. Covers:
5//! `hardwareConcurrency`, `deviceMemory`, `connection` (`NetworkInformation API`),
6//! `maxTouchPoints`, `languages`, `pdfViewerEnabled`, `plugins`, `mimeTypes`,
7//! and `userAgentData` Client Hints.
8//!
9//! # Example
10//!
11//! ```
12//! use stygian_browser::navigator_coherence::navigator_coherence_script;
13//! use stygian_browser::profile::FingerprintProfile;
14//!
15//! let p = FingerprintProfile::windows_chrome_136_rtx3060();
16//! let js = navigator_coherence_script(&p);
17//! assert!(js.contains("hardwareConcurrency"));
18//! assert!(js.contains("deviceMemory"));
19//! assert!(js.contains("userAgentData"));
20//! ```
21
22use crate::profile::{BrowserKind, FingerprintProfile};
23
24/// Generate a CDP injection script for comprehensive navigator coherence.
25///
26/// All overrides are derived from `profile` to guarantee internal consistency.
27/// Must be injected via `Page.addScriptToEvaluateOnNewDocument`.
28///
29/// When a profile is not set, call `stealth.rs`'s existing navigator spoof
30/// instead — this function requires a fully populated profile.
31///
32/// # Example
33///
34/// ```
35/// use stygian_browser::navigator_coherence::navigator_coherence_script;
36/// use stygian_browser::profile::FingerprintProfile;
37///
38/// let p = FingerprintProfile::linux_chrome_136_intel();
39/// let js = navigator_coherence_script(&p);
40/// assert!(js.contains("4")); // 4 cores
41/// assert!(js.contains("userAgentData"));
42/// ```
43#[must_use]
44pub fn navigator_coherence_script(profile: &FingerprintProfile) -> String {
45    let cores = profile.hardware.cores;
46    let memory = profile.hardware.memory_gb;
47    let rtt = profile.network.rtt;
48    let downlink = profile.network.downlink;
49    let effective_type = &profile.network.effective_type;
50    let save_data = profile.network.save_data;
51    let max_touch = profile.platform.max_touch_points;
52    let pdf_viewer_enabled = matches!(
53        profile.browser.kind,
54        BrowserKind::Chrome | BrowserKind::Edge
55    );
56
57    // Build languages array from the UA / platform
58    let languages_js = build_languages_js(profile);
59    let plugins_js = build_plugins_js(profile);
60    let ua_data_js = build_ua_data_js(profile);
61    let user_agent = &profile.browser.user_agent;
62    let platform_string = &profile.platform.platform_string;
63
64    format!(
65        r"(function() {{
66  'use strict';
67
68  // ── toString spoof utility ───────────────────────────────────────────────
69  function _nts(name) {{ return function toString() {{ return 'function ' + name + '() {{ [native code] }}'; }}; }}
70  function _def(obj, prop, val) {{
71    Object.defineProperty(obj, prop, {{ value: val, writable: false, configurable: false, enumerable: false }});
72  }}
73  function _defGetter(obj, prop, getter) {{
74    getter.toString = _nts('get ' + prop);
75    Object.defineProperty(obj, prop, {{ get: getter, configurable: false, enumerable: true }});
76  }}
77
78  // ── 1. hardwareConcurrency ───────────────────────────────────────────────
79  _defGetter(Navigator.prototype, 'hardwareConcurrency', function() {{ return {cores}; }});
80
81  // ── 2. deviceMemory ─────────────────────────────────────────────────────
82  if ('deviceMemory' in navigator) {{
83    _defGetter(Navigator.prototype, 'deviceMemory', function() {{ return {memory}; }});
84  }}
85
86  // ── 3. connection (NetworkInformation) ──────────────────────────────────
87  const _conn = Object.create(EventTarget.prototype);
88  _def(_conn, 'rtt', {rtt});
89  _def(_conn, 'downlink', {downlink});
90  _def(_conn, 'effectiveType', '{effective_type}');
91  _def(_conn, 'saveData', {save_data_js});
92  _def(_conn, 'onchange', null);
93  _defGetter(Navigator.prototype, 'connection', function() {{ return _conn; }});
94
95  // ── 4. maxTouchPoints ───────────────────────────────────────────────────
96  _defGetter(Navigator.prototype, 'maxTouchPoints', function() {{ return {max_touch}; }});
97
98  // ── 5. languages ─────────────────────────────────────────────────────────
99  const _langs = {languages_js};
100  _defGetter(Navigator.prototype, 'languages', function() {{ return _langs; }});
101  _defGetter(Navigator.prototype, 'language', function() {{ return _langs[0] || 'en-US'; }});
102
103  // ── 6. pdfViewerEnabled ──────────────────────────────────────────────────
104  _defGetter(Navigator.prototype, 'pdfViewerEnabled', function() {{ return {pdf_viewer_enabled_js}; }});
105
106  // ── 7. plugins + mimeTypes ───────────────────────────────────────────────
107  {plugins_js}
108
109  // ── 8. userAgent + platform ─────────────────────────────────────────────
110  _defGetter(Navigator.prototype, 'userAgent', function() {{ return '{user_agent}'; }});
111  _defGetter(Navigator.prototype, 'platform', function() {{ return '{platform_string}'; }});
112  _defGetter(Navigator.prototype, 'appVersion', function() {{
113    return '{user_agent}'.replace('Mozilla/', '');
114  }});
115
116  // ── 9. userAgentData (Client Hints) ─────────────────────────────────────
117  {ua_data_js}
118
119}})();
120",
121        cores = cores,
122        memory = memory,
123        rtt = rtt,
124        downlink = downlink,
125        effective_type = effective_type,
126        save_data_js = if save_data { "true" } else { "false" },
127        max_touch = max_touch,
128        languages_js = languages_js,
129        pdf_viewer_enabled_js = if pdf_viewer_enabled { "true" } else { "false" },
130        plugins_js = plugins_js,
131        user_agent = user_agent.replace('\'', "\\'"),
132        platform_string = platform_string.replace('\'', "\\'"),
133        ua_data_js = ua_data_js,
134    )
135}
136
137// ---------------------------------------------------------------------------
138// Private helpers
139// ---------------------------------------------------------------------------
140
141fn build_languages_js(profile: &FingerprintProfile) -> String {
142    // Derive language from the sec_ch_ua_platform — always en-US as primary for now.
143    // Future: add locale field to BrowserProfile.
144    let _ = profile.browser.sec_ch_ua_mobile == "?1";
145    "['en-US', 'en']".into()
146}
147
148fn build_plugins_js(profile: &FingerprintProfile) -> String {
149    let is_chrome_like = matches!(
150        profile.browser.kind,
151        BrowserKind::Chrome | BrowserKind::Edge
152    );
153    if !is_chrome_like {
154        return String::new();
155    }
156    // Chrome 136 standard plugin list (5 entries)
157    r"
158  (function() {
159    const _mimeTypes = [
160      { type: 'application/pdf', suffixes: 'pdf', description: '' },
161      { type: 'text/pdf', suffixes: 'pdf', description: '' },
162    ];
163    const _pluginData = [
164      'PDF Viewer',
165      'Chrome PDF Viewer',
166      'Chromium PDF Viewer',
167      'Microsoft Edge PDF Viewer',
168      'WebKit built-in PDF',
169    ];
170    function _makePlugin(name) {
171      const p = Object.create(Plugin.prototype);
172      Object.defineProperty(p, 'name',        { value: name, enumerable: true });
173      Object.defineProperty(p, 'description', { value: '', enumerable: true });
174      Object.defineProperty(p, 'filename',    { value: 'internal-pdf-viewer', enumerable: true });
175      Object.defineProperty(p, 'length',      { value: _mimeTypes.length, enumerable: true });
176      _mimeTypes.forEach(function(mt, i) {
177        Object.defineProperty(p, i, { value: mt, enumerable: true });
178      });
179      p.item = function(i) { return _mimeTypes[i] || null; };
180      p.namedItem = function(n) { return _mimeTypes.find(function(m) { return m.type === n; }) || null; };
181      return p;
182    }
183    const _plugins = _pluginData.map(_makePlugin);
184    const _pluginArray = Object.create(PluginArray.prototype);
185    Object.defineProperty(_pluginArray, 'length', { value: _plugins.length, enumerable: true });
186    _plugins.forEach(function(p, i) { Object.defineProperty(_pluginArray, i, { value: p, enumerable: true }); });
187    _pluginArray.item = function(i) { return _plugins[i] || null; };
188    _pluginArray.namedItem = function(n) { return _plugins.find(function(p) { return p.name === n; }) || null; };
189    _pluginArray.refresh = function() {};
190    Object.defineProperty(Navigator.prototype, 'plugins', {
191      get: function() { return _pluginArray; }, configurable: false, enumerable: true
192    });
193  })();".into()
194}
195
196fn build_ua_data_js(profile: &FingerprintProfile) -> String {
197    let brands = parse_sec_ch_ua_brands(&profile.browser.sec_ch_ua);
198    let mobile = profile.browser.sec_ch_ua_mobile == "?1";
199    let platform = strip_quotes(&profile.browser.sec_ch_ua_platform);
200    let os_version = &profile.platform.os_version;
201
202    format!(
203        r"
204  if (typeof NavigatorUAData !== 'undefined' || 'userAgentData' in navigator) {{
205    const _brands = {brands};
206    const _uaData = {{
207      brands: _brands,
208      mobile: {mobile_js},
209      platform: '{platform}',
210      getHighEntropyValues: function(hints) {{
211        return Promise.resolve({{
212          architecture: 'x86',
213          model: '',
214          platform: '{platform}',
215          platformVersion: '{os_version}',
216          fullVersionList: _brands,
217          mobile: {mobile_js},
218          bitness: '64',
219          wow64: false,
220        }});
221      }},
222      toJSON: function() {{
223        return {{ brands: _brands, mobile: {mobile_js}, platform: '{platform}' }};
224      }},
225    }};
226    Object.defineProperty(Navigator.prototype, 'userAgentData', {{
227      get: function() {{ return _uaData; }}, configurable: false, enumerable: true
228    }});
229  }}",
230        brands = brands,
231        mobile_js = if mobile { "true" } else { "false" },
232        platform = platform,
233        os_version = os_version,
234    )
235}
236
237/// Parse `Sec-CH-UA` header string into a JS array literal of brand objects.
238///
239/// Input: `"Chromium";v="136", "Google Chrome";v="136", "Not-A.Brand";v="99"`
240/// Output: `[{brand:"Chromium",version:"136"}, ...]`
241fn parse_sec_ch_ua_brands(sec_ch_ua: &str) -> String {
242    use std::fmt::Write;
243
244    let mut result = String::from('[');
245    for part in sec_ch_ua.split(',') {
246        let part = part.trim();
247        // Each part: `"Brand";v="version"`
248        let mut iter = part.splitn(2, ";v=");
249        let brand_raw = iter.next().unwrap_or("").trim().trim_matches('"');
250        let version_raw = iter.next().unwrap_or("\"\"").trim().trim_matches('"');
251        if !brand_raw.is_empty() {
252            if result.len() > 1 {
253                result.push(',');
254            }
255            let _ = write!(
256                result,
257                "{{brand:\"{brand_raw}\",version:\"{version_raw}\"}}",
258            );
259        }
260    }
261    result.push(']');
262    result
263}
264
265/// Strip surrounding quotes from a `sec_ch_ua_platform` value like `"Windows"`.
266fn strip_quotes(s: &str) -> String {
267    s.trim_matches('"').to_string()
268}
269
270// ---------------------------------------------------------------------------
271// Tests
272// ---------------------------------------------------------------------------
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::profile::FingerprintProfile;
278
279    fn script_for(p: &FingerprintProfile) -> String {
280        navigator_coherence_script(p)
281    }
282
283    #[test]
284    fn script_overrides_all_nine_groups() {
285        let p = FingerprintProfile::windows_chrome_136_rtx3060();
286        let js = script_for(&p);
287        assert!(
288            js.contains("hardwareConcurrency"),
289            "missing hardwareConcurrency"
290        );
291        assert!(js.contains("deviceMemory"), "missing deviceMemory");
292        assert!(js.contains("connection"), "missing connection");
293        assert!(js.contains("maxTouchPoints"), "missing maxTouchPoints");
294        assert!(js.contains("languages"), "missing languages");
295        assert!(js.contains("pdfViewerEnabled"), "missing pdfViewerEnabled");
296        assert!(js.contains("plugins"), "missing plugins");
297        assert!(js.contains("userAgentData"), "missing userAgentData");
298        assert!(js.contains("userAgent"), "missing userAgent");
299    }
300
301    #[test]
302    fn hardware_concurrency_matches_profile() {
303        let p = FingerprintProfile::windows_chrome_136_rtx3060();
304        let js = script_for(&p);
305        assert!(
306            js.contains(&format!("return {};", p.hardware.cores)),
307            "hardwareConcurrency value not found"
308        );
309    }
310
311    #[test]
312    fn device_memory_matches_profile() {
313        let p = FingerprintProfile::windows_chrome_136_rtx3060();
314        let js = script_for(&p);
315        assert!(
316            js.contains(&format!("return {};", p.hardware.memory_gb)),
317            "deviceMemory value not found"
318        );
319    }
320
321    #[test]
322    fn connection_has_all_four_properties() {
323        let js = script_for(&FingerprintProfile::windows_chrome_136_rtx3060());
324        assert!(js.contains("rtt"), "missing rtt");
325        assert!(js.contains("downlink"), "missing downlink");
326        assert!(js.contains("effectiveType"), "missing effectiveType");
327        assert!(js.contains("saveData"), "missing saveData");
328    }
329
330    #[test]
331    fn plugins_count_five_for_chrome() {
332        let js = script_for(&FingerprintProfile::windows_chrome_136_rtx3060());
333        assert!(js.contains("PDF Viewer"), "missing PDF Viewer plugin");
334        assert!(
335            js.contains("Chrome PDF Viewer"),
336            "missing Chrome PDF Viewer"
337        );
338        assert!(
339            js.contains("Chromium PDF Viewer"),
340            "missing Chromium PDF Viewer"
341        );
342        assert!(
343            js.contains("Microsoft Edge PDF Viewer"),
344            "missing Edge plugin"
345        );
346        assert!(js.contains("WebKit built-in PDF"), "missing WebKit plugin");
347    }
348
349    #[test]
350    fn ua_data_brands_match_sec_ch_ua() {
351        let p = FingerprintProfile::windows_chrome_136_rtx3060();
352        let js = script_for(&p);
353        assert!(js.contains("Google Chrome"), "missing Google Chrome brand");
354        assert!(js.contains("Chromium"), "missing Chromium brand");
355    }
356
357    #[test]
358    fn max_touch_points_desktop_is_zero() {
359        let p = FingerprintProfile::windows_chrome_136_rtx3060();
360        assert_eq!(p.platform.max_touch_points, 0);
361        let js = script_for(&p);
362        assert!(js.contains("return 0;"), "maxTouchPoints not 0 on desktop");
363    }
364
365    #[test]
366    fn max_touch_points_mobile_is_nonzero() {
367        let p = FingerprintProfile::android_chrome_136_pixel();
368        assert!(p.platform.max_touch_points > 0);
369        let js = script_for(&p);
370        assert!(
371            js.contains(&format!("return {};", p.platform.max_touch_points)),
372            "maxTouchPoints not > 0 on mobile"
373        );
374    }
375
376    #[test]
377    fn parse_sec_ch_ua_brands_roundtrip() {
378        let input = r#""Chromium";v="136", "Google Chrome";v="136", "Not-A.Brand";v="99""#;
379        let out = parse_sec_ch_ua_brands(input);
380        assert!(out.contains("Chromium"), "Chromium not parsed");
381        assert!(out.contains("136"), "version 136 not parsed");
382    }
383}