stygian_browser/
webrtc.rs

1//! WebRTC IP leak prevention and geolocation consistency
2//!
3//! Browsers can expose the host machine's real IP address via `RTCPeerConnection`,
4//! even when a proxy is configured, because WebRTC uses UDP STUN requests that bypass
5//! the HTTP proxy tunnel.  This module provides:
6//!
7//! - [`WebRtcPolicy`] — controls how aggressively WebRTC is restricted.
8//! - [`ProxyLocation`] — optional geolocation to match a proxy's region.
9//! - [`WebRtcConfig`] — bundles policy + location and generates injection scripts
10//!   and Chrome launch arguments.
11//!
12//! ## Example
13//!
14//! ```
15//! use stygian_browser::webrtc::{WebRtcConfig, WebRtcPolicy, ProxyLocation};
16//!
17//! let location = ProxyLocation {
18//!     latitude: 40.7128,
19//!     longitude: -74.0060,
20//!     accuracy: 100.0,
21//!     timezone: "America/New_York".to_string(),
22//!     locale: "en-US".to_string(),
23//! };
24//!
25//! let config = WebRtcConfig {
26//!     policy: WebRtcPolicy::DisableNonProxied,
27//!     public_ip: Some("203.0.113.42".to_string()),
28//!     local_ip: Some("192.168.1.100".to_string()),
29//!     location: Some(location),
30//! };
31//!
32//! let script = config.injection_script();
33//! assert!(script.contains("RTCPeerConnection"));
34//! let args = config.chrome_args();
35//! assert!(args.iter().any(|a| a.contains("disable_non_proxied_udp")));
36//! ```
37
38use serde::{Deserialize, Serialize};
39
40// ─── WebRtcPolicy ─────────────────────────────────────────────────────────────
41
42/// Controls how WebRTC connections are handled to prevent IP leakage.
43///
44/// # Example
45///
46/// ```
47/// use stygian_browser::webrtc::WebRtcPolicy;
48/// assert_eq!(WebRtcPolicy::default(), WebRtcPolicy::DisableNonProxied);
49/// ```
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
51#[serde(rename_all = "snake_case")]
52pub enum WebRtcPolicy {
53    /// No WebRTC restrictions.  The real IP may be exposed.
54    AllowAll,
55    /// Force WebRTC traffic through the configured proxy.
56    ///
57    /// Applies the `disable_non_proxied_udp` handling policy via
58    /// `--force-webrtc-ip-handling-policy`.  This is the recommended setting
59    /// when a proxy is in use.
60    #[default]
61    DisableNonProxied,
62    /// Completely block WebRTC by overriding `RTCPeerConnection` with a stub
63    /// that never emits ICE candidates.  Breaks real-time communication on
64    /// target pages but provides the strongest IP protection.
65    BlockAll,
66}
67
68// ─── ProxyLocation ────────────────────────────────────────────────────────────
69
70/// Geographic location metadata for geolocation consistency with a proxy.
71///
72/// When a proxy routes traffic through a specific region, the browser's
73/// `navigator.geolocation` API and timezone should match that region so that
74/// fingerprint analysis cannot detect the mismatch.
75///
76/// # Example
77///
78/// ```
79/// use stygian_browser::webrtc::ProxyLocation;
80///
81/// let loc = ProxyLocation::new_us_east();
82/// assert_eq!(loc.timezone, "America/New_York");
83/// assert_eq!(loc.locale, "en-US");
84/// ```
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct ProxyLocation {
87    /// WGS-84 decimal latitude.
88    pub latitude: f64,
89    /// WGS-84 decimal longitude.
90    pub longitude: f64,
91    /// Accuracy radius in metres (typical GPS: 10–50, cell/IP: 100–5000).
92    pub accuracy: f64,
93    /// IANA timezone identifier, e.g. `"America/New_York"`.
94    pub timezone: String,
95    /// BCP-47 locale tag, e.g. `"en-US"`.
96    pub locale: String,
97}
98
99impl ProxyLocation {
100    /// US East Coast (New York) preset.
101    pub fn new_us_east() -> Self {
102        Self {
103            latitude: 40.7128,
104            longitude: -74.0060,
105            accuracy: 1000.0,
106            timezone: "America/New_York".to_string(),
107            locale: "en-US".to_string(),
108        }
109    }
110
111    /// US West Coast (Los Angeles) preset.
112    pub fn new_us_west() -> Self {
113        Self {
114            latitude: 34.0522,
115            longitude: -118.2437,
116            accuracy: 1000.0,
117            timezone: "America/Los_Angeles".to_string(),
118            locale: "en-US".to_string(),
119        }
120    }
121
122    /// UK (London) preset.
123    pub fn new_uk() -> Self {
124        Self {
125            latitude: 51.5074,
126            longitude: -0.1278,
127            accuracy: 1000.0,
128            timezone: "Europe/London".to_string(),
129            locale: "en-GB".to_string(),
130        }
131    }
132
133    /// Central Europe (Frankfurt) preset.
134    pub fn new_eu_central() -> Self {
135        Self {
136            latitude: 50.1109,
137            longitude: 8.6821,
138            accuracy: 1000.0,
139            timezone: "Europe/Berlin".to_string(),
140            locale: "de-DE".to_string(),
141        }
142    }
143
144    /// Asia Pacific (Singapore) preset.
145    pub fn new_apac() -> Self {
146        Self {
147            latitude: 1.3521,
148            longitude: 103.8198,
149            accuracy: 1000.0,
150            timezone: "Asia/Singapore".to_string(),
151            locale: "en-SG".to_string(),
152        }
153    }
154}
155
156// ─── WebRtcConfig ─────────────────────────────────────────────────────────────
157
158/// WebRTC leak-prevention and geolocation consistency configuration.
159///
160/// Produces both a JavaScript injection script (to run before page load) and
161/// Chrome launch arguments that enforce the chosen [`WebRtcPolicy`].
162///
163/// # Example
164///
165/// ```
166/// use stygian_browser::webrtc::{WebRtcConfig, WebRtcPolicy};
167///
168/// let cfg = WebRtcConfig::default();
169/// assert_eq!(cfg.policy, WebRtcPolicy::DisableNonProxied);
170/// let args = cfg.chrome_args();
171/// assert!(args.iter().any(|a| a.contains("disable_non_proxied_udp")));
172/// ```
173#[derive(Debug, Clone, Serialize, Deserialize, Default)]
174pub struct WebRtcConfig {
175    /// WebRTC IP-handling policy.
176    pub policy: WebRtcPolicy,
177
178    /// Fake public IP address to substitute in WebRTC SDP when using
179    /// [`WebRtcPolicy::BlockAll`].  Use an IP plausible for the proxy region.
180    /// Has no effect when [`WebRtcPolicy::AllowAll`] or
181    /// [`WebRtcPolicy::DisableNonProxied`] is selected.
182    pub public_ip: Option<String>,
183
184    /// Fake LAN IP address to substitute in WebRTC SDP when using
185    /// [`WebRtcPolicy::BlockAll`].
186    pub local_ip: Option<String>,
187
188    /// Optional geographic location to inject via `navigator.geolocation`.
189    /// When `None`, geolocation is not overridden.
190    pub location: Option<ProxyLocation>,
191}
192
193impl WebRtcConfig {
194    /// Returns `true` when WebRTC is not being restricted at all.
195    ///
196    /// # Example
197    ///
198    /// ```
199    /// use stygian_browser::webrtc::{WebRtcConfig, WebRtcPolicy};
200    ///
201    /// let cfg = WebRtcConfig { policy: WebRtcPolicy::AllowAll, ..Default::default() };
202    /// assert!(cfg.is_permissive());
203    /// ```
204    pub fn is_permissive(&self) -> bool {
205        self.policy == WebRtcPolicy::AllowAll && self.location.is_none()
206    }
207
208    /// Chrome launch arguments that enforce the selected [`WebRtcPolicy`].
209    ///
210    /// Returns an empty `Vec` for [`WebRtcPolicy::AllowAll`] since no Chrome
211    /// flag is needed in that case.
212    ///
213    /// # Example
214    ///
215    /// ```
216    /// use stygian_browser::webrtc::{WebRtcConfig, WebRtcPolicy};
217    ///
218    /// let cfg = WebRtcConfig { policy: WebRtcPolicy::BlockAll, ..Default::default() };
219    /// let args = cfg.chrome_args();
220    /// assert!(args.iter().any(|a| a.contains("disable_non_proxied_udp")));
221    /// ```
222    pub fn chrome_args(&self) -> Vec<String> {
223        match self.policy {
224            WebRtcPolicy::AllowAll => vec![],
225            WebRtcPolicy::DisableNonProxied | WebRtcPolicy::BlockAll => {
226                vec!["--force-webrtc-ip-handling-policy=disable_non_proxied_udp".to_string()]
227            }
228        }
229    }
230
231    /// JavaScript injection script that overrides `RTCPeerConnection` and
232    /// optionally overrides `navigator.geolocation`.
233    ///
234    /// The generated script is an IIFE (immediately-invoked function expression)
235    /// designed to be injected via CDP `Page.addScriptToEvaluateOnNewDocument`.
236    ///
237    /// # Example
238    ///
239    /// ```
240    /// use stygian_browser::webrtc::{WebRtcConfig, WebRtcPolicy};
241    ///
242    /// let cfg = WebRtcConfig { policy: WebRtcPolicy::BlockAll, ..Default::default() };
243    /// let script = cfg.injection_script();
244    /// assert!(script.contains("RTCPeerConnection"));
245    /// ```
246    pub fn injection_script(&self) -> String {
247        let mut parts: Vec<String> = Vec::new();
248
249        let rtc_part = match self.policy {
250            WebRtcPolicy::AllowAll => String::new(),
251            WebRtcPolicy::DisableNonProxied => rtc_disable_non_proxied_script(),
252            WebRtcPolicy::BlockAll => {
253                let public_ip = self.public_ip.as_deref().unwrap_or("203.0.113.1");
254                let local_ip = self.local_ip.as_deref().unwrap_or("10.0.0.1");
255                rtc_block_all_script(public_ip, local_ip)
256            }
257        };
258
259        if !rtc_part.is_empty() {
260            parts.push(rtc_part);
261        }
262
263        if let Some(loc) = &self.location {
264            parts.push(geolocation_script(loc));
265        }
266
267        if parts.is_empty() {
268            return String::new();
269        }
270
271        // Wrap everything in a single IIFE so variables don't leak to page scope.
272        format!(
273            "(function(){{\n  'use strict';\n{}\n}})();",
274            parts.join("\n")
275        )
276    }
277}
278
279// ─── Private script builders ──────────────────────────────────────────────────
280
281/// Generates JS that filters out non-proxied ICE candidates from SDP without
282/// completely blocking WebRTC (mirrors the Chrome flag behaviour in JS-space
283/// for defence-in-depth).
284fn rtc_disable_non_proxied_script() -> String {
285    r"
286  // WebRTC: suppress host/srflx candidates; allow relay (TURN) candidates only
287  (function patchRTCNonProxied() {
288    var _RPC = window.RTCPeerConnection;
289    if (!_RPC) return;
290    var patchedRPC = function(config) {
291      var pc = new _RPC(config);
292      var origSetLocalDescription = pc.setLocalDescription.bind(pc);
293      // Intercept onicecandidate to strip host + srflx candidates
294      var origOICH = pc.__lookupGetter__ ? null : null; // will use addEventListener
295      pc.addEventListener('icecandidate', function(e) {
296        if (e.candidate && e.candidate.candidate) {
297          var c = e.candidate.candidate;
298          // Drop host (LAN) and server-reflexive (public via STUN) candidates
299          if (c.indexOf('typ host') !== -1 || c.indexOf('typ srflx') !== -1) {
300            Object.defineProperty(e, 'candidate', { value: null, configurable: true });
301          }
302        }
303      }, true);
304      return pc;
305    };
306    patchedRPC.prototype = _RPC.prototype;
307    Object.defineProperty(window, 'RTCPeerConnection', {
308      value: patchedRPC,
309      writable: false,
310      configurable: false,
311    });
312  })();
313"
314    .to_string()
315}
316
317/// Generates JS that completely overrides `RTCPeerConnection` with a stub that
318/// replaces real IPs in SDP with the supplied fake IPs.
319fn rtc_block_all_script(public_ip: &str, local_ip: &str) -> String {
320    format!(
321        r"
322  // WebRTC: replace all real IPs in SDP with fake ones
323  (function patchRTCBlockAll() {{
324    var _RPC = window.RTCPeerConnection;
325    if (!_RPC) return;
326    var PUBLIC_IP = '{public_ip}';
327    var LOCAL_IP  = '{local_ip}';
328    var PRIV_RE   = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
329
330    function patchSDP(sdp) {{
331      return sdp.replace(
332        /(\b(?:\d{{1,3}}\.)\d{{1,3}}\.(?:\d{{1,3}}\.)\d{{1,3}}\b)/g,
333        function(ip) {{
334          if (ip === '127.0.0.1' || ip === '0.0.0.0') return ip;
335          if (PRIV_RE.test(ip)) return LOCAL_IP;
336          return PUBLIC_IP;
337        }}
338      );
339    }}
340
341    var patchedRPC = function(config) {{
342      // Remove all ICE servers so no STUN/TURN queries are made
343      if (config && Array.isArray(config.iceServers)) {{
344        config.iceServers = [];
345      }}
346      var pc = new _RPC(config);
347      ['createOffer', 'createAnswer'].forEach(function(method) {{
348        var orig = pc[method].bind(pc);
349        pc[method] = function() {{
350          return orig.apply(this, arguments).then(function(desc) {{
351            if (desc && desc.sdp) {{
352              return new RTCSessionDescription({{
353                type: desc.type,
354                sdp: patchSDP(desc.sdp),
355              }});
356            }}
357            return desc;
358          }});
359        }};
360      }});
361      return pc;
362    }};
363    patchedRPC.prototype = _RPC.prototype;
364    Object.defineProperty(window, 'RTCPeerConnection', {{
365      value: patchedRPC,
366      writable: false,
367      configurable: false,
368    }});
369  }})();
370"
371    )
372}
373
374/// Generates JS that overrides `navigator.geolocation` with a fixed fake position.
375fn geolocation_script(loc: &ProxyLocation) -> String {
376    format!(
377        r"
378  // Geolocation override to match proxy region
379  (function patchGeolocation() {{
380    var fakeCoords = {{
381      latitude:         {lat},
382      longitude:        {lon},
383      accuracy:         {acc},
384      altitude:         null,
385      altitudeAccuracy: null,
386      heading:          null,
387      speed:            null,
388    }};
389    var fakePosition = {{ coords: fakeCoords, timestamp: Date.now() }};
390
391    try {{
392      Object.defineProperty(navigator, 'geolocation', {{
393        value: {{
394          getCurrentPosition: function(success, _err, _opts) {{
395            setTimeout(function() {{ success(fakePosition); }}, 50 + Math.random() * 100);
396          }},
397          watchPosition: function(success, _err, _opts) {{
398            setTimeout(function() {{ success(fakePosition); }}, 50 + Math.random() * 100);
399            return 1;
400          }},
401          clearWatch: function() {{}},
402        }},
403        writable:     false,
404        configurable: false,
405      }});
406    }} catch (_) {{
407      // Already non-configurable in some browsers; best-effort only.
408    }}
409  }})();
410",
411        lat = loc.latitude,
412        lon = loc.longitude,
413        acc = loc.accuracy,
414    )
415}
416
417// ─── Tests ────────────────────────────────────────────────────────────────────
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn default_policy_is_disable_non_proxied() {
425        assert_eq!(WebRtcPolicy::default(), WebRtcPolicy::DisableNonProxied);
426    }
427
428    #[test]
429    fn allow_all_has_no_chrome_args() {
430        let cfg = WebRtcConfig {
431            policy: WebRtcPolicy::AllowAll,
432            ..Default::default()
433        };
434        assert!(cfg.chrome_args().is_empty());
435    }
436
437    #[test]
438    fn disable_non_proxied_adds_webrtc_flag() {
439        let cfg = WebRtcConfig::default();
440        let args = cfg.chrome_args();
441        assert_eq!(args.len(), 1);
442        assert!(
443            args.first()
444                .is_some_and(|a| a.contains("disable_non_proxied_udp"))
445        );
446    }
447
448    #[test]
449    fn block_all_adds_webrtc_flag() {
450        let cfg = WebRtcConfig {
451            policy: WebRtcPolicy::BlockAll,
452            ..Default::default()
453        };
454        let args = cfg.chrome_args();
455        assert!(!args.is_empty());
456        assert!(args.iter().any(|a| a.contains("disable_non_proxied_udp")));
457    }
458
459    #[test]
460    fn allow_all_injection_script_is_empty() {
461        let cfg = WebRtcConfig {
462            policy: WebRtcPolicy::AllowAll,
463            ..Default::default()
464        };
465        assert!(cfg.injection_script().is_empty());
466    }
467
468    #[test]
469    fn disable_non_proxied_script_contains_rtc() {
470        let cfg = WebRtcConfig::default();
471        let script = cfg.injection_script();
472        assert!(script.contains("RTCPeerConnection"));
473    }
474
475    #[test]
476    fn block_all_script_contains_rtc_and_fake_ips() {
477        let cfg = WebRtcConfig {
478            policy: WebRtcPolicy::BlockAll,
479            public_ip: Some("1.2.3.4".to_string()),
480            local_ip: Some("10.0.0.5".to_string()),
481            ..Default::default()
482        };
483        let script = cfg.injection_script();
484        assert!(script.contains("RTCPeerConnection"));
485        assert!(script.contains("1.2.3.4"));
486        assert!(script.contains("10.0.0.5"));
487    }
488
489    #[test]
490    fn block_all_uses_default_fake_ips_when_none_set() {
491        let cfg = WebRtcConfig {
492            policy: WebRtcPolicy::BlockAll,
493            public_ip: None,
494            local_ip: None,
495            ..Default::default()
496        };
497        let script = cfg.injection_script();
498        // Should use fallback IPs (203.0.113.1 and 10.0.0.1)
499        assert!(script.contains("203.0.113.1"));
500        assert!(script.contains("10.0.0.1"));
501    }
502
503    #[test]
504    fn geolocation_script_included_when_location_set() {
505        let cfg = WebRtcConfig {
506            policy: WebRtcPolicy::AllowAll,
507            location: Some(ProxyLocation::new_us_east()),
508            ..Default::default()
509        };
510        let script = cfg.injection_script();
511        assert!(script.contains("geolocation"));
512        assert!(script.contains("40.7128"));
513    }
514
515    #[test]
516    fn is_permissive_only_when_allow_all_and_no_location() {
517        let mut cfg = WebRtcConfig {
518            policy: WebRtcPolicy::AllowAll,
519            ..Default::default()
520        };
521        assert!(cfg.is_permissive());
522
523        cfg.location = Some(ProxyLocation::new_uk());
524        assert!(!cfg.is_permissive());
525
526        cfg.location = None;
527        cfg.policy = WebRtcPolicy::DisableNonProxied;
528        assert!(!cfg.is_permissive());
529    }
530
531    #[test]
532    fn proxy_location_presets_have_valid_coords() {
533        let presets = [
534            ProxyLocation::new_us_east(),
535            ProxyLocation::new_us_west(),
536            ProxyLocation::new_uk(),
537            ProxyLocation::new_eu_central(),
538            ProxyLocation::new_apac(),
539        ];
540        for loc in &presets {
541            assert!(loc.latitude >= -90.0 && loc.latitude <= 90.0);
542            assert!(loc.longitude >= -180.0 && loc.longitude <= 180.0);
543            assert!(loc.accuracy > 0.0);
544            assert!(!loc.timezone.is_empty());
545            assert!(!loc.locale.is_empty());
546        }
547    }
548
549    #[test]
550    fn proxy_location_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
551        let loc = ProxyLocation::new_us_east();
552        let json = serde_json::to_string(&loc)?;
553        let back: ProxyLocation = serde_json::from_str(&json)?;
554        assert!((back.latitude - loc.latitude).abs() < 1e-9);
555        assert_eq!(back.timezone, loc.timezone);
556        Ok(())
557    }
558
559    #[test]
560    fn webrtc_config_serializes_to_json() -> Result<(), Box<dyn std::error::Error>> {
561        let cfg = WebRtcConfig {
562            policy: WebRtcPolicy::DisableNonProxied,
563            public_ip: Some("1.2.3.4".to_string()),
564            local_ip: None,
565            location: Some(ProxyLocation::new_uk()),
566        };
567        let json = serde_json::to_string(&cfg)?;
568        let back: WebRtcConfig = serde_json::from_str(&json)?;
569        assert_eq!(back.policy, cfg.policy);
570        assert_eq!(back.public_ip, cfg.public_ip);
571        Ok(())
572    }
573
574    #[test]
575    fn combined_script_is_valid_iife() {
576        let cfg = WebRtcConfig {
577            policy: WebRtcPolicy::DisableNonProxied,
578            location: Some(ProxyLocation::new_apac()),
579            ..Default::default()
580        };
581        let script = cfg.injection_script();
582        assert!(script.starts_with("(function(){"));
583        assert!(script.ends_with("})();"));
584        assert!(script.contains("RTCPeerConnection"));
585        assert!(script.contains("geolocation"));
586    }
587}