Skip to main content

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