diff --git a/packages/desktop-shell/src-tauri/src/local_control.rs b/packages/desktop-shell/src-tauri/src/local_control.rs index 5e5d3b612a5..f2b1e53bd33 100644 --- a/packages/desktop-shell/src-tauri/src/local_control.rs +++ b/packages/desktop-shell/src-tauri/src/local_control.rs @@ -19,6 +19,22 @@ const MAX_CONNECTIONS: usize = 64; const HEADER_TIMEOUT: Duration = Duration::from_secs(10); static NEXT_CONNECTION_ID: AtomicU64 = AtomicU64::new(1); +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct LocalNetwork { + address: Ipv4Addr, + netmask: Ipv4Addr, +} + +impl LocalNetwork { + fn contains(&self, peer: IpAddr) -> bool { + let IpAddr::V4(peer) = peer else { + return false; + }; + u32::from(peer) & u32::from(self.netmask) + == u32::from(self.address) & u32::from(self.netmask) + } +} + struct Connections { stopping: AtomicBool, streams: Mutex>>, @@ -58,7 +74,8 @@ impl LocalControlSession { current_url: &Url, ) -> Result { let target = runtime_socket_addr(runtime_url)?; - let lan_ip = primary_lan_ipv4()?; + let network = primary_lan_ipv4()?; + let lan_ip = network.address; let listener = TcpListener::bind((lan_ip, 0)) .map_err(|error| format!("Failed to open Local Control on the LAN: {error}"))?; listener @@ -89,6 +106,7 @@ impl LocalControlSession { public_origin, pair_token, runtime_token.to_string(), + network, Arc::clone(&connections), ); let inhibitor = start_sleep_inhibitor(); @@ -141,12 +159,13 @@ fn spawn_proxy( public_origin: String, pair_token: String, runtime_token: String, + network: LocalNetwork, connections: Arc, ) -> JoinHandle<()> { thread::spawn(move || { while !connections.stopping.load(Ordering::SeqCst) { match listener.accept() { - Ok((mut client, _)) => { + Ok((mut client, peer)) => { if connections.stopping.load(Ordering::SeqCst) { let _ = client.shutdown(Shutdown::Both); break; @@ -154,6 +173,10 @@ fn spawn_proxy( if client.set_nonblocking(false).is_err() { continue; } + if !network.contains(peer.ip()) { + let _ = write_rejection(&mut client, 403, "Forbidden (off-network)"); + continue; + } let connection_id = NEXT_CONNECTION_ID.fetch_add(1, Ordering::Relaxed); let Ok(client_guard) = client.try_clone() else { continue; @@ -441,17 +464,19 @@ fn local_control_url( Ok(url.into()) } -fn primary_lan_ipv4() -> Result { +fn primary_lan_ipv4() -> Result { select_lan_ipv4(routed_ipv4().ok(), NetworkInterface::show().ok()) } fn select_lan_ipv4( routed: Option, interfaces: Option>, -) -> Result { +) -> Result { let interfaces = interfaces.ok_or_else(|| "Local Control could not inspect IPv4 networks.".to_string())?; - let physical = interfaces + let mut saw_unverified = false; + let mut routed_unverified = false; + let physical: Vec = interfaces .into_iter() .filter(|interface| { !interface.internal @@ -467,24 +492,47 @@ fn select_lan_ipv4( && !address.ip.is_loopback() && !address.ip.is_unspecified() => { - Some(address.ip) + match address + .netmask + .filter(|netmask| !netmask.is_unspecified() && *netmask != Ipv4Addr::BROADCAST) + { + Some(netmask) => Some(LocalNetwork { + address: address.ip, + netmask, + }), + None => { + saw_unverified = true; + routed_unverified |= routed == Some(address.ip); + None + } + } } _ => None, }) .collect(); + if routed_unverified || (physical.is_empty() && saw_unverified) { + return Err( + "Local Control found an IPv4 adapter without a verifiable netmask.".to_string(), + ); + } choose_lan_ipv4(routed, physical) } fn choose_lan_ipv4( routed: Option, - mut physical: Vec, -) -> Result { - physical.sort_unstable(); - physical.dedup(); - physical.retain(|address| address.is_private() || address.is_link_local()); - if let Some(routed) = routed.filter(|address| physical.contains(address)) { - return Ok(routed); + mut physical: Vec, +) -> Result { + physical.sort_unstable_by_key(|network| (network.address, std::cmp::Reverse(network.netmask))); + physical.dedup_by_key(|network| network.address); + if let Some(network) = routed.and_then(|routed| { + physical + .iter() + .find(|network| network.address == routed) + .copied() + }) { + return Ok(network); } + physical.retain(|network| network.address.is_private() || network.address.is_link_local()); match physical.as_slice() { [address] => Ok(*address), [] => Err("Local Control could not find a usable IPv4 network.".to_string()), @@ -567,8 +615,9 @@ fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { mod tests { use super::{ choose_lan_ipv4, find_header_end, local_control_url, rewrite_request, runtime_socket_addr, - select_lan_ipv4, spawn_proxy, Connections, + select_lan_ipv4, spawn_proxy, Connections, LocalNetwork, }; + use network_interface::NetworkInterface; use std::collections::HashMap; use std::io::{Read, Write}; use std::net::{Ipv4Addr, TcpListener, TcpStream}; @@ -580,35 +629,110 @@ mod tests { use std::time::Duration; use url::Url; + fn network(address: &str, netmask: &str) -> LocalNetwork { + LocalNetwork { + address: address.parse().expect("network address"), + netmask: netmask.parse().expect("network mask"), + } + } + #[test] - fn selects_only_a_private_physical_lan() { + fn selects_and_limits_the_physical_lan() { assert_eq!( choose_lan_ipv4( Some("10.8.0.2".parse::().expect("VPN address")), - vec!["192.168.1.20".parse::().expect("Wi-Fi address")], + vec![network("192.168.1.20", "255.255.255.0")], ) .expect("LAN address"), - "192.168.1.20" - .parse::() - .expect("expected address"), + network("192.168.1.20", "255.255.255.0"), ); - assert!(choose_lan_ipv4( - Some("203.0.113.10".parse().expect("public route")), - vec!["203.0.113.10".parse().expect("public interface")], - ) - .is_err()); + let enterprise = network("203.0.113.10", "255.255.255.0"); + assert_eq!( + choose_lan_ipv4(Some(enterprise.address), vec![enterprise]).expect("enterprise LAN"), + enterprise, + ); + assert!(enterprise.contains("203.0.113.20".parse().expect("same subnet"))); + assert!(!enterprise.contains("198.51.100.20".parse().expect("other subnet"))); + assert!(choose_lan_ipv4(None, vec![enterprise]).is_err()); let routed = Ipv4Addr::new(192, 168, 1, 20); assert_eq!( - choose_lan_ipv4(Some(routed), vec![routed, Ipv4Addr::new(192, 168, 2, 5)],) - .expect("routed LAN"), - routed, + choose_lan_ipv4( + Some(routed), + vec![ + network("192.168.1.20", "255.255.255.0"), + network("192.168.2.5", "255.255.255.0"), + ], + ) + .expect("routed LAN") + .address, + routed + ); + assert_eq!( + choose_lan_ipv4( + Some(routed), + vec![ + network("192.168.1.20", "255.255.0.0"), + network("192.168.1.20", "255.255.255.0"), + ], + ) + .expect("narrowest duplicate"), + network("192.168.1.20", "255.255.255.0"), ); + assert!(choose_lan_ipv4(None, vec![]).is_err()); + assert!(choose_lan_ipv4( + None, + vec![ + network("192.168.1.20", "255.255.255.0"), + network("192.168.2.5", "255.255.255.0"), + ], + ) + .is_err()); } #[test] fn rejects_unverified_networks_when_interface_enumeration_fails() { let routed = Ipv4Addr::new(192, 168, 1, 20); assert!(select_lan_ipv4(Some(routed), None).is_err()); + + let interface = |name, address, netmask| { + NetworkInterface::new_afinet(name, address, netmask, Some(address), 1, false) + .with_mac_addr(Some("00:11:22:33:44:55".to_string())) + }; + assert!(select_lan_ipv4(Some(routed), Some(vec![interface("en0", routed, None)])).is_err()); + assert!(select_lan_ipv4( + Some(routed), + Some(vec![interface("en0", routed, Some(Ipv4Addr::UNSPECIFIED))]), + ) + .is_err()); + assert!(select_lan_ipv4( + Some(routed), + Some(vec![interface("en0", routed, Some(Ipv4Addr::BROADCAST))]), + ) + .is_err()); + assert!(select_lan_ipv4( + Some(routed), + Some(vec![ + interface("en0", routed, None), + interface( + "en1", + Ipv4Addr::new(192, 168, 2, 5), + Some(Ipv4Addr::new(255, 255, 255, 0)), + ), + ]), + ) + .is_err()); + assert_eq!( + select_lan_ipv4( + Some(routed), + Some(vec![interface( + "en0", + routed, + Some(Ipv4Addr::new(255, 255, 255, 0)), + )]), + ) + .expect("verified network"), + network("192.168.1.20", "255.255.255.0"), + ); } #[test] @@ -685,6 +809,7 @@ mod tests { format!("http://{public_address}"), "pair-token".to_string(), "runtime-token".to_string(), + network("127.0.0.1", "255.0.0.0"), Arc::clone(&connections), ); @@ -702,6 +827,41 @@ mod tests { upstream_thread.join().expect("stop upstream"); } + #[test] + fn rejects_off_subnet_peers() { + let target = TcpListener::bind(("127.0.0.1", 0)).expect("target listener"); + let target_address = target.local_addr().expect("target address"); + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("proxy listener"); + let proxy_address = listener.local_addr().expect("proxy address"); + listener + .set_nonblocking(true) + .expect("nonblocking listener"); + let connections = Arc::new(Connections { + stopping: AtomicBool::new(false), + streams: Mutex::new(HashMap::new()), + }); + let proxy_thread = spawn_proxy( + listener, + target_address, + format!("http://{proxy_address}"), + "pair-token".to_string(), + "runtime-token".to_string(), + network("192.168.1.20", "255.255.255.0"), + Arc::clone(&connections), + ); + + let mut client = TcpStream::connect(proxy_address).expect("proxy connection"); + client + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("read timeout"); + let mut response = String::new(); + client.read_to_string(&mut response).expect("read response"); + assert!(response.starts_with("HTTP/1.1 403"), "{response}"); + + connections.stopping.store(true, Ordering::SeqCst); + proxy_thread.join().expect("stop proxy"); + } + #[test] fn enforces_the_pairing_boundary() { use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};