From a8aea3ccb80d0f6def0413adc83d7c01d4278aba Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:24:12 -0400 Subject: [PATCH 01/25] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 93939c7..8b7da94 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,6 @@ /vcpkg /vcpkg_installed /memory/ +/.worktrees/ err.txt /System.Collections.Hashtable.Root/ From 628f51f699e2aa9a554d1ee3272ada0e4a1147bf Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:39:40 -0400 Subject: [PATCH 02/25] security: classify outbound proxy destinations --- server/Cargo.toml | 3 + server/src/lib.rs | 1 + server/src/network_security/ip.rs | 448 +++++++++++++++++++++++++++++ server/src/network_security/mod.rs | 1 + 4 files changed, 453 insertions(+) create mode 100644 server/src/network_security/ip.rs create mode 100644 server/src/network_security/mod.rs diff --git a/server/Cargo.toml b/server/Cargo.toml index 93b0a4b..4b84c03 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -32,6 +32,7 @@ tokio-util = { version = "0.7.19", features = ["io", "compat"] } hex = "0.4.3" librqbit = { version = "9.0.0", optional = true } if-addrs = "0.15.0" +ipnet = "2.12.1" urlencoding = "2.1.3" regex = "1.13.1" reqwest = { version = "0.13.4", features = ["blocking", "json", "stream"] } @@ -48,6 +49,8 @@ tempfile = "3.27.0" mimalloc = { version = "0.1.52", default-features = false } rayon = "1.12.0" sha2 = "0.11.0" +subtle = "2.6.1" +thiserror = "2.0.20" semver = "1.0.28" ratatui = "0.30.2" crossterm = "0.29.0" diff --git a/server/src/lib.rs b/server/src/lib.rs index 69db2f9..9346f5c 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -48,6 +48,7 @@ mod cache_cleaner; mod diagnostics; mod ffmpeg_setup; mod local_addon; +mod network_security; mod routes; mod ssdp; mod state; diff --git a/server/src/network_security/ip.rs b/server/src/network_security/ip.rs new file mode 100644 index 0000000..fdb5927 --- /dev/null +++ b/server/src/network_security/ip.rs @@ -0,0 +1,448 @@ +use ipnet::IpNet; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::LazyLock, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DestinationClass { + Public, + PrivateSource, + AlwaysBlocked, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct LocalNetworks { + pub(crate) interfaces: Vec, +} + +impl LocalNetworks { + pub(crate) fn contains(&self, ip: IpAddr) -> bool { + self.interfaces.iter().any(|network| network.contains(&ip)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct Nat64Prefix { + pub(crate) network: Ipv6Addr, + pub(crate) length: u8, +} + +// IANA registry snapshot: 2026-08-19 +// https://www.iana.org/assignments/iana-ipv4-special-registry +// https://www.iana.org/assignments/iana-ipv6-special-registry +const PRIVATE_V4: &[&str] = &[ + "10.0.0.0/8", + "100.64.0.0/10", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.168.0.0/16", +]; + +const ALWAYS_BLOCKED_V4: &[&str] = &[ + "0.0.0.0/8", + "192.0.0.0/24", + "192.0.2.0/24", + "192.31.196.0/24", + "192.52.193.0/24", + "192.88.99.0/24", + "192.175.48.0/24", + "198.18.0.0/15", + "198.51.100.0/24", + "203.0.113.0/24", + "224.0.0.0/4", + "240.0.0.0/4", +]; + +const METADATA_V4: &[&str] = &[ + "169.254.169.254/32", + "169.254.170.2/32", + "100.100.100.200/32", + "192.0.0.192/32", +]; + +const PRIVATE_V6: &[&str] = &["::1/128", "fc00::/7", "fe80::/10"]; + +const ALWAYS_BLOCKED_V6: &[&str] = &[ + "::/128", + "100::/64", + "100:0:0:1::/64", + "2001::/23", + "2001:db8::/32", + "2620:4f:8000::/48", + "3fff::/20", + "5f00::/16", + "ff00::/8", +]; + +const METADATA_V6: &[&str] = &["fd00:ec2::254/128"]; + +static PRIVATE_V4_NETS: LazyLock> = LazyLock::new(|| parse_networks(PRIVATE_V4)); +static ALWAYS_BLOCKED_V4_NETS: LazyLock> = + LazyLock::new(|| parse_networks(ALWAYS_BLOCKED_V4)); +static METADATA_V4_NETS: LazyLock> = LazyLock::new(|| parse_networks(METADATA_V4)); +static PRIVATE_V6_NETS: LazyLock> = LazyLock::new(|| parse_networks(PRIVATE_V6)); +static ALWAYS_BLOCKED_V6_NETS: LazyLock> = + LazyLock::new(|| parse_networks(ALWAYS_BLOCKED_V6)); +static METADATA_V6_NETS: LazyLock> = LazyLock::new(|| parse_networks(METADATA_V6)); + +fn parse_networks(values: &[&str]) -> Vec { + values + .iter() + .map(|value| value.parse().expect("hard-coded IP network is valid")) + .collect() +} + +fn contains(networks: &[IpNet], ip: IpAddr) -> bool { + networks.iter().any(|network| network.contains(&ip)) +} + +fn classify_v4(ip: Ipv4Addr, local: &LocalNetworks) -> DestinationClass { + let ip = IpAddr::V4(ip); + if contains(&METADATA_V4_NETS, ip) || contains(&ALWAYS_BLOCKED_V4_NETS, ip) { + DestinationClass::AlwaysBlocked + } else if local.contains(ip) || contains(&PRIVATE_V4_NETS, ip) { + DestinationClass::PrivateSource + } else { + DestinationClass::Public + } +} + +fn extract_6to4(ip: Ipv6Addr) -> Option { + let octets = ip.octets(); + (octets[..2] == [0x20, 0x02]).then(|| Ipv4Addr::new(octets[2], octets[3], octets[4], octets[5])) +} + +fn extract_teredo_client(ip: Ipv6Addr) -> Option { + let octets = ip.octets(); + (octets[..4] == [0x20, 0x01, 0x00, 0x00]) + .then(|| Ipv4Addr::new(!octets[12], !octets[13], !octets[14], !octets[15])) +} + +fn extract_ipv4_compatible(ip: Ipv6Addr) -> Option { + let octets = ip.octets(); + if octets[..12].iter().all(|byte| *byte == 0) { + let embedded = Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15]); + if embedded != Ipv4Addr::UNSPECIFIED && embedded != Ipv4Addr::new(0, 0, 0, 1) { + return Some(embedded); + } + } + None +} + +pub(crate) fn extract_rfc6052(ip: Ipv6Addr, prefix: Nat64Prefix) -> Option { + const VALID_LENGTHS: &[u8] = &[32, 40, 48, 56, 64, 96]; + if !VALID_LENGTHS.contains(&prefix.length) { + return None; + } + + let length = u32::from(prefix.length); + let mask = if length == 0 { + 0 + } else { + u128::MAX << (128 - length) + }; + if u128::from(ip) & mask != u128::from(prefix.network) & mask { + return None; + } + + let bytes = ip.octets(); + let embedded = match prefix.length { + 32 => [bytes[4], bytes[5], bytes[6], bytes[7]], + 40 => [bytes[5], bytes[6], bytes[7], bytes[9]], + 48 => [bytes[6], bytes[7], bytes[9], bytes[10]], + 56 => [bytes[7], bytes[9], bytes[10], bytes[11]], + 64 => [bytes[9], bytes[10], bytes[11], bytes[12]], + 96 => [bytes[12], bytes[13], bytes[14], bytes[15]], + _ => return None, + }; + if prefix.length != 96 && bytes[8] != 0 { + return None; + } + Some(embedded.into()) +} + +fn embedded_nat64(ip: Ipv6Addr, prefixes: &[Nat64Prefix]) -> Option { + const WELL_KNOWN: Nat64Prefix = Nat64Prefix { + network: Ipv6Addr::new(0x64, 0xff9b, 0, 0, 0, 0, 0, 0), + length: 96, + }; + const LOCAL_USE: Nat64Prefix = Nat64Prefix { + network: Ipv6Addr::new(0x64, 0xff9b, 1, 0, 0, 0, 0, 0), + length: 48, + }; + + extract_rfc6052(ip, WELL_KNOWN) + // RFC 8215 reserves a /48. Existing Stremio clients commonly encode the + // IPv4 value in the low 32 bits, so inspect that form as well as RFC 6052. + .or_else(|| { + let value = u128::from(ip); + let prefix = u128::from(LOCAL_USE.network); + let mask = u128::MAX << 80; + (value & mask == prefix & mask).then(|| Ipv4Addr::from(value as u32)) + }) + .or_else(|| extract_rfc6052(ip, LOCAL_USE)) + .or_else(|| { + prefixes + .iter() + .find_map(|prefix| extract_rfc6052(ip, *prefix)) + }) +} + +fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> DestinationClass { + if let Some(embedded) = ip.to_ipv4_mapped() { + return classify_v4(embedded, local); + } + if let Some(embedded) = embedded_nat64(ip, nat64) { + return classify_v4(embedded, local); + } + if extract_ipv4_compatible(ip).is_some() + || extract_6to4(ip).is_some() + || extract_teredo_client(ip).is_some() + { + return DestinationClass::AlwaysBlocked; + } + + let ip = IpAddr::V6(ip); + if contains(&METADATA_V6_NETS, ip) || contains(&ALWAYS_BLOCKED_V6_NETS, ip) { + DestinationClass::AlwaysBlocked + } else if local.contains(ip) || contains(&PRIVATE_V6_NETS, ip) { + DestinationClass::PrivateSource + } else { + DestinationClass::Public + } +} + +pub(crate) fn classify_ip( + ip: IpAddr, + local: &LocalNetworks, + nat64: &[Nat64Prefix], +) -> DestinationClass { + match ip { + IpAddr::V4(ip) => classify_v4(ip, local), + IpAddr::V6(ip) => classify_v6(ip, local, nat64), + } +} + +#[cfg(test)] +mod tests { + use super::{DestinationClass, LocalNetworks, Nat64Prefix, classify_ip}; + + #[test] + fn ipv4_special_purpose_ranges_are_not_public() { + let local = LocalNetworks::default(); + let private = [ + "10.0.0.1", + "100.64.0.1", + "127.0.0.1", + "169.254.1.1", + "172.16.0.1", + "192.168.0.1", + ]; + let always_blocked = [ + "0.0.0.0", + "192.0.0.1", + "192.0.2.1", + "192.31.196.1", + "192.52.193.1", + "192.88.99.1", + "192.175.48.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "240.0.0.1", + "255.255.255.255", + ]; + + for value in private { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::PrivateSource, + "{value}" + ); + } + for value in always_blocked { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::AlwaysBlocked, + "{value}" + ); + } + } + + #[test] + fn ipv4_metadata_precedes_private_source_ranges() { + let local = LocalNetworks::default(); + for value in [ + "169.254.169.254", + "169.254.170.2", + "100.100.100.200", + "192.0.0.192", + ] { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::AlwaysBlocked, + "{value}" + ); + } + } + + #[test] + fn ipv4_public_boundaries_remain_public() { + let local = LocalNetworks::default(); + for value in [ + "9.255.255.255", + "11.0.0.0", + "100.63.255.255", + "100.128.0.0", + "126.255.255.255", + "128.0.0.0", + "172.15.255.255", + "172.32.0.0", + "192.167.255.255", + "192.169.0.0", + "223.255.255.255", + ] { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::Public, + "{value}" + ); + } + } + + #[test] + fn ipv6_special_purpose_ranges_are_not_public() { + let local = LocalNetworks::default(); + let private = ["::1", "fc00::1", "fdff:ffff::1", "fe80::1", "febf:ffff::1"]; + let always_blocked = [ + "::", + "100::1", + "100:0:0:1::1", + "2001:db8::1", + "2620:4f:8000::1", + "3fff::1", + "5f00::1", + "ff00::1", + ]; + + for value in private { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::PrivateSource, + "{value}" + ); + } + for value in always_blocked { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::AlwaysBlocked, + "{value}" + ); + } + } + + #[test] + fn ipv6_encodings_cannot_hide_private_ipv4() { + let local = LocalNetworks::default(); + let cases = [ + "::ffff:127.0.0.1", + "::127.0.0.1", + "2002:7f00:0001::", + "2001:0000:4136:e378:8000:63bf:3fff:fdd2", + "64:ff9b::7f00:1", + "64:ff9b:1::7f00:1", + ]; + for value in cases { + assert_ne!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::Public, + "{value}" + ); + } + } + + #[test] + fn obsolete_ipv6_wrappers_are_always_blocked_even_for_public_ipv4() { + let local = LocalNetworks::default(); + for value in [ + "::93.184.216.34", + "2002:5db8:d822::", + "2001:0000:4136:e378:8000:63bf:a247:27dd", + ] { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::AlwaysBlocked, + "{value}" + ); + } + } + + #[test] + fn mapped_and_nat64_public_ipv4_remain_public() { + let local = LocalNetworks::default(); + let discovered = [Nat64Prefix { + network: "2001:db8:64::".parse().unwrap(), + length: 96, + }]; + for (value, prefixes) in [ + ("::ffff:93.184.216.34", &[][..]), + ("64:ff9b::5db8:d822", &[][..]), + ("64:ff9b:1::5db8:d822", &[][..]), + ("2001:db8:64::5db8:d822", &discovered[..]), + ] { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, prefixes), + DestinationClass::Public, + "{value}" + ); + } + } + + #[test] + fn ipv6_metadata_precedes_private_source_ranges() { + let local = LocalNetworks::default(); + assert_eq!( + classify_ip("fd00:ec2::254".parse().unwrap(), &local, &[]), + DestinationClass::AlwaysBlocked + ); + } + + #[test] + fn directly_connected_public_prefixes_are_private_sources() { + let local = LocalNetworks { + interfaces: vec![ + "8.8.8.8/29".parse().unwrap(), + "2001:4860:4860::8888/64".parse().unwrap(), + ], + }; + + for value in ["8.8.8.10", "2001:4860:4860::1"] { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::PrivateSource, + "{value}" + ); + } + for value in ["8.8.8.16", "2001:4860:4861::1"] { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::Public, + "{value}" + ); + } + } + + #[test] + fn an_always_blocked_range_cannot_be_reclassified_as_local() { + let local = LocalNetworks { + interfaces: vec!["203.0.113.8/29".parse().unwrap()], + }; + assert_eq!( + classify_ip("203.0.113.10".parse().unwrap(), &local, &[]), + DestinationClass::AlwaysBlocked + ); + } +} diff --git a/server/src/network_security/mod.rs b/server/src/network_security/mod.rs new file mode 100644 index 0000000..135a91a --- /dev/null +++ b/server/src/network_security/mod.rs @@ -0,0 +1 @@ +mod ip; From 8acd5ac4b30f1fd061436641312d9dbf248585a5 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:51:20 -0400 Subject: [PATCH 03/25] security: pin validated proxy DNS answers --- server/src/network_security/ip.rs | 9 +- server/src/network_security/mod.rs | 1 + server/src/network_security/resolver.rs | 888 ++++++++++++++++++++++++ 3 files changed, 894 insertions(+), 4 deletions(-) create mode 100644 server/src/network_security/resolver.rs diff --git a/server/src/network_security/ip.rs b/server/src/network_security/ip.rs index fdb5927..bc1a61f 100644 --- a/server/src/network_security/ip.rs +++ b/server/src/network_security/ip.rs @@ -190,11 +190,12 @@ fn embedded_nat64(ip: Ipv6Addr, prefixes: &[Nat64Prefix]) -> Option { }) } +pub(crate) fn normalized_embedded_ipv4(ip: Ipv6Addr, nat64: &[Nat64Prefix]) -> Option { + ip.to_ipv4_mapped().or_else(|| embedded_nat64(ip, nat64)) +} + fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> DestinationClass { - if let Some(embedded) = ip.to_ipv4_mapped() { - return classify_v4(embedded, local); - } - if let Some(embedded) = embedded_nat64(ip, nat64) { + if let Some(embedded) = normalized_embedded_ipv4(ip, nat64) { return classify_v4(embedded, local); } if extract_ipv4_compatible(ip).is_some() diff --git a/server/src/network_security/mod.rs b/server/src/network_security/mod.rs index 135a91a..a6c19a2 100644 --- a/server/src/network_security/mod.rs +++ b/server/src/network_security/mod.rs @@ -1 +1,2 @@ mod ip; +mod resolver; diff --git a/server/src/network_security/resolver.rs b/server/src/network_security/resolver.rs new file mode 100644 index 0000000..38350da --- /dev/null +++ b/server/src/network_security/resolver.rs @@ -0,0 +1,888 @@ +use super::ip::{DestinationClass, LocalNetworks, Nat64Prefix, extract_rfc6052}; +use async_trait::async_trait; +use std::{ + io, + net::{IpAddr, SocketAddr}, + sync::Arc, + time::{Duration, Instant}, +}; +use url::{Host, Url}; + +const VALIDATION_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_DNS_ANSWERS: usize = 32; +const NAT64_CACHE_TTL: Duration = Duration::from_secs(5 * 60); + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct OutboundPolicy { + pub(crate) allow_private_network_sources: bool, +} + +#[async_trait] +pub(crate) trait DnsResolver: Send + Sync { + async fn resolve(&self, host: &str, port: u16) -> io::Result>; +} + +#[async_trait] +pub(crate) trait LocalNetworkProvider: Send + Sync { + async fn current(&self) -> io::Result; +} + +pub(crate) trait Clock: Send + Sync { + fn now(&self) -> Instant; +} + +pub(crate) struct SystemClock; + +impl Clock for SystemClock { + fn now(&self) -> Instant { + Instant::now() + } +} + +pub(crate) struct SystemDnsResolver; + +#[async_trait] +impl DnsResolver for SystemDnsResolver { + async fn resolve(&self, host: &str, port: u16) -> io::Result> { + let mut addresses: Vec<_> = tokio::net::lookup_host((host, port)).await?.collect(); + if addresses.is_empty() || addresses.len() > MAX_DNS_ANSWERS { + return Err(io::Error::other("DNS answer count is outside policy")); + } + addresses.sort_unstable(); + addresses.dedup(); + Ok(addresses) + } +} + +pub(crate) struct SystemLocalNetworkProvider; + +#[async_trait] +impl LocalNetworkProvider for SystemLocalNetworkProvider { + async fn current(&self) -> io::Result { + tokio::task::spawn_blocking(|| { + let interfaces = if_addrs::get_if_addrs()?; + Ok(LocalNetworks { + interfaces: interfaces + .iter() + .filter_map(network_for_interface) + .collect(), + }) + }) + .await + .map_err(|error| io::Error::other(format!("interface worker failed: {error}")))? + } +} + +fn network_for_interface(interface: &if_addrs::Interface) -> Option { + let eligible = interface.is_oper_up() + || (interface.is_loopback() && interface.oper_status == if_addrs::IfOperStatus::Unknown); + if !eligible { + return None; + } + + let (ip, prefix) = match &interface.addr { + if_addrs::IfAddr::V4(address) => (IpAddr::V4(address.ip), address.prefixlen), + if_addrs::IfAddr::V6(address) => (IpAddr::V6(address.ip), address.prefixlen), + }; + ipnet::IpNet::new(ip, prefix) + .ok() + .map(|network| network.trunc()) +} + +#[derive(Clone, Debug)] +pub(crate) struct ResolvedDestination { + pub(crate) url: Url, + pub(crate) domain: Option, + pub(crate) addrs: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ListenerBinding { + pub(crate) address: IpAddr, + pub(crate) port: u16, +} + +#[derive(thiserror::Error, Debug, Eq, PartialEq)] +pub(crate) enum DestinationError { + #[error("unsupported URL scheme")] + UnsupportedScheme, + #[error("target host is missing")] + MissingHost, + #[error("target name did not resolve")] + ResolutionFailed, + #[error("target address is blocked")] + Blocked, + #[error("local network state is unavailable")] + LocalNetworkUnavailable, +} + +pub(crate) struct DestinationValidator { + resolver: Arc, + local_networks: Arc, + clock: Arc, + listeners: Vec, + nat64_cache: tokio::sync::Mutex>, + nat64_refresh: tokio::sync::Mutex<()>, +} + +struct CachedNat64Prefixes { + expires_at: Instant, + prefixes: Vec, +} + +impl DestinationValidator { + pub(crate) fn new( + resolver: Arc, + local_networks: Arc, + clock: Arc, + listeners: Vec, + ) -> Self { + Self { + resolver, + local_networks, + clock, + listeners, + nat64_cache: tokio::sync::Mutex::new(None), + nat64_refresh: tokio::sync::Mutex::new(()), + } + } + + pub(crate) async fn validate( + &self, + url: &Url, + policy: OutboundPolicy, + ) -> Result { + tokio::time::timeout(VALIDATION_TIMEOUT, self.validate_inner(url, policy)) + .await + .map_err(|_| DestinationError::ResolutionFailed)? + } + + async fn validate_inner( + &self, + url: &Url, + policy: OutboundPolicy, + ) -> Result { + if !matches!(url.scheme(), "http" | "https") { + return Err(DestinationError::UnsupportedScheme); + } + let host = url.host().ok_or(DestinationError::MissingHost)?; + let port = url + .port_or_known_default() + .ok_or(DestinationError::MissingHost)?; + + let mut canonical_url = url.clone(); + canonical_url.set_fragment(None); + + let (domain, addresses) = match host { + Host::Ipv4(ip) => (None, vec![SocketAddr::new(IpAddr::V4(ip), port)]), + Host::Ipv6(ip) => (None, vec![SocketAddr::new(IpAddr::V6(ip), port)]), + Host::Domain(domain) => { + let domain = domain.to_owned(); + let work = async { + tokio::join!( + self.local_networks.current(), + self.resolver.resolve(&domain, port) + ) + }; + let (local, resolved) = work.await; + let local = local.map_err(|_| DestinationError::LocalNetworkUnavailable)?; + let mut resolved = resolved.map_err(|_| DestinationError::ResolutionFailed)?; + if resolved.is_empty() || resolved.len() > MAX_DNS_ANSWERS { + return Err(DestinationError::ResolutionFailed); + } + for address in &mut resolved { + address.set_port(port); + } + resolved.sort_unstable(); + resolved.dedup(); + let nat64 = self.nat64_prefixes_for(&resolved).await; + self.validate_addresses(&resolved, &local, &nat64, policy)?; + return Ok(ResolvedDestination { + url: canonical_url, + domain: Some(domain), + addrs: resolved, + }); + } + }; + + let local = self + .local_networks + .current() + .await + .map_err(|_| DestinationError::LocalNetworkUnavailable)?; + let nat64 = self.nat64_prefixes_for(&addresses).await; + self.validate_addresses(&addresses, &local, &nat64, policy)?; + Ok(ResolvedDestination { + url: canonical_url, + domain, + addrs: addresses, + }) + } + + fn validate_addresses( + &self, + addresses: &[SocketAddr], + local: &LocalNetworks, + nat64: &[Nat64Prefix], + policy: OutboundPolicy, + ) -> Result<(), DestinationError> { + for address in addresses { + if self.matches_listener(*address, local, nat64) { + return Err(DestinationError::Blocked); + } + match super::ip::classify_ip(address.ip(), local, nat64) { + DestinationClass::Public => {} + DestinationClass::PrivateSource if policy.allow_private_network_sources => {} + DestinationClass::PrivateSource | DestinationClass::AlwaysBlocked => { + return Err(DestinationError::Blocked); + } + } + } + Ok(()) + } + + fn matches_listener( + &self, + target: SocketAddr, + local: &LocalNetworks, + nat64: &[Nat64Prefix], + ) -> bool { + let target_ip = normalized_listener_ip(target.ip(), nat64); + self.listeners.iter().any(|listener| { + if listener.port != target.port() { + return false; + } + + let listener_ip = normalized_listener_ip(listener.address, nat64); + if listener.address.is_unspecified() { + return match (listener.address, target_ip) { + (IpAddr::V4(_), IpAddr::V4(ip)) => { + ip.is_loopback() || local.contains(IpAddr::V4(ip)) + } + (IpAddr::V6(_), IpAddr::V6(ip)) => { + ip.is_loopback() || local.contains(IpAddr::V6(ip)) + } + _ => false, + }; + } + + listener_ip == target_ip + }) + } + + async fn nat64_prefixes_for(&self, addresses: &[SocketAddr]) -> Vec { + if !addresses.iter().any(SocketAddr::is_ipv6) { + return Vec::new(); + } + + if let Some(prefixes) = self.cached_nat64_prefixes().await { + return prefixes; + } + + let _refresh = self.nat64_refresh.lock().await; + if let Some(prefixes) = self.cached_nat64_prefixes().await { + return prefixes; + } + + let prefixes = self + .resolver + .resolve("ipv4only.arpa", 0) + .await + .ok() + .filter(|answers| !answers.is_empty() && answers.len() <= MAX_DNS_ANSWERS) + .map(|answers| discover_nat64_prefixes(&answers)) + .unwrap_or_default(); + + let expires_at = self + .clock + .now() + .checked_add(NAT64_CACHE_TTL) + .unwrap_or_else(|| self.clock.now()); + *self.nat64_cache.lock().await = Some(CachedNat64Prefixes { + expires_at, + prefixes: prefixes.clone(), + }); + prefixes + } + + async fn cached_nat64_prefixes(&self) -> Option> { + let cache = self.nat64_cache.lock().await; + cache + .as_ref() + .filter(|cached| self.clock.now() < cached.expires_at) + .map(|cached| cached.prefixes.clone()) + } +} + +fn normalized_listener_ip(ip: IpAddr, nat64: &[Nat64Prefix]) -> IpAddr { + match ip { + IpAddr::V6(ip) => super::ip::normalized_embedded_ipv4(ip, nat64) + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)), + ip => ip, + } +} + +fn discover_nat64_prefixes(answers: &[SocketAddr]) -> Vec { + const IPV4ONLY: [std::net::Ipv4Addr; 2] = [ + std::net::Ipv4Addr::new(192, 0, 0, 170), + std::net::Ipv4Addr::new(192, 0, 0, 171), + ]; + const LENGTHS: [u8; 6] = [32, 40, 48, 56, 64, 96]; + + let ipv6_answers: Vec<_> = answers + .iter() + .filter_map(|answer| match answer.ip() { + IpAddr::V6(ip) => Some(ip), + IpAddr::V4(_) => None, + }) + .collect(); + let mut prefixes = Vec::new(); + for length in LENGTHS { + let mask = u128::MAX << (128 - u32::from(length)); + for address in &ipv6_answers { + let prefix = Nat64Prefix { + network: std::net::Ipv6Addr::from(u128::from(*address) & mask), + length, + }; + let mut seen = [false; 2]; + for candidate in &ipv6_answers { + if let Some(extracted) = extract_rfc6052(*candidate, prefix) { + if extracted == IPV4ONLY[0] { + seen[0] = true; + } else if extracted == IPV4ONLY[1] { + seen[1] = true; + } + } + } + if seen == [true, true] + && !prefixes + .iter() + .any(|existing: &Nat64Prefix| *existing == prefix) + { + prefixes.push(prefix); + } + } + } + prefixes.sort_unstable_by_key(|prefix| (prefix.length, u128::from(prefix.network))); + prefixes +} + +#[cfg(test)] +mod tests { + use super::super::ip::LocalNetworks; + use super::{ + Clock, DestinationError, DestinationValidator, DnsResolver, ListenerBinding, + LocalNetworkProvider, OutboundPolicy, network_for_interface, + }; + use async_trait::async_trait; + use std::{ + io, + net::SocketAddr, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, + }; + use url::Url; + + struct FakeResolver { + answer: Vec, + fail: bool, + calls: AtomicUsize, + } + + struct BlockingResolver { + answer: Vec, + calls: AtomicUsize, + started: tokio::sync::Notify, + release: tokio::sync::Notify, + } + + struct SlowThenStalledResolver; + + #[async_trait] + impl DnsResolver for SlowThenStalledResolver { + async fn resolve(&self, host: &str, _port: u16) -> io::Result> { + if host == "slow.example" { + tokio::time::sleep(Duration::from_secs(4)).await; + Ok(vec!["[2001:4860:4860::8888]:80".parse().unwrap()]) + } else { + std::future::pending().await + } + } + } + + #[async_trait] + impl DnsResolver for BlockingResolver { + async fn resolve(&self, _host: &str, _port: u16) -> io::Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + self.started.notify_one(); + self.release.notified().await; + Ok(self.answer.clone()) + } + } + + impl FakeResolver { + fn new(answer: Vec) -> Arc { + Arc::new(Self { + answer, + fail: false, + calls: AtomicUsize::new(0), + }) + } + + fn failing() -> Arc { + Arc::new(Self { + answer: Vec::new(), + fail: true, + calls: AtomicUsize::new(0), + }) + } + } + + #[async_trait] + impl DnsResolver for FakeResolver { + async fn resolve(&self, _host: &str, _port: u16) -> io::Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.fail { + Err(io::Error::other("synthetic resolver failure")) + } else { + Ok(self.answer.clone()) + } + } + } + + struct StaticLocalNetworks(LocalNetworks); + + #[async_trait] + impl LocalNetworkProvider for StaticLocalNetworks { + async fn current(&self) -> io::Result { + Ok(self.0.clone()) + } + } + + struct FixedClock(Instant); + + impl Clock for FixedClock { + fn now(&self) -> Instant { + self.0 + } + } + + struct ManualClock(Mutex); + + impl ManualClock { + fn new() -> Arc { + Arc::new(Self(Mutex::new(Instant::now()))) + } + + fn advance(&self, duration: Duration) { + let mut now = self.0.lock().unwrap(); + *now = now.checked_add(duration).unwrap(); + } + } + + impl Clock for ManualClock { + fn now(&self) -> Instant { + *self.0.lock().unwrap() + } + } + + fn validator(resolver: Arc) -> DestinationValidator { + DestinationValidator::new( + resolver, + Arc::new(StaticLocalNetworks(LocalNetworks::default())), + Arc::new(FixedClock(Instant::now())), + Vec::new(), + ) + } + + fn validator_with_listeners( + resolver: Arc, + local: LocalNetworks, + listeners: Vec, + ) -> DestinationValidator { + DestinationValidator::new( + resolver, + Arc::new(StaticLocalNetworks(local)), + Arc::new(FixedClock(Instant::now())), + listeners, + ) + } + + #[tokio::test] + async fn unsupported_scheme_is_rejected_before_resolution() { + let resolver = FakeResolver::new(vec!["93.184.216.34:80".parse().unwrap()]); + let validator = validator(resolver.clone()); + let result = validator + .validate( + &Url::parse("file:///etc/passwd").unwrap(), + OutboundPolicy::default(), + ) + .await; + assert_eq!(result.unwrap_err(), DestinationError::UnsupportedScheme); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn integer_loopback_is_blocked_before_connect() { + let resolver = FakeResolver::new(Vec::new()); + let validator = validator(resolver.clone()); + let result = validator + .validate( + &Url::parse("http://2130706433/").unwrap(), + OutboundPolicy::default(), + ) + .await; + assert_eq!(result.unwrap_err(), DestinationError::Blocked); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn mixed_public_and_loopback_dns_answer_is_rejected() { + let validator = validator(FakeResolver::new(vec![ + "93.184.216.34:80".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ])); + let result = validator + .validate( + &Url::parse("http://mixed.example/").unwrap(), + OutboundPolicy::default(), + ) + .await; + assert_eq!(result.unwrap_err(), DestinationError::Blocked); + } + + #[tokio::test] + async fn private_answers_require_opt_in_but_metadata_never_does() { + let private_url = Url::parse("http://private.example/").unwrap(); + let private = validator(FakeResolver::new(vec!["10.1.2.3:80".parse().unwrap()])); + assert_eq!( + private + .validate(&private_url, OutboundPolicy::default()) + .await + .unwrap_err(), + DestinationError::Blocked + ); + assert!( + private + .validate( + &private_url, + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .is_ok() + ); + + let metadata = validator(FakeResolver::new(vec![ + "169.254.169.254:80".parse().unwrap(), + ])); + assert_eq!( + metadata + .validate( + &Url::parse("http://metadata.example/").unwrap(), + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + } + + #[tokio::test] + async fn empty_failed_and_oversized_dns_answers_fail_closed() { + for resolver in [ + FakeResolver::new(Vec::new()), + FakeResolver::failing(), + FakeResolver::new( + (1..=33) + .map(|last| SocketAddr::from(([93, 184, 216, last], 80))) + .collect(), + ), + ] { + let result = validator(resolver) + .validate( + &Url::parse("http://failure.example/").unwrap(), + OutboundPolicy::default(), + ) + .await; + assert_eq!(result.unwrap_err(), DestinationError::ResolutionFailed); + } + } + + #[tokio::test] + async fn canonical_result_strips_fragments_and_deduplicates_pinned_addresses() { + let validator = validator(FakeResolver::new(vec![ + "93.184.216.34:1234".parse().unwrap(), + "93.184.216.34:4321".parse().unwrap(), + ])); + let result = validator + .validate( + &Url::parse("HTTP://ExAmPle.COM.:8080/path#never-forwarded").unwrap(), + OutboundPolicy::default(), + ) + .await + .unwrap(); + assert_eq!(result.url.as_str(), "http://example.com.:8080/path"); + assert_eq!(result.domain.as_deref(), Some("example.com.")); + assert_eq!(result.addrs, vec!["93.184.216.34:8080".parse().unwrap()]); + } + + #[tokio::test] + async fn exact_and_wildcard_self_listeners_are_always_blocked() { + let local = LocalNetworks { + interfaces: vec!["8.8.8.8/29".parse().unwrap()], + }; + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + + let exact = validator_with_listeners( + FakeResolver::new(vec!["93.184.216.34:11470".parse().unwrap()]), + LocalNetworks::default(), + vec![ListenerBinding { + address: "93.184.216.34".parse().unwrap(), + port: 11470, + }], + ); + assert_eq!( + exact + .validate(&Url::parse("http://self.example:11470/").unwrap(), policy) + .await + .unwrap_err(), + DestinationError::Blocked + ); + + let wildcard = validator_with_listeners( + FakeResolver::new(vec!["8.8.8.10:11470".parse().unwrap()]), + local, + vec![ListenerBinding { + address: "0.0.0.0".parse().unwrap(), + port: 11470, + }], + ); + assert_eq!( + wildcard + .validate( + &Url::parse("http://local-interface.example:11470/").unwrap(), + policy, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + } + + #[tokio::test] + async fn a_different_port_on_the_listener_host_remains_eligible() { + let validator = validator_with_listeners( + FakeResolver::new(vec!["93.184.216.34:8080".parse().unwrap()]), + LocalNetworks::default(), + vec![ListenerBinding { + address: "93.184.216.34".parse().unwrap(), + port: 11470, + }], + ); + assert!( + validator + .validate( + &Url::parse("http://same-host.example:8080/").unwrap(), + OutboundPolicy::default(), + ) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn mapped_and_nat64_forms_cannot_hide_an_exact_self_listener() { + let validator = validator_with_listeners( + FakeResolver::new(Vec::new()), + LocalNetworks::default(), + vec![ListenerBinding { + address: "93.184.216.34".parse().unwrap(), + port: 80, + }], + ); + for target in [ + "http://[::ffff:93.184.216.34]/", + "http://[64:ff9b::5db8:d822]/", + ] { + assert_eq!( + validator + .validate(&Url::parse(target).unwrap(), OutboundPolicy::default()) + .await + .unwrap_err(), + DestinationError::Blocked, + "{target}" + ); + } + } + + #[tokio::test] + async fn discovered_nat64_prefix_exposes_embedded_metadata_and_is_cached() { + let resolver = FakeResolver::new(vec![ + "[2001:4860:64::c000:aa]:0".parse().unwrap(), + "[2001:4860:64::c000:ab]:0".parse().unwrap(), + ]); + let validator = validator(resolver.clone()); + let target = Url::parse("http://[2001:4860:64::a9fe:a9fe]/").unwrap(); + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + + for _ in 0..2 { + assert_eq!( + validator.validate(&target, policy).await.unwrap_err(), + DestinationError::Blocked + ); + } + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn nat64_discovery_cache_refreshes_after_five_minutes() { + let resolver = FakeResolver::new(vec![ + "[2001:4860:64::c000:aa]:0".parse().unwrap(), + "[2001:4860:64::c000:ab]:0".parse().unwrap(), + ]); + let clock = ManualClock::new(); + let validator = DestinationValidator::new( + resolver.clone(), + Arc::new(StaticLocalNetworks(LocalNetworks::default())), + clock.clone(), + Vec::new(), + ); + let target = Url::parse("http://[2001:4860:64::a9fe:a9fe]/").unwrap(); + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + + assert!(validator.validate(&target, policy).await.is_err()); + clock.advance(Duration::from_secs(299)); + assert!(validator.validate(&target, policy).await.is_err()); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + clock.advance(Duration::from_secs(2)); + assert!(validator.validate(&target, policy).await.is_err()); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn concurrent_nat64_cache_misses_share_one_discovery() { + let resolver = Arc::new(BlockingResolver { + answer: vec![ + "[2001:4860:64::c000:aa]:0".parse().unwrap(), + "[2001:4860:64::c000:ab]:0".parse().unwrap(), + ], + calls: AtomicUsize::new(0), + started: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + }); + let validator = Arc::new(DestinationValidator::new( + resolver.clone(), + Arc::new(StaticLocalNetworks(LocalNetworks::default())), + Arc::new(FixedClock(Instant::now())), + Vec::new(), + )); + let target = Url::parse("http://[2001:4860:64::a9fe:a9fe]/").unwrap(); + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + let mut tasks = Vec::new(); + for _ in 0..32 { + let validator = validator.clone(); + let target = target.clone(); + tasks.push(tokio::spawn(async move { + validator.validate(&target, policy).await + })); + } + + resolver.started.notified().await; + tokio::task::yield_now().await; + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + resolver.release.notify_waiters(); + for task in tasks { + assert_eq!(task.await.unwrap().unwrap_err(), DestinationError::Blocked); + } + } + + #[test] + fn local_interface_snapshots_ignore_down_links_but_keep_unknown_loopback() { + fn interface( + ip: std::net::Ipv4Addr, + prefixlen: u8, + oper_status: if_addrs::IfOperStatus, + ) -> if_addrs::Interface { + if_addrs::Interface { + name: "test".to_owned(), + addr: if_addrs::IfAddr::V4(if_addrs::Ifv4Addr { + ip, + netmask: std::net::Ipv4Addr::UNSPECIFIED, + prefixlen, + broadcast: None, + }), + index: None, + oper_status, + is_p2p: false, + #[cfg(windows)] + adapter_name: "test".to_owned(), + } + } + + let up = interface("8.8.8.8".parse().unwrap(), 24, if_addrs::IfOperStatus::Up); + assert_eq!( + network_for_interface(&up).unwrap().to_string(), + "8.8.8.0/24" + ); + + let down = interface("8.8.4.4".parse().unwrap(), 24, if_addrs::IfOperStatus::Down); + assert!(network_for_interface(&down).is_none()); + + let unknown_loopback = interface( + std::net::Ipv4Addr::LOCALHOST, + 8, + if_addrs::IfOperStatus::Unknown, + ); + assert!(network_for_interface(&unknown_loopback).is_some()); + + let unknown_public = interface( + "1.1.1.1".parse().unwrap(), + 24, + if_addrs::IfOperStatus::Unknown, + ); + assert!(network_for_interface(&unknown_public).is_none()); + } + + #[tokio::test(start_paused = true)] + async fn dns_interfaces_and_nat64_share_one_five_second_budget() { + let validator = Arc::new(DestinationValidator::new( + Arc::new(SlowThenStalledResolver), + Arc::new(StaticLocalNetworks(LocalNetworks::default())), + Arc::new(FixedClock(Instant::now())), + Vec::new(), + )); + let task = tokio::spawn(async move { + validator + .validate( + &Url::parse("http://slow.example/").unwrap(), + OutboundPolicy::default(), + ) + .await + }); + + tokio::task::yield_now().await; + tokio::time::advance(Duration::from_secs(5)).await; + tokio::task::yield_now().await; + assert!(task.is_finished(), "validation exceeded the per-hop budget"); + assert_eq!( + task.await.unwrap().unwrap_err(), + DestinationError::ResolutionFailed + ); + } +} From c11b5623aeec8e6c6751a8e5a948f89e9140435c Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:07:26 -0400 Subject: [PATCH 04/25] security: protect proxy policy settings --- Cargo.lock | 3 + server/Cargo.toml | 2 +- server/src/diagnostics/logging.rs | 77 ++++++- server/src/lib.rs | 44 +++- server/src/network_security/mod.rs | 12 ++ server/src/network_security/runtime.rs | 236 +++++++++++++++++++++ server/src/routes/system.rs | 280 +++++++++++++++++++++++-- server/src/settings_control.rs | 275 ++++++++++++++++++++++++ server/src/state.rs | 92 +++++--- server/src/tray.rs | 12 +- 10 files changed, 973 insertions(+), 60 deletions(-) create mode 100644 server/src/network_security/runtime.rs create mode 100644 server/src/settings_control.rs diff --git a/Cargo.lock b/Cargo.lock index 59dfba3..6fe05bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7840,6 +7840,7 @@ dependencies = [ "ico", "if-addrs", "image", + "ipnet", "jni 0.22.4", "librqbit", "lz-str", @@ -7861,10 +7862,12 @@ dependencies = [ "settings-gui", "sha2 0.11.0", "ssdp-client", + "subtle", "sysinfo", "tao", "tar", "tempfile", + "thiserror 2.0.20", "tokio", "tokio-native-tls", "tokio-util", diff --git a/server/Cargo.toml b/server/Cargo.toml index 4b84c03..aa08f28 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,7 +28,7 @@ tracing-appender = "0.2.5" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" anyhow = "1.0.104" -tokio-util = { version = "0.7.19", features = ["io", "compat"] } +tokio-util = { version = "0.7.19", features = ["io", "compat", "rt"] } hex = "0.4.3" librqbit = { version = "9.0.0", optional = true } if-addrs = "0.15.0" diff --git a/server/src/diagnostics/logging.rs b/server/src/diagnostics/logging.rs index 779fa66..cf7ca5b 100644 --- a/server/src/diagnostics/logging.rs +++ b/server/src/diagnostics/logging.rs @@ -41,7 +41,11 @@ fn format_headers(headers: &axum::http::HeaderMap) -> String { fn is_sensitive(name: &axum::http::HeaderName) -> bool { matches!( name.as_str(), - "authorization" | "proxy-authorization" | "cookie" | "set-cookie" + "authorization" + | "proxy-authorization" + | "cookie" + | "set-cookie" + | "x-stream-server-settings-token" ) } @@ -64,6 +68,27 @@ fn format_headers(headers: &axum::http::HeaderMap) -> String { out } +pub(crate) struct SanitizedRequestTarget { + pub(crate) uri: String, + pub(crate) path: String, + pub(crate) query: String, +} + +pub(crate) fn sanitize_request_target(uri: &axum::http::Uri) -> SanitizedRequestTarget { + if uri.path().starts_with("/proxy") { + return SanitizedRequestTarget { + uri: "/proxy/".to_owned(), + path: "/proxy/".to_owned(), + query: String::new(), + }; + } + SanitizedRequestTarget { + uri: uri.to_string(), + path: uri.path().to_owned(), + query: uri.query().unwrap_or("").to_owned(), + } +} + /// Emit an ERROR log describing an unhandled route or request (a 404 fallback, /// a 405 method mismatch, or a catch-all route that matched but could not be /// served) with as much request context as is available: peer address, method, @@ -90,15 +115,16 @@ pub fn log_unhandled( let peer = peer .map(|p| p.to_string()) .unwrap_or_else(|| "unknown".to_string()); + let request_target = sanitize_request_target(uri); tracing::error!( reason, status, peer = %peer, method = %method, - uri = %uri, - path = uri.path(), - query = uri.query().unwrap_or(""), + uri = %request_target.uri, + path = %request_target.path, + query = %request_target.query, version = ?version, host = %header_value(&header::HOST), user_agent = %header_value(&header::USER_AGENT), @@ -367,3 +393,46 @@ unsafe extern "system" fn windows_exception_filter( pub const MEMORY_SNAPSHOT_INTERVAL: Duration = Duration::from_secs(60); pub const MEMORY_GROWTH_ALERT_BYTES: u64 = 128 * 1024 * 1024; + +#[cfg(test)] +mod tests { + use super::{format_headers, sanitize_request_target}; + use axum::http::{HeaderMap, HeaderValue, Uri}; + + #[test] + fn settings_control_header_is_redacted() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-stream-server-settings-token", + HeaderValue::from_static( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + ); + let rendered = format_headers(&headers); + assert_eq!( + rendered, + "x-stream-server-settings-token=" + ); + assert!(!rendered.contains("aaaaaaaa")); + } + + #[test] + fn proxy_targets_are_redacted_before_extractors_run() { + let uri: Uri = "/proxy/d=http%3A%2F%2Fuser%3Asecret%40host/private?token=secret" + .parse() + .unwrap(); + let target = sanitize_request_target(&uri); + assert_eq!(target.uri, "/proxy/"); + assert_eq!(target.path, "/proxy/"); + assert_eq!(target.query, ""); + } + + #[test] + fn ordinary_request_targets_keep_diagnostic_context() { + let uri: Uri = "/heartbeat?probe=1".parse().unwrap(); + let target = sanitize_request_target(&uri); + assert_eq!(target.uri, "/heartbeat?probe=1"); + assert_eq!(target.path, "/heartbeat"); + assert_eq!(target.query, "probe=1"); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 9346f5c..0d3a2f6 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -50,6 +50,7 @@ mod ffmpeg_setup; mod local_addon; mod network_security; mod routes; +mod settings_control; mod ssdp; mod state; mod tui; @@ -381,11 +382,14 @@ async fn run_inner( }; let settings = AppState::load_settings(&config_dir, &default_settings); + let settings_control = settings_control::SettingsControl::load_or_create(&config_dir)?; let settings_arc = Arc::new(tokio::sync::RwLock::new(settings.clone())); let settings_path = config_dir.join("settings.json"); - let tracker_storage = Arc::new(state::TrackerStorageBridge::new( + let settings_persistence = Arc::new(tokio::sync::Mutex::new(())); + let tracker_storage = Arc::new(state::TrackerStorageBridge::new_with_persistence( settings_arc.clone(), settings_path.clone(), + settings_persistence.clone(), )); let backend_config = enginefs::backend::BackendConfig { @@ -467,6 +471,36 @@ async fn run_inner( state.base_url = base_url.clone(); state.http_addr = public_http_addr; state.update_install_exit_enabled = cfg.enable_update_exit; + state.settings_control = settings_control; + state.settings_persistence = settings_persistence; + let https_cert_path = config_dir.join("https-cert.pem"); + let https_key_path = config_dir.join("https-key.pem"); + let mut listeners = vec![network_security::ListenerBinding { + address: bound_http_addr.ip(), + port: bound_http_addr.port(), + }]; + if https_cert_path.exists() + && https_key_path.exists() + && let Some(https_addr) = cfg.https_addr + { + listeners.push(network_security::ListenerBinding { + address: https_addr.ip(), + port: https_addr.port(), + }); + } + let validator = Arc::new(network_security::DestinationValidator::new( + Arc::new(network_security::SystemDnsResolver), + Arc::new(network_security::SystemLocalNetworkProvider), + Arc::new(network_security::SystemClock), + listeners, + )); + state.proxy_runtime = Arc::new(network_security::ProxyRuntime::new( + network_security::ProxyPolicySettings { + allow_private_network_sources: settings.allow_private_network_sources, + allow_invalid_proxy_tls_certificates: settings.allow_invalid_proxy_tls_certificates, + }, + validator, + )); #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] { @@ -619,9 +653,6 @@ async fn run_inner( let _ = shutdown_started_tx.send(source); }; - let https_cert_path = config_dir.join("https-cert.pem"); - let https_key_path = config_dir.join("https-key.pem"); - if let Some(https_addr) = cfg.https_addr { if https_cert_path.exists() && https_key_path.exists() { tracing::info!("Found HTTPS certificates, starting HTTPS server on {https_addr}"); @@ -839,7 +870,7 @@ pub fn build_router(state: AppState) -> Router { .nest("/tgz", routes::archive::router()) .nest("/nzb", routes::nzb::router()) .nest("/local-addon", local_addon::get_router()) - .nest("/proxy", routes::proxy::router()) + .merge(routes::proxy::router()) .nest("/ftp", routes::ftp::router()) .route("/samples/{filename}", get(routes::system::get_samples)) .route("/hlsv2/status", get(routes::hls::hls_status)) @@ -881,10 +912,11 @@ pub fn build_router(state: AppState) -> Router { .method_not_allowed_fallback(method_not_allowed_handler) .layer( TraceLayer::new_for_http().make_span_with(|request: &axum::http::Request<_>| { + let target = diagnostics::logging::sanitize_request_target(request.uri()); tracing::info_span!( "request", method = %request.method(), - path = request.uri().path(), + path = %target.path, ) }), ) diff --git a/server/src/network_security/mod.rs b/server/src/network_security/mod.rs index a6c19a2..1b51268 100644 --- a/server/src/network_security/mod.rs +++ b/server/src/network_security/mod.rs @@ -1,2 +1,14 @@ mod ip; mod resolver; +mod runtime; + +pub(crate) use resolver::{ + DestinationError, DestinationValidator, ListenerBinding, SystemClock, SystemDnsResolver, + SystemLocalNetworkProvider, +}; +pub(crate) use runtime::{ProxyPolicySettings, ProxyRequestContext, ProxyRuntime}; + +#[cfg(test)] +pub(crate) use ip::LocalNetworks; +#[cfg(test)] +pub(crate) use resolver::{Clock, DnsResolver, LocalNetworkProvider}; diff --git a/server/src/network_security/runtime.rs b/server/src/network_security/runtime.rs new file mode 100644 index 0000000..034a704 --- /dev/null +++ b/server/src/network_security/runtime.rs @@ -0,0 +1,236 @@ +use super::resolver::{ + DestinationError, DestinationValidator, OutboundPolicy, ResolvedDestination, +}; +use std::sync::{Arc, Mutex}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tokio_util::sync::CancellationToken; +use url::Url; + +const MAX_CONCURRENT_PROXY_REQUESTS: usize = 64; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProxyPolicySettings { + pub(crate) allow_private_network_sources: bool, + pub(crate) allow_invalid_proxy_tls_certificates: bool, +} + +pub(crate) struct ProxyRequestContext { + pub(crate) settings: ProxyPolicySettings, + pub(crate) cancellation: CancellationToken, + pub(crate) capacity: OwnedSemaphorePermit, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ProxyCapacityError; + +struct ProxyGeneration { + settings: ProxyPolicySettings, + cancellation: CancellationToken, +} + +pub(crate) struct ProxyRuntime { + validator: Arc, + capacity: Arc, + generation: Mutex, +} + +impl ProxyRuntime { + pub(crate) fn new(settings: ProxyPolicySettings, validator: Arc) -> Self { + Self { + validator, + capacity: Arc::new(Semaphore::new(MAX_CONCURRENT_PROXY_REQUESTS)), + generation: Mutex::new(ProxyGeneration { + settings, + cancellation: CancellationToken::new(), + }), + } + } + + pub(crate) fn try_request(&self) -> Result { + let capacity = self + .capacity + .clone() + .try_acquire_owned() + .map_err(|_| ProxyCapacityError)?; + let generation = self + .generation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Ok(ProxyRequestContext { + settings: generation.settings, + cancellation: generation.cancellation.clone(), + capacity, + }) + } + + pub(crate) async fn validate( + &self, + context: &ProxyRequestContext, + url: &Url, + ) -> Result { + let policy = OutboundPolicy { + allow_private_network_sources: context.settings.allow_private_network_sources, + }; + tokio::select! { + biased; + _ = context.cancellation.cancelled() => Err(DestinationError::ResolutionFailed), + result = self.validator.validate(url, policy) => result, + } + } + + pub(crate) fn begin_reconfigure(&self, next: ProxyPolicySettings) { + let mut generation = self + .generation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let intersection = ProxyPolicySettings { + allow_private_network_sources: generation.settings.allow_private_network_sources + && next.allow_private_network_sources, + allow_invalid_proxy_tls_certificates: generation + .settings + .allow_invalid_proxy_tls_certificates + && next.allow_invalid_proxy_tls_certificates, + }; + if intersection != generation.settings { + generation.cancellation.cancel(); + *generation = ProxyGeneration { + settings: intersection, + cancellation: CancellationToken::new(), + }; + } + } + + pub(crate) fn finish_reconfigure(&self, next: ProxyPolicySettings) { + let mut generation = self + .generation + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if generation.settings == next { + return; + } + let restrictive = (generation.settings.allow_private_network_sources + && !next.allow_private_network_sources) + || (generation.settings.allow_invalid_proxy_tls_certificates + && !next.allow_invalid_proxy_tls_certificates); + if restrictive { + generation.cancellation.cancel(); + } + *generation = ProxyGeneration { + settings: next, + cancellation: CancellationToken::new(), + }; + } +} + +#[cfg(test)] +mod tests { + use super::super::{ + ip::LocalNetworks, + resolver::{Clock, DestinationValidator, DnsResolver, LocalNetworkProvider}, + }; + use super::{ProxyPolicySettings, ProxyRuntime}; + use async_trait::async_trait; + use std::{io, net::SocketAddr, sync::Arc, time::Instant}; + + struct NoDns; + + #[async_trait] + impl DnsResolver for NoDns { + async fn resolve(&self, _host: &str, _port: u16) -> io::Result> { + Err(io::Error::other("unused")) + } + } + + struct NoLocalNetworks; + + #[async_trait] + impl LocalNetworkProvider for NoLocalNetworks { + async fn current(&self) -> io::Result { + Ok(LocalNetworks::default()) + } + } + + struct FixedClock; + + impl Clock for FixedClock { + fn now(&self) -> Instant { + Instant::now() + } + } + + fn runtime(settings: ProxyPolicySettings) -> ProxyRuntime { + let validator = Arc::new(DestinationValidator::new( + Arc::new(NoDns), + Arc::new(NoLocalNetworks), + Arc::new(FixedClock), + Vec::new(), + )); + ProxyRuntime::new(settings, validator) + } + + #[test] + fn sixty_fifth_request_is_rejected_without_waiting() { + let runtime = runtime(ProxyPolicySettings::default()); + let permits: Vec<_> = (0..64).map(|_| runtime.try_request().unwrap()).collect(); + assert!(runtime.try_request().is_err()); + drop(permits); + assert!(runtime.try_request().is_ok()); + } + + #[tokio::test] + async fn restrictive_reconfiguration_cancels_the_old_generation() { + let runtime = runtime(ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: true, + }); + let old = runtime.try_request().unwrap(); + let next = ProxyPolicySettings::default(); + runtime.begin_reconfigure(next); + assert!(old.cancellation.is_cancelled()); + let during = runtime.try_request().unwrap(); + assert_eq!(during.settings, ProxyPolicySettings::default()); + runtime.finish_reconfigure(next); + assert!(!during.cancellation.is_cancelled()); + } + + #[test] + fn enabling_permissions_does_not_cancel_stricter_inflight_work() { + let runtime = runtime(ProxyPolicySettings::default()); + let old = runtime.try_request().unwrap(); + let next = ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: true, + }; + runtime.begin_reconfigure(next); + assert!(!old.cancellation.is_cancelled()); + assert_eq!( + runtime.try_request().unwrap().settings, + ProxyPolicySettings::default() + ); + runtime.finish_reconfigure(next); + assert!(!old.cancellation.is_cancelled()); + assert_eq!(runtime.try_request().unwrap().settings, next); + } + + #[test] + fn mixed_transition_exposes_only_the_old_new_intersection_until_publish() { + let old = ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }; + let next = ProxyPolicySettings { + allow_private_network_sources: false, + allow_invalid_proxy_tls_certificates: true, + }; + let runtime = runtime(old); + let old_request = runtime.try_request().unwrap(); + runtime.begin_reconfigure(next); + assert!(old_request.cancellation.is_cancelled()); + assert_eq!( + runtime.try_request().unwrap().settings, + ProxyPolicySettings::default() + ); + runtime.finish_reconfigure(next); + assert_eq!(runtime.try_request().unwrap().settings, next); + } +} diff --git a/server/src/routes/system.rs b/server/src/routes/system.rs index 4cea28a..c3e7536 100644 --- a/server/src/routes/system.rs +++ b/server/src/routes/system.rs @@ -1,10 +1,11 @@ use crate::routes::compat; use crate::state::AppState; use crate::updater::version::UpdateChannel; +use crate::{network_security::ProxyPolicySettings, settings_control::SettingsMutationAuthority}; use axum::{ Json, - extract::{Query, RawQuery, State}, - http::StatusCode, + extract::{ConnectInfo, Query, RawQuery, State}, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use enginefs::backend::{ @@ -98,6 +99,10 @@ pub struct ServerSettings { pub cache_size: f64, #[serde(rename = "proxyStreamsEnabled")] pub proxy_streams_enabled: bool, + #[serde(rename = "allowPrivateNetworkSources", default)] + pub allow_private_network_sources: bool, + #[serde(rename = "allowInvalidProxyTlsCertificates", default)] + pub allow_invalid_proxy_tls_certificates: bool, #[serde(rename = "btMaxConnections")] pub bt_max_connections: u64, #[serde(rename = "btHandshakeTimeout")] @@ -267,6 +272,48 @@ pub fn default_bt_ssrf_mitigation() -> bool { true } +fn parse_environment_bool(value: &str) -> Option { + if value.eq_ignore_ascii_case("1") + || value.eq_ignore_ascii_case("true") + || value.eq_ignore_ascii_case("yes") + || value.eq_ignore_ascii_case("on") + { + Some(true) + } else if value.eq_ignore_ascii_case("0") + || value.eq_ignore_ascii_case("false") + || value.eq_ignore_ascii_case("no") + || value.eq_ignore_ascii_case("off") + { + Some(false) + } else { + None + } +} + +pub(crate) fn apply_proxy_environment_overrides(settings: &mut ServerSettings) { + for (name, target) in [ + ( + "STREMIO_ALLOW_PRIVATE_NETWORK_SOURCES", + &mut settings.allow_private_network_sources, + ), + ( + "STREMIO_ALLOW_INVALID_PROXY_TLS_CERTIFICATES", + &mut settings.allow_invalid_proxy_tls_certificates, + ), + ] { + if let Ok(value) = std::env::var(name) { + if let Some(value) = parse_environment_bool(&value) { + *target = value; + } else { + tracing::warn!( + variable = name, + "ignoring invalid boolean environment override" + ); + } + } + } +} + fn parse_torrent_encryption_mode(value: &Value) -> Option { if let Some(code) = value.as_u64() { return match code { @@ -385,6 +432,8 @@ impl Default for ServerSettings { cache_root, cache_size: 10.0 * 1024.0 * 1024.0 * 1024.0, // 10GB proxy_streams_enabled: false, + allow_private_network_sources: false, + allow_invalid_proxy_tls_certificates: false, bt_max_connections: enginefs::backend::DEFAULT_BT_MAX_CONNECTIONS, bt_handshake_timeout: 20000, bt_request_timeout: 10000, @@ -425,6 +474,55 @@ impl Default for ServerSettings { } } +pub(crate) struct PreparedSettingsUpdate { + pub(crate) next: ServerSettings, +} + +#[derive(thiserror::Error, Debug)] +pub(crate) enum SettingsUpdateError { + #[error("protected setting requires local authorization")] + Forbidden, + #[error("invalid settings payload: {0}")] + Invalid(&'static str), + #[error("settings persistence failed")] + Persistence(#[source] anyhow::Error), +} + +fn prepare_settings_update( + current: &ServerSettings, + payload: &Value, + authority: SettingsMutationAuthority, +) -> Result { + let object = payload + .as_object() + .ok_or(SettingsUpdateError::Invalid("expected a JSON object"))?; + let mut next = current.clone(); + for (key, current_value, target) in [ + ( + "allowPrivateNetworkSources", + current.allow_private_network_sources, + &mut next.allow_private_network_sources, + ), + ( + "allowInvalidProxyTlsCertificates", + current.allow_invalid_proxy_tls_certificates, + &mut next.allow_invalid_proxy_tls_certificates, + ), + ] { + if let Some(value) = object.get(key) { + let value = value.as_bool().ok_or(SettingsUpdateError::Invalid( + "protected values must be boolean", + ))?; + if value != current_value && authority == SettingsMutationAuthority::Untrusted { + return Err(SettingsUpdateError::Forbidden); + } + *target = value; + } + } + + Ok(PreparedSettingsUpdate { next }) +} + /// Returns server settings in the SettingsResponse format expected by stremio-core /// Response format: { "baseUrl": "http://...", "values": { ...settings } } pub async fn get_settings(State(state): State) -> impl IntoResponse { @@ -436,11 +534,44 @@ pub async fn get_settings(State(state): State) -> impl IntoResponse { })) } -pub async fn update_settings(state: &AppState, payload: &Value) -> anyhow::Result<()> { - tracing::debug!("update_settings: received payload: {:?}", payload); +pub(crate) async fn persist_settings_atomic( + path: &std::path::Path, + settings: &ServerSettings, +) -> anyhow::Result<()> { + let bytes = serde_json::to_vec_pretty(settings)?; + let path = path.to_owned(); + let parent = path + .parent() + .ok_or_else(|| anyhow::anyhow!("settings path has no parent"))? + .to_owned(); + tokio::fs::create_dir_all(&parent).await?; + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + use std::io::Write; + let mut temporary = tempfile::NamedTempFile::new_in(&parent)?; + temporary.write_all(&bytes)?; + temporary.flush()?; + temporary.as_file().sync_all()?; + temporary.persist(&path).map_err(|error| error.error)?; + #[cfg(unix)] + std::fs::File::open(parent)?.sync_all()?; + Ok(()) + }) + .await??; + Ok(()) +} - // Merge with existing settings - let mut settings = state.settings.write().await; +pub async fn update_settings( + state: &AppState, + payload: &Value, + authority: SettingsMutationAuthority, +) -> Result<(), SettingsUpdateError> { + let _persistence = state.settings_persistence.lock().await; + let current = state.settings.read().await.clone(); + let protected = prepare_settings_update(¤t, payload, authority)?; + let mut settings = current; + settings.allow_private_network_sources = protected.next.allow_private_network_sources; + settings.allow_invalid_proxy_tls_certificates = + protected.next.allow_invalid_proxy_tls_certificates; if let Some(obj) = payload.as_object() { // Update fields that are present in the payload @@ -645,8 +776,19 @@ pub async fn update_settings(state: &AppState, payload: &Value) -> anyhow::Resul bt_ssrf_mitigation: settings.bt_ssrf_mitigation, }; - // Release the write lock before saving - drop(settings); + let proxy_policy = ProxyPolicySettings { + allow_private_network_sources: settings.allow_private_network_sources, + allow_invalid_proxy_tls_certificates: settings.allow_invalid_proxy_tls_certificates, + }; + persist_settings_atomic(&state.settings_path, &settings) + .await + .map_err(SettingsUpdateError::Persistence)?; + let mut published = state.settings.write().await; + state.proxy_runtime.begin_reconfigure(proxy_policy); + *published = settings; + state.proxy_runtime.finish_reconfigure(proxy_policy); + drop(published); + drop(_persistence); // Apply updated torrent session settings dynamically. state @@ -661,21 +803,41 @@ pub async fn update_settings(state: &AppState, payload: &Value) -> anyhow::Resul state.engine.set_seeding_enabled(seeding_enabled); state.download_engine.set_seeding_enabled(seeding_enabled); - // Save to disk - state.save_settings().await?; - Ok(()) } pub async fn set_settings( + ConnectInfo(peer): ConnectInfo, State(state): State, + headers: HeaderMap, Json(payload): Json, -) -> impl IntoResponse { - match update_settings(&state, &payload).await { - Ok(_) => Json(json!({ "success": true })), - Err(e) => { - tracing::error!("Failed to save settings: {}", e); - Json(json!({ "success": false, "error": e.to_string() })) +) -> Response { + let authority = state.settings_control.authorize_http(peer, &headers); + match update_settings(&state, &payload, authority).await { + Ok(()) => (StatusCode::OK, Json(json!({"success": true}))).into_response(), + Err(SettingsUpdateError::Forbidden) => ( + StatusCode::FORBIDDEN, + Json(json!({ + "success": false, + "error": "protected setting requires local authorization" + })), + ) + .into_response(), + Err(SettingsUpdateError::Invalid(_)) => ( + StatusCode::BAD_REQUEST, + Json(json!({"success": false, "error": "invalid settings payload"})), + ) + .into_response(), + Err(SettingsUpdateError::Persistence(_)) => { + tracing::error!("settings persistence failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "success": false, + "error": "settings could not be saved" + })), + ) + .into_response() } } } @@ -1027,10 +1189,94 @@ pub async fn get_file_stats( #[cfg(test)] mod tests { use super::*; + use crate::settings_control::SettingsMutationAuthority; #[test] fn server_version_default_uses_crate_version() { let settings = ServerSettings::default(); assert_eq!(settings.server_version, env!("CARGO_PKG_VERSION")); } + + #[test] + fn proxy_security_settings_default_false_when_missing_from_json() { + let mut value = serde_json::to_value(ServerSettings::default()).unwrap(); + let object = value.as_object_mut().unwrap(); + object.remove("allowPrivateNetworkSources"); + object.remove("allowInvalidProxyTlsCertificates"); + let settings: ServerSettings = serde_json::from_value(value).unwrap(); + assert!(!settings.allow_private_network_sources); + assert!(!settings.allow_invalid_proxy_tls_certificates); + } + + #[test] + fn untrusted_round_trip_may_repeat_but_not_change_protected_values() { + let current = ServerSettings::default(); + let unchanged = json!({ + "allowPrivateNetworkSources": false, + "allowInvalidProxyTlsCertificates": false, + }); + assert!( + prepare_settings_update(¤t, &unchanged, SettingsMutationAuthority::Untrusted,) + .is_ok() + ); + + let changed = json!({"allowPrivateNetworkSources": true}); + assert!(matches!( + prepare_settings_update(¤t, &changed, SettingsMutationAuthority::Untrusted,), + Err(SettingsUpdateError::Forbidden) + )); + } + + #[test] + fn authorized_callers_may_change_protected_values() { + let current = ServerSettings::default(); + for authority in [ + SettingsMutationAuthority::TrustedLocal, + SettingsMutationAuthority::HttpAuthorized, + ] { + let prepared = prepare_settings_update( + ¤t, + &json!({ + "allowPrivateNetworkSources": true, + "allowInvalidProxyTlsCertificates": true, + }), + authority, + ) + .unwrap(); + assert!(prepared.next.allow_private_network_sources); + assert!(prepared.next.allow_invalid_proxy_tls_certificates); + } + } + + #[test] + fn non_boolean_protected_values_are_invalid_even_when_falsey() { + let current = ServerSettings::default(); + for payload in [ + json!({"allowPrivateNetworkSources": null}), + json!({"allowPrivateNetworkSources": 0}), + json!({"allowInvalidProxyTlsCertificates": "false"}), + ] { + assert!(matches!( + prepare_settings_update( + ¤t, + &payload, + SettingsMutationAuthority::TrustedLocal, + ), + Err(SettingsUpdateError::Invalid(_)) + )); + } + } + + #[test] + fn environment_boolean_parser_is_strict_and_case_insensitive() { + for value in ["1", "true", "TRUE", "yes", "On"] { + assert_eq!(parse_environment_bool(value), Some(true), "{value}"); + } + for value in ["0", "false", "FALSE", "no", "Off"] { + assert_eq!(parse_environment_bool(value), Some(false), "{value}"); + } + for value in ["", " true ", "enabled", "2"] { + assert_eq!(parse_environment_bool(value), None, "{value}"); + } + } } diff --git a/server/src/settings_control.rs b/server/src/settings_control.rs new file mode 100644 index 0000000..3682605 --- /dev/null +++ b/server/src/settings_control.rs @@ -0,0 +1,275 @@ +use anyhow::{Context, bail}; +use axum::http::HeaderMap; +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + net::SocketAddr, + path::Path, + sync::Arc, +}; +use subtle::ConstantTimeEq; +use uuid::Uuid; + +pub(crate) const SETTINGS_TOKEN_HEADER: &str = "x-stream-server-settings-token"; +const TOKEN_FILE_NAME: &str = "settings-control.token"; +const TOKEN_LENGTH: usize = 64; +const MAX_TOKEN_FILE_LENGTH: u64 = 66; + +#[derive(Clone)] +pub(crate) struct SettingsControl { + token: Arc<[u8; TOKEN_LENGTH]>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SettingsMutationAuthority { + TrustedLocal, + HttpAuthorized, + Untrusted, +} + +impl SettingsControl { + pub(crate) fn load_or_create(config_dir: &Path) -> anyhow::Result { + fs::create_dir_all(config_dir).with_context(|| { + format!("failed to create config directory {}", config_dir.display()) + })?; + let path = config_dir.join(TOKEN_FILE_NAME); + match create_token_file(&path) { + Ok(token) => Ok(Self { + token: Arc::new(token), + }), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let token = load_token_file(&path)?; + Ok(Self { + token: Arc::new(token), + }) + } + Err(error) => Err(error).with_context(|| { + format!("failed to create settings control token {}", path.display()) + }), + } + } + + pub(crate) fn authorize_http( + &self, + peer: SocketAddr, + headers: &HeaderMap, + ) -> SettingsMutationAuthority { + if !peer.ip().is_loopback() { + return SettingsMutationAuthority::Untrusted; + } + let Some(candidate) = headers.get(SETTINGS_TOKEN_HEADER) else { + return SettingsMutationAuthority::Untrusted; + }; + let candidate = candidate.as_bytes(); + if candidate.len() != TOKEN_LENGTH { + return SettingsMutationAuthority::Untrusted; + } + if bool::from(candidate.ct_eq(self.token.as_ref())) { + SettingsMutationAuthority::HttpAuthorized + } else { + SettingsMutationAuthority::Untrusted + } + } + + pub(crate) fn ephemeral() -> Self { + let raw = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let token = raw + .as_bytes() + .try_into() + .expect("two simple UUIDs are exactly 64 bytes"); + Self { + token: Arc::new(token), + } + } + + #[cfg(test)] + pub(crate) fn for_test(token: [u8; TOKEN_LENGTH]) -> Self { + Self { + token: Arc::new(token), + } + } +} + +fn create_token_file(path: &Path) -> io::Result<[u8; TOKEN_LENGTH]> { + let raw = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let token: [u8; TOKEN_LENGTH] = raw + .as_bytes() + .try_into() + .expect("two simple UUIDs are exactly 64 bytes"); + + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options.open(path)?; + file.write_all(&token)?; + file.sync_all()?; + Ok(token) +} + +fn load_token_file(path: &Path) -> anyhow::Result<[u8; TOKEN_LENGTH]> { + validate_token_metadata(path)?; + let metadata = fs::metadata(path)?; + if metadata.len() > MAX_TOKEN_FILE_LENGTH { + bail!("settings control token file is oversized"); + } + let bytes = fs::read(path)?; + parse_token_bytes(&bytes) +} + +fn validate_token_metadata(path: &Path) -> anyhow::Result<()> { + let metadata = fs::symlink_metadata(path).with_context(|| { + format!( + "failed to inspect settings control token {}", + path.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { + bail!("settings control token must be a regular non-symlink file"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + bail!("settings control token permissions must be 0600"); + } + } + Ok(()) +} + +fn parse_token_bytes(bytes: &[u8]) -> anyhow::Result<[u8; TOKEN_LENGTH]> { + let token = match bytes { + [token @ .., b'\n'] if token.len() == TOKEN_LENGTH => token, + [token @ .., b'\r', b'\n'] if token.len() == TOKEN_LENGTH => token, + token if token.len() == TOKEN_LENGTH => token, + _ => bail!("settings control token has invalid length or line ending"), + }; + if !token + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + bail!("settings control token must be lowercase hexadecimal"); + } + Ok(token.try_into().expect("token length was validated")) +} + +#[cfg(test)] +mod tests { + use super::{ + SETTINGS_TOKEN_HEADER, SettingsControl, SettingsMutationAuthority, parse_token_bytes, + }; + use axum::http::{HeaderMap, HeaderValue}; + use std::{fs, net::SocketAddr}; + + fn headers_with(value: &[u8]) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + SETTINGS_TOKEN_HEADER, + HeaderValue::from_bytes(value).unwrap(), + ); + headers + } + + #[test] + fn valid_token_requires_a_loopback_peer() { + let control = SettingsControl::for_test([b'a'; 64]); + let headers = headers_with(&[b'a'; 64]); + assert_eq!( + control.authorize_http( + "192.168.1.50:40000".parse::().unwrap(), + &headers, + ), + SettingsMutationAuthority::Untrusted + ); + assert_eq!( + control.authorize_http("127.0.0.1:40000".parse::().unwrap(), &headers,), + SettingsMutationAuthority::HttpAuthorized + ); + assert_eq!( + control.authorize_http("[::1]:40000".parse::().unwrap(), &headers), + SettingsMutationAuthority::HttpAuthorized + ); + } + + #[test] + fn missing_wrong_or_wrong_length_token_is_untrusted() { + let control = SettingsControl::for_test([b'a'; 64]); + let peer = "127.0.0.1:40000".parse().unwrap(); + assert_eq!( + control.authorize_http(peer, &HeaderMap::new()), + SettingsMutationAuthority::Untrusted + ); + for value in [&[b'b'; 64][..], &[b'a'; 63][..], &[b'a'; 65][..]] { + assert_eq!( + control.authorize_http(peer, &headers_with(value)), + SettingsMutationAuthority::Untrusted + ); + } + } + + #[test] + fn token_file_syntax_is_exact_and_allows_one_line_ending() { + let token = [b'a'; 64]; + assert_eq!(parse_token_bytes(&token).unwrap(), token); + assert_eq!( + parse_token_bytes(&[&token[..], b"\n"].concat()).unwrap(), + token + ); + assert_eq!( + parse_token_bytes(&[&token[..], b"\r\n"].concat()).unwrap(), + token + ); + + for invalid in [ + Vec::new(), + vec![b'a'; 63], + vec![b'a'; 65], + vec![b'A'; 64], + [&token[..], b" \n"].concat(), + [&token[..], b"\n\n"].concat(), + [b" ".as_slice(), &token[..]].concat(), + ] { + assert!(parse_token_bytes(&invalid).is_err(), "{invalid:?}"); + } + } + + #[test] + fn token_is_created_once_and_reloaded_stably() { + let temp = tempfile::tempdir().unwrap(); + let first = SettingsControl::load_or_create(temp.path()).unwrap(); + let bytes = fs::read(temp.path().join("settings-control.token")).unwrap(); + assert_eq!(bytes.len(), 64); + assert!(bytes.iter().all(u8::is_ascii_hexdigit)); + assert!(bytes.iter().all(|byte| !byte.is_ascii_uppercase())); + + let second = SettingsControl::load_or_create(temp.path()).unwrap(); + let headers = headers_with(&bytes); + let peer = "127.0.0.1:40000".parse().unwrap(); + assert_eq!( + first.authorize_http(peer, &headers), + SettingsMutationAuthority::HttpAuthorized + ); + assert_eq!( + second.authorize_http(peer, &headers), + SettingsMutationAuthority::HttpAuthorized + ); + } + + #[test] + fn existing_non_regular_or_invalid_token_is_never_replaced() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings-control.token"); + fs::create_dir(&path).unwrap(); + assert!(SettingsControl::load_or_create(temp.path()).is_err()); + assert!(path.is_dir()); + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings-control.token"); + fs::write(&path, b"not-a-token").unwrap(); + assert!(SettingsControl::load_or_create(temp.path()).is_err()); + assert_eq!(fs::read(path).unwrap(), b"not-a-token"); + } +} diff --git a/server/src/state.rs b/server/src/state.rs index 35d4f16..dfb2a59 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -1,4 +1,11 @@ use crate::routes::system::ServerSettings; +use crate::{ + network_security::{ + DestinationValidator, ListenerBinding, ProxyPolicySettings, ProxyRuntime, SystemClock, + SystemDnsResolver, SystemLocalNetworkProvider, + }, + settings_control::SettingsControl, +}; use enginefs::EngineFS; use std::net::SocketAddr; use std::path::PathBuf; @@ -24,6 +31,9 @@ pub struct AppState { pub archive_cache: Arc>, pub nzb_sessions: Arc>, pub devices: Arc>>, + pub(crate) settings_control: SettingsControl, + pub(crate) proxy_runtime: Arc, + pub(crate) settings_persistence: Arc>, } impl AppState { @@ -86,6 +96,23 @@ impl AppState { ) -> Self { let settings_path = config_dir.join("settings.json"); let updater = Arc::new(crate::updater::UpdateManager::new(config_dir.clone())); + let proxy_policy = settings + .try_read() + .map(|settings| ProxyPolicySettings { + allow_private_network_sources: settings.allow_private_network_sources, + allow_invalid_proxy_tls_certificates: settings.allow_invalid_proxy_tls_certificates, + }) + .unwrap_or_default(); + let default_http_addr = SocketAddr::from(([127, 0, 0, 1], 11470)); + let validator = Arc::new(DestinationValidator::new( + Arc::new(SystemDnsResolver), + Arc::new(SystemLocalNetworkProvider), + Arc::new(SystemClock), + vec![ListenerBinding { + address: default_http_addr.ip(), + port: default_http_addr.port(), + }], + )); Self { engine, @@ -96,26 +123,23 @@ impl AppState { config_dir, log_dir, base_url: "http://127.0.0.1:11470".to_string(), - http_addr: SocketAddr::from(([127, 0, 0, 1], 11470)), + http_addr: default_http_addr, update_install_exit_enabled: true, updater, local_index: LocalIndex::new(), archive_cache: Arc::new(dashmap::DashMap::new()), nzb_sessions: Arc::new(dashmap::DashMap::new()), devices: Arc::new(RwLock::new(Vec::new())), + settings_control: SettingsControl::ephemeral(), + proxy_runtime: Arc::new(ProxyRuntime::new(proxy_policy, validator)), + settings_persistence: Arc::new(tokio::sync::Mutex::new(())), } } pub async fn save_settings(&self) -> anyhow::Result<()> { - let settings = self.settings.read().await; - let json = serde_json::to_string_pretty(&*settings)?; - - // Ensure parent directory exists - if let Some(parent) = self.settings_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - tokio::fs::write(&self.settings_path, json).await?; + let _persistence = self.settings_persistence.lock().await; + let settings = self.settings.read().await.clone(); + crate::routes::system::persist_settings_atomic(&self.settings_path, &settings).await?; tracing::info!("Settings saved to {:?}", self.settings_path); Ok(()) } @@ -150,11 +174,14 @@ impl AppState { ); settings.bt_max_connections = enginefs::backend::DEFAULT_BT_MAX_CONNECTIONS; } + crate::routes::system::apply_proxy_environment_overrides(&mut settings); return settings; } tracing::info!("Using default settings"); - defaults.clone() + let mut settings = defaults.clone(); + crate::routes::system::apply_proxy_environment_overrides(&mut settings); + settings } } @@ -163,13 +190,28 @@ impl AppState { pub struct TrackerStorageBridge { settings: Arc>, settings_path: PathBuf, + settings_persistence: Arc>, } impl TrackerStorageBridge { + #[allow(dead_code)] // Retained for embedders that construct the bridge directly. pub fn new(settings: Arc>, settings_path: PathBuf) -> Self { + Self::new_with_persistence( + settings, + settings_path, + Arc::new(tokio::sync::Mutex::new(())), + ) + } + + pub fn new_with_persistence( + settings: Arc>, + settings_path: PathBuf, + settings_persistence: Arc>, + ) -> Self { Self { settings, settings_path, + settings_persistence, } } } @@ -225,32 +267,20 @@ impl enginefs::TrackerStorage for TrackerStorageBridge { fn save_trackers(&self, trackers: Vec, timestamp: i64) { let settings = self.settings.clone(); let settings_path = self.settings_path.clone(); + let settings_persistence = self.settings_persistence.clone(); // Spawn async task to update and save tokio::spawn(async move { - let mut guard = settings.write().await; - guard.cached_trackers = trackers; - guard.trackers_last_updated = timestamp; - let json = match serde_json::to_string_pretty(&*guard) { - Ok(j) => j, - Err(e) => { - tracing::error!("Failed to serialize settings: {}", e); - return; - } - }; - drop(guard); - - // Ensure parent directory exists - if let Some(parent) = settings_path.parent() - && let Err(e) = tokio::fs::create_dir_all(parent).await + let _persistence = settings_persistence.lock().await; + let mut next = settings.read().await.clone(); + next.cached_trackers = trackers; + next.trackers_last_updated = timestamp; + if let Err(e) = + crate::routes::system::persist_settings_atomic(&settings_path, &next).await { - tracing::error!("Failed to create settings directory: {}", e); - return; - } - - if let Err(e) = tokio::fs::write(&settings_path, json).await { tracing::error!("Failed to save settings after tracker update: {}", e); } else { + *settings.write().await = next; tracing::debug!("Saved cached trackers to settings"); } }); diff --git a/server/src/tray.rs b/server/src/tray.rs index f4d924c..f670857 100644 --- a/server/src/tray.rs +++ b/server/src/tray.rs @@ -368,6 +368,10 @@ struct DirectConnector { #[async_trait::async_trait] impl settings_gui::ServerConnector for DirectConnector { + fn can_update_protected_settings(&self) -> bool { + true + } + async fn get_settings(&self) -> anyhow::Result { let settings = self.state.settings.read().await; let val = serde_json::to_value(&*settings)?; @@ -376,7 +380,13 @@ impl settings_gui::ServerConnector for DirectConnector { async fn apply_settings(&self, payload: settings_gui::SettingsPayload) -> anyhow::Result<()> { let val = serde_json::to_value(&payload)?; - crate::routes::system::update_settings(&self.state, &val).await + crate::routes::system::update_settings( + &self.state, + &val, + crate::settings_control::SettingsMutationAuthority::TrustedLocal, + ) + .await?; + Ok(()) } async fn get_logs(&self) -> anyhow::Result { From 714af826b252132a2fb3843fb3e6ccfd8ab8a3ab Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:07:39 -0400 Subject: [PATCH 05/25] security: harden proxy dispatch and streaming --- server/src/network_security/resolver.rs | 4 +- server/src/routes/proxy.rs | 1057 +++++++++++++++++++---- 2 files changed, 866 insertions(+), 195 deletions(-) diff --git a/server/src/network_security/resolver.rs b/server/src/network_security/resolver.rs index 38350da..ef7f367 100644 --- a/server/src/network_security/resolver.rs +++ b/server/src/network_security/resolver.rs @@ -356,9 +356,7 @@ fn discover_nat64_prefixes(answers: &[SocketAddr]) -> Vec { } } if seen == [true, true] - && !prefixes - .iter() - .any(|existing: &Nat64Prefix| *existing == prefix) + && !prefixes.contains(&prefix) { prefixes.push(prefix); } diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 69aacff..2ff6dad 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -1,240 +1,913 @@ -use crate::state::AppState; +use crate::{ + network_security::{DestinationError, ProxyRequestContext, ProxyRuntime}, + state::AppState, +}; use axum::{ Router, - extract::{Path, Query}, - http::{HeaderMap, StatusCode, header}, + body::Body, + extract::{Path, RawQuery, State}, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, routing::any, }; +use bytes::Bytes; +use futures_util::{Stream, StreamExt}; use reqwest::{Client, Method}; -use std::collections::HashMap; +use std::{pin::Pin, time::Duration}; +use tokio::sync::OwnedSemaphorePermit; +use tokio_util::sync::CancellationToken; use url::Url; +const MAX_PROXY_INPUT: usize = 64 * 1024; +const MAX_TARGET_URL: usize = 16 * 1024; +const MAX_CUSTOM_OPTIONS: usize = 64; +const MAX_HEADER_PAIR: usize = 8 * 1024; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ProxyError { + InvalidRequest, + Blocked, + Capacity, + Upstream, + Cancelled, +} + +impl From for ProxyError { + fn from(value: DestinationError) -> Self { + match value { + DestinationError::UnsupportedScheme | DestinationError::MissingHost => { + Self::InvalidRequest + } + DestinationError::Blocked => Self::Blocked, + DestinationError::ResolutionFailed | DestinationError::LocalNetworkUnavailable => { + Self::Upstream + } + } + } +} + +struct ParsedProxyRequest { + target: Url, + request_headers: HeaderMap, + response_headers: HeaderMap, +} + +fn parse_proxy_request( + rest: &str, + raw_query: Option<&str>, +) -> Result { + let input_length = rest + .len() + .checked_add(raw_query.map_or(0, str::len)) + .ok_or(ProxyError::InvalidRequest)?; + if input_length > MAX_PROXY_INPUT { + return Err(ProxyError::InvalidRequest); + } + + let query_has_target = raw_query.is_some_and(|query| { + url::form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == "d") + }); + let (encoded_options, path_tail, upstream_query) = if query_has_target { + (raw_query.unwrap_or_default(), "", None) + } else { + let (options, tail) = rest.split_once('/').unwrap_or((rest, "")); + (options, tail, raw_query) + }; + + let mut target = None; + let mut request_headers = HeaderMap::new(); + let mut response_headers = HeaderMap::new(); + let mut option_count = 0usize; + for (key, value) in url::form_urlencoded::parse(encoded_options.as_bytes()) { + match key.as_ref() { + "d" => target = Some(value.into_owned()), + "h" | "r" => { + option_count = option_count + .checked_add(1) + .ok_or(ProxyError::InvalidRequest)?; + if option_count > MAX_CUSTOM_OPTIONS || value.len() > MAX_HEADER_PAIR { + return Err(ProxyError::InvalidRequest); + } + let (name, value) = parse_custom_header(&value)?; + if key == "h" { + if request_header_forbidden(&name) { + return Err(ProxyError::InvalidRequest); + } + request_headers.insert(name, value); + } else { + if response_header_forbidden(&name) { + return Err(ProxyError::InvalidRequest); + } + response_headers.insert(name, value); + } + } + _ => {} + } + } + + let target = target.ok_or(ProxyError::InvalidRequest)?; + if target.len() > MAX_TARGET_URL { + return Err(ProxyError::InvalidRequest); + } + let mut target = Url::parse(&target).map_err(|_| ProxyError::InvalidRequest)?; + if !matches!(target.scheme(), "http" | "https") || target.host().is_none() { + return Err(ProxyError::InvalidRequest); + } + target.set_fragment(None); + if !path_tail.is_empty() { + target = target + .join(path_tail) + .map_err(|_| ProxyError::InvalidRequest)?; + } + if let Some(query) = upstream_query { + target.set_query(Some(query)); + } + if target.as_str().len() > MAX_TARGET_URL { + return Err(ProxyError::InvalidRequest); + } + + Ok(ParsedProxyRequest { + target, + request_headers, + response_headers, + }) +} + +fn parse_custom_header(value: &str) -> Result<(HeaderName, HeaderValue), ProxyError> { + let (name, value) = value.split_once(':').ok_or(ProxyError::InvalidRequest)?; + let name = name.trim(); + let value = value.trim(); + if name + .len() + .checked_add(value.len()) + .and_then(|length| length.checked_add(1)) + .is_none_or(|length| length > MAX_HEADER_PAIR) + { + return Err(ProxyError::InvalidRequest); + } + let name = HeaderName::from_bytes(name.as_bytes()).map_err(|_| ProxyError::InvalidRequest)?; + let value = HeaderValue::from_str(value).map_err(|_| ProxyError::InvalidRequest)?; + Ok((name, value)) +} + +fn request_header_forbidden(name: &HeaderName) -> bool { + matches!( + name.as_str(), + "host" + | "connection" + | "keep-alive" + | "expect" + | "proxy-authenticate" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + | "content-length" + ) +} + +fn response_header_forbidden(name: &HeaderName) -> bool { + request_header_forbidden(name) + || matches!( + name.as_str(), + "set-cookie" + | "access-control-allow-origin" + | "access-control-allow-methods" + | "access-control-allow-headers" + ) +} + +async fn fetch_with_redirects( + runtime: &ProxyRuntime, + context: &ProxyRequestContext, + request: &ParsedProxyRequest, + method: Method, + incoming: &HeaderMap, +) -> Result<(reqwest::Response, Url), ProxyError> { + const REDIRECT_STATUSES: &[StatusCode] = &[ + StatusCode::MOVED_PERMANENTLY, + StatusCode::FOUND, + StatusCode::SEE_OTHER, + StatusCode::TEMPORARY_REDIRECT, + StatusCode::PERMANENT_REDIRECT, + ]; + const AUTOMATIC_REQUEST_HEADERS: &[HeaderName] = &[ + header::ACCEPT, + header::ACCEPT_LANGUAGE, + header::RANGE, + header::IF_RANGE, + header::USER_AGENT, + ]; + + let mut target = request.target.clone(); + let mut custom_headers = request.request_headers.clone(); + let mut redirects = 0usize; + loop { + let destination = runtime.validate(context, &target).await?; + let mut builder = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .connect_timeout(Duration::from_secs(10)) + .read_timeout(Duration::from_secs(30)) + .http2_max_header_list_size(65_536) + .danger_accept_invalid_certs(context.settings.allow_invalid_proxy_tls_certificates); + if let Some(domain) = &destination.domain { + builder = builder.resolve_to_addrs(domain, &destination.addrs); + } + let client = builder.build().map_err(|_| ProxyError::Upstream)?; + let mut headers = HeaderMap::new(); + for name in AUTOMATIC_REQUEST_HEADERS { + if let Some(value) = incoming.get(name) { + headers.insert(name.clone(), value.clone()); + } + } + for (name, value) in &custom_headers { + headers.insert(name.clone(), value.clone()); + } + headers.insert( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + let send = client + .request(method.clone(), destination.url.clone()) + .headers(headers) + .send(); + let response = tokio::select! { + biased; + _ = context.cancellation.cancelled() => return Err(ProxyError::Cancelled), + result = send => result.map_err(|_| ProxyError::Upstream)?, + }; + + if !REDIRECT_STATUSES.contains(&response.status()) { + return Ok((response, destination.url)); + } + if redirects >= 5 { + return Err(ProxyError::Upstream); + } + redirects += 1; + let location = response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or(ProxyError::Upstream)?; + let mut next = destination + .url + .join(location) + .map_err(|_| ProxyError::Upstream)?; + if destination.url.scheme() == "https" && next.scheme() == "http" { + return Err(ProxyError::Upstream); + } + if !matches!(next.scheme(), "http" | "https") || next.host().is_none() { + return Err(ProxyError::Upstream); + } + next.set_fragment(None); + if !same_authority(&destination.url, &next) { + let _ = next.set_username(""); + let _ = next.set_password(None); + custom_headers.remove(header::AUTHORIZATION); + custom_headers.remove(header::COOKIE); + custom_headers.remove(header::PROXY_AUTHORIZATION); + } + target = next; + } +} + +fn same_authority(left: &Url, right: &Url) -> bool { + left.host() == right.host() && left.port_or_known_default() == right.port_or_known_default() +} + pub fn router() -> Router { Router::new() - // The original JS uses /proxy/:opts/:pathname* - // We can use a wildcard capturing the whole path. - .route("/{*rest}", any(proxy_handler)) + .route("/proxy", any(proxy_root_handler)) + .route("/proxy/", any(proxy_root_handler)) + .route("/proxy/{*rest}", any(proxy_path_handler)) } -pub async fn proxy_handler( +async fn proxy_root_handler( + State(state): State, + RawQuery(raw_query): RawQuery, + headers: HeaderMap, + method: Method, +) -> Response { + handle_proxy(state, String::new(), raw_query, headers, method).await +} + +async fn proxy_path_handler( + State(state): State, Path(rest): Path, - axum::extract::RawQuery(raw_query): axum::extract::RawQuery, - Query(params): Query>, + RawQuery(raw_query): RawQuery, headers: HeaderMap, method: Method, -) -> impl IntoResponse { - // Porting the logic from express_805.js - // Format 1: ?d=URL (standard) - // Format 2: // (Core) where query_params contains d=ORIGIN&h=HEADER&r=RESPONSE_HEADER - - let mut target_url = String::new(); - let mut custom_headers = HashMap::new(); - let mut custom_response_headers = HashMap::new(); - let mut is_path_format = false; - - // Check for standard query param '?d=' - if let Some(d) = params.get("d") { - target_url = d.clone(); - // Fallback: If rest is not empty and d is just origin, we might need to append rest? - // But usually ?d=FULL_URL - } else { - is_path_format = true; - // Handle path-based format: /proxy/d=...&h=.../path/to/file - // Split rest by first slash to get query_segment and path - let (query_seg, path_seg) = match rest.split_once('/') { - Some((q, p)) => (q, p), - None => (rest.as_str(), ""), +) -> Response { + handle_proxy(state, rest, raw_query, headers, method).await +} + +async fn handle_proxy( + state: AppState, + rest: String, + raw_query: Option, + headers: HeaderMap, + method: Method, +) -> Response { + let context = match state.proxy_runtime.try_request() { + Ok(context) => context, + Err(_) => return proxy_error_response(ProxyError::Capacity), + }; + let request = match parse_proxy_request(&rest, raw_query.as_deref()) { + Ok(request) => request, + Err(error) => return proxy_error_response(error), + }; + let (upstream, final_url) = match fetch_with_redirects( + &state.proxy_runtime, + &context, + &request, + method, + &headers, + ) + .await + { + Ok(response) => response, + Err(error) => return proxy_error_response(error), + }; + let status = upstream.status(); + let upstream_headers = upstream.headers().clone(); + let content_type = upstream_headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let playlist = final_url.path().ends_with(".m3u8") + || final_url.path().ends_with(".m3u") + || content_type.to_ascii_lowercase().contains("mpegurl"); + + if playlist { + if upstream_headers + .get(header::CONTENT_ENCODING) + .is_some_and(|value| { + value + .to_str() + .map(|value| !value.eq_ignore_ascii_case("identity")) + .unwrap_or(true) + }) + { + return proxy_error_response(ProxyError::Upstream); + } + let body = match collect_playlist(upstream, &context).await { + Ok(body) => body, + Err(error) => return proxy_error_response(error), }; + let body = match String::from_utf8(body) + .map_err(|_| ProxyError::Upstream) + .and_then(|body| rewrite_playlist_bounded(&body, &final_url)) + { + Ok(body) => body, + Err(error) => return proxy_error_response(error), + }; + return build_proxy_response( + status, + &upstream_headers, + &request.response_headers, + Body::from(body), + true, + ); + } - // Parse the query segment - for (key, val) in url::form_urlencoded::parse(query_seg.as_bytes()) { - match key.as_ref() { - "d" => target_url = val.into_owned(), - "h" => { - // Header format "Name:Value" - if let Some((name, value)) = val.split_once(':') { - custom_headers.insert(name.trim().to_string(), value.trim().to_string()); + let ProxyRequestContext { + cancellation, + capacity, + .. + } = context; + let stream = ProxyBodyState { + stream: Box::pin(upstream.bytes_stream()), + cancellation, + _capacity: capacity, + terminal: false, + }; + let stream = futures_util::stream::unfold(stream, |mut state| async move { + if state.terminal { + return None; + } + let next = tokio::select! { + biased; + _ = state.cancellation.cancelled() => { + state.terminal = true; + Some(Err(std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + "proxy policy changed", + ))) + } + result = tokio::time::timeout(Duration::from_secs(30), state.stream.next()) => { + match result { + Err(_) => { + state.terminal = true; + Some(Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + "proxy upstream body timed out", + ))) } - } - "r" => { - // Response header format "Name:Value" - if let Some((name, value)) = val.split_once(':') { - custom_response_headers - .insert(name.trim().to_string(), value.trim().to_string()); + Ok(Some(Ok(bytes))) => Some(Ok(bytes)), + Ok(Some(Err(_))) => { + state.terminal = true; + Some(Err(std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + "proxy upstream body failed", + ))) } + Ok(None) => return None, } - _ => {} } - } + }; + next.map(|item| (item, state)) + }); + build_proxy_response( + status, + &upstream_headers, + &request.response_headers, + Body::from_stream(stream), + false, + ) +} - // If we found 'd', construct the full URL - if !target_url.is_empty() { - // target_url is the origin (e.g. http://example.com) - // path_seg is the relative path (e.g. video.mp4) - // Join them carefully - if !path_seg.is_empty() { - if !target_url.ends_with('/') { - target_url.push('/'); - } - target_url.push_str(path_seg); +type UpstreamByteStream = + Pin> + Send + 'static>>; + +struct ProxyBodyState { + stream: UpstreamByteStream, + cancellation: CancellationToken, + _capacity: OwnedSemaphorePermit, + terminal: bool, +} + +const MAX_PLAYLIST_INPUT: usize = 8 * 1024 * 1024; + +async fn collect_playlist( + response: reqwest::Response, + context: &ProxyRequestContext, +) -> Result, ProxyError> { + if response + .content_length() + .and_then(|length| usize::try_from(length).ok()) + .is_some_and(|length| length > MAX_PLAYLIST_INPUT) + { + return Err(ProxyError::Upstream); + } + let mut bytes = Vec::with_capacity( + response + .content_length() + .and_then(|length| usize::try_from(length).ok()) + .unwrap_or(0) + .min(MAX_PLAYLIST_INPUT), + ); + let mut stream = response.bytes_stream(); + loop { + let next = tokio::select! { + biased; + _ = context.cancellation.cancelled() => return Err(ProxyError::Cancelled), + result = tokio::time::timeout(Duration::from_secs(30), stream.next()) => { + result.map_err(|_| ProxyError::Upstream)? } - } else { - // Fallback: assume whole rest is the URL (legacy/simple proxy) - target_url = rest.clone(); + }; + let Some(chunk) = next else { + break; + }; + let chunk = chunk.map_err(|_| ProxyError::Upstream)?; + let next_length = bytes + .len() + .checked_add(chunk.len()) + .ok_or(ProxyError::Upstream)?; + if next_length > MAX_PLAYLIST_INPUT { + return Err(ProxyError::Upstream); } + bytes.extend_from_slice(&chunk); } + Ok(bytes) +} + +fn build_proxy_response( + status: StatusCode, + upstream: &HeaderMap, + custom: &HeaderMap, + body: Body, + rewritten: bool, +) -> Response { + const SAFE_RESPONSE_HEADERS: &[HeaderName] = &[ + header::ACCEPT_RANGES, + header::CONTENT_TYPE, + header::CONTENT_LENGTH, + header::CONTENT_RANGE, + header::LAST_MODIFIED, + header::ETAG, + header::SERVER, + header::DATE, + header::CONTENT_ENCODING, + ]; + let mut response = Response::new(body); + *response.status_mut() = status; + for name in SAFE_RESPONSE_HEADERS { + if rewritten + && matches!( + *name, + header::CONTENT_LENGTH | header::CONTENT_RANGE | header::CONTENT_ENCODING + ) + { + continue; + } + if let Some(value) = upstream.get(name) { + response.headers_mut().insert(name.clone(), value.clone()); + } + } + for (name, value) in custom { + response.headers_mut().insert(name.clone(), value.clone()); + } + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("GET, POST, OPTIONS"), + ); + response.headers_mut().insert( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("*"), + ); + response +} - let mut url = match Url::parse(&target_url) { - Ok(u) => u, - Err(_) => return (StatusCode::BAD_REQUEST, "Invalid target URL").into_response(), +fn proxy_error_response(error: ProxyError) -> Response { + let (status, message) = match error { + ProxyError::InvalidRequest => (StatusCode::BAD_REQUEST, "Invalid proxy request"), + ProxyError::Blocked => (StatusCode::FORBIDDEN, "Proxy destination is blocked"), + ProxyError::Capacity => ( + StatusCode::SERVICE_UNAVAILABLE, + "Proxy capacity is exhausted", + ), + ProxyError::Upstream | ProxyError::Cancelled => { + (StatusCode::BAD_GATEWAY, "Proxy upstream request failed") + } }; + let mut response = (status, message).into_response(); + if error == ProxyError::Capacity { + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from_static("1")); + } + response +} + +const MAX_PLAYLIST_OUTPUT: usize = 16 * 1024 * 1024; + +fn rewrite_playlist_bounded(body: &str, base_url: &Url) -> Result { + let mut output = String::with_capacity(body.len().min(MAX_PLAYLIST_OUTPUT)); + for line_with_ending in body.split_inclusive('\n') { + let (line, ending) = if let Some(line) = line_with_ending.strip_suffix("\r\n") { + (line, "\r\n") + } else if let Some(line) = line_with_ending.strip_suffix('\n') { + (line, "\n") + } else { + (line_with_ending, "") + }; + + if line.starts_with('#') { + rewrite_playlist_tag(line, base_url, &mut output)?; + } else if line.is_empty() { + push_playlist(&mut output, "")?; + } else if let Ok(absolute) = base_url.join(line) { + push_proxy_uri(&mut output, &absolute)?; + } else { + push_playlist(&mut output, line)?; + } + push_playlist(&mut output, ending)?; + } + if body.is_empty() { + return Ok(String::new()); + } + Ok(output) +} + +fn rewrite_playlist_tag(line: &str, base_url: &Url, output: &mut String) -> Result<(), ProxyError> { + let mut remaining = line; + while let Some(start) = remaining.find("URI=\"") { + let value_start = start + 5; + push_playlist(output, &remaining[..value_start])?; + let after_start = &remaining[value_start..]; + let Some(end) = after_start.find('"') else { + push_playlist(output, after_start)?; + return Ok(()); + }; + let value = &after_start[..end]; + if let Ok(absolute) = base_url.join(value) { + push_proxy_uri(output, &absolute)?; + } else { + push_playlist(output, value)?; + } + remaining = &after_start[end..]; + } + push_playlist(output, remaining) +} + +fn push_proxy_uri(output: &mut String, absolute: &Url) -> Result<(), ProxyError> { + const PREFIX: &str = "/proxy/?d="; + let maximum_encoded = absolute + .as_str() + .len() + .checked_mul(3) + .and_then(|length| length.checked_add(PREFIX.len())) + .ok_or(ProxyError::Upstream)?; + let remaining = MAX_PLAYLIST_OUTPUT.saturating_sub(output.len()); + if maximum_encoded > remaining { + return Err(ProxyError::Upstream); + } + push_playlist(output, PREFIX)?; + push_playlist(output, &urlencoding::encode(absolute.as_str())) +} - if is_path_format && let Some(q) = raw_query { - url.set_query(Some(&q)); +fn push_playlist(output: &mut String, value: &str) -> Result<(), ProxyError> { + let next_length = output + .len() + .checked_add(value.len()) + .ok_or(ProxyError::Upstream)?; + if next_length > MAX_PLAYLIST_OUTPUT { + return Err(ProxyError::Upstream); } + output.push_str(value); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ProxyError, fetch_with_redirects, parse_proxy_request, rewrite_playlist_bounded}; + use crate::network_security::{ + Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, + ProxyRuntime, + }; + use async_trait::async_trait; + use axum::{ + Router, + http::{HeaderMap, StatusCode, header}, + routing::get, + }; + use std::{ + io, + net::SocketAddr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Instant, + }; - let client = Client::builder() - .danger_accept_invalid_certs(true) // Parity with rejectUnauthorized: false - .build() + #[test] + fn parse_core_path_format_preserves_tail_query() { + let parsed = parse_proxy_request( + "d=https%3A%2F%2Fexample.com&h=Range%3Abytes%3D1-9&r=Content-Type%3Avideo%2Fmp4/media/file", + Some("token=a%2Bb"), + ) .unwrap(); + assert_eq!( + parsed.target.as_str(), + "https://example.com/media/file?token=a%2Bb" + ); + assert_eq!(parsed.request_headers[header::RANGE], "bytes=1-9"); + assert_eq!(parsed.response_headers[header::CONTENT_TYPE], "video/mp4"); + } - let mut req_builder = client.request(method, url.clone()); - - // Forward standard headers - let allowed_req_headers = [ - "accept", - "accept-encoding", - "accept-language", - "connection", - "transfer-encoding", - "range", - "if-range", - "user-agent", - ]; + #[test] + fn parse_query_format_accepts_full_url_and_repeated_options_last_wins() { + let parsed = parse_proxy_request( + "", + Some( + "d=https%3A%2F%2Fexample.com%2Fvideo%3Fx%3D1&h=X-Test%3Afirst&h=X-Test%3Asecond&r=X-Reply%3Aok", + ), + ) + .unwrap(); + assert_eq!(parsed.target.as_str(), "https://example.com/video?x=1"); + assert_eq!(parsed.request_headers["x-test"], "second"); + assert_eq!(parsed.response_headers["x-reply"], "ok"); + } - for name in allowed_req_headers { - if let Some(value) = headers.get(name) { - req_builder = req_builder.header(name, value); + #[test] + fn parse_rejects_missing_or_unsupported_targets() { + for (rest, query) in [ + ("", None), + ("", Some("h=X-Test%3Avalue")), + ("", Some("d=file%3A%2F%2F%2Fetc%2Fpasswd")), + ("", Some("d=not-a-url")), + ] { + assert!(matches!( + parse_proxy_request(rest, query), + Err(ProxyError::InvalidRequest) + )); } } - // Apply custom headers from query params (Core format) - for (name, value) in custom_headers { - req_builder = req_builder.header(name, value); + #[test] + fn parse_rejects_header_smuggling_and_forbidden_fields() { + for option in [ + "h=Host%3Aexample.com", + "h=Content-Length%3A4", + "h=Connection%3Akeep-alive", + "h=Expect%3A100-continue", + "h=X-Test%3Aok%0D%0AX-Evil%3Ayes", + "r=Set-Cookie%3Astolen%3D1", + "r=Transfer-Encoding%3Achunked", + "r=Access-Control-Allow-Origin%3Ahttps%3A%2F%2Fevil.example", + ] { + let query = format!("d=https%3A%2F%2Fexample.com&{option}"); + assert!( + matches!( + parse_proxy_request("", Some(&query)), + Err(ProxyError::InvalidRequest) + ), + "{option}" + ); + } } - let response = match req_builder.send().await { - Ok(resp) => resp, - Err(e) => return (StatusCode::BAD_GATEWAY, format!("Proxy error: {}", e)).into_response(), - }; + #[test] + fn parse_enforces_option_and_target_limits_before_network_access() { + let options = (0..65) + .map(|index| format!("h=X-{index}%3Avalue")) + .collect::>() + .join("&"); + let query = format!("d=https%3A%2F%2Fexample.com&{options}"); + assert!(matches!( + parse_proxy_request("", Some(&query)), + Err(ProxyError::InvalidRequest) + )); - let status = response.status(); - let mut res_builder = Response::builder().status(status); - - let allowed_res_headers = [ - "accept-ranges", - "content-type", - "content-length", - "content-range", - "connection", - "transfer-encoding", - "last-modified", - "etag", - "server", - "date", - ]; + let oversized = format!("https://example.com/{}", "a".repeat(16 * 1024)); + let query = format!("d={}", urlencoding::encode(&oversized)); + assert!(matches!( + parse_proxy_request("", Some(&query)), + Err(ProxyError::InvalidRequest) + )); + } + + struct FixtureResolver { + address: SocketAddr, + calls: AtomicUsize, + } - let res_headers = response.headers().clone(); - for name in allowed_res_headers { - if let Some(value) = res_headers.get(name) { - res_builder = res_builder.header(name, value); + #[async_trait] + impl DnsResolver for FixtureResolver { + async fn resolve(&self, host: &str, port: u16) -> io::Result> { + assert_ne!(host, "ipv4only.arpa"); + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(vec![SocketAddr::new(self.address.ip(), port)]) } } - // Apply custom response headers (Core format) - for (name, value) in custom_response_headers { - res_builder = res_builder.header(name, value); + struct EmptyLocalNetworks; + + #[async_trait] + impl LocalNetworkProvider for EmptyLocalNetworks { + async fn current(&self) -> io::Result { + Ok(crate::network_security::LocalNetworks::default()) + } } - // CORS headers - res_builder = res_builder - .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") - .header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, OPTIONS") - .header(header::ACCESS_CONTROL_ALLOW_HEADERS, "*"); + struct FixedClock; - let content_type = res_headers - .get(header::CONTENT_TYPE) - .and_then(|h| h.to_str().ok()) - .unwrap_or(""); - let is_playlist = url.path().ends_with(".m3u8") - || url.path().ends_with(".m3u") - || content_type.contains("mpegurl"); - - if is_playlist { - // We need to rewrite the playlist. - // For now, let's just stream it without rewriting as a first step, - // then add rewriting if segments fail. - let body = response.text().await.unwrap_or_default(); - let rewritten = rewrite_playlist(&body, &url); - return res_builder - .body(axum::body::Body::from(rewritten)) - .unwrap() - .into_response(); - } - - let stream = response.bytes_stream(); - res_builder - .body(axum::body::Body::from_stream(stream)) - .unwrap() - .into_response() -} - -fn rewrite_playlist(body: &str, base_url: &Url) -> String { - let mut rewritten = String::new(); - for line in body.lines() { - if line.is_empty() { - rewritten.push('\n'); - continue; + impl Clock for FixedClock { + fn now(&self) -> Instant { + Instant::now() } - if line.starts_with("#") { - // Handle URI="url" in tags like #EXT-X-MEDIA - if let Some(start) = line.find("URI=\"") { - let rest = &line[start + 5..]; - if let Some(end) = rest.find("\"") { - let uri = &rest[..end]; - let absolute_uri = if uri.contains("://") { - uri.to_string() - } else { - base_url - .join(uri) - .map(|u: Url| u.to_string()) - .unwrap_or_else(|_| uri.to_string()) - }; - let proxy_uri = format!("/proxy/?d={}", urlencoding::encode(&absolute_uri)); - rewritten.push_str(&line[..start + 5]); - rewritten.push_str(&proxy_uri); - rewritten.push_str(&rest[end..]); - rewritten.push('\n'); - continue; - } - } - rewritten.push_str(line); - rewritten.push('\n'); - } else { - // It's a URL - let absolute_uri = if line.contains("://") { - line.to_string() - } else { - base_url - .join(line) - .map(|u: Url| u.to_string()) - .unwrap_or_else(|_| line.to_string()) - }; - let proxy_uri = format!("/proxy/?d={}", urlencoding::encode(&absolute_uri)); - rewritten.push_str(&proxy_uri); - rewritten.push('\n'); - } - } - rewritten + } + + fn test_runtime( + address: SocketAddr, + settings: ProxyPolicySettings, + ) -> (ProxyRuntime, Arc) { + let resolver = Arc::new(FixtureResolver { + address, + calls: AtomicUsize::new(0), + }); + let validator = Arc::new(DestinationValidator::new( + resolver.clone(), + Arc::new(EmptyLocalNetworks), + Arc::new(FixedClock), + Vec::new(), + )); + (ProxyRuntime::new(settings, validator), resolver) + } + + async fn fixture(router: Router) -> (SocketAddr, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + (address, task) + } + + #[tokio::test] + async fn dns_pinning_resolves_each_hop_exactly_once() { + let (address, fixture) = fixture(Router::new().route( + "/resource", + get(|| async { (StatusCode::OK, "fixture-body") }), + )) + .await; + let (runtime, resolver) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let parsed = parse_proxy_request( + "", + Some(&format!( + "d=http%3A%2F%2Frebind.test%3A{}%2Fresource", + address.port() + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + let (response, _) = fetch_with_redirects( + &runtime, + &context, + &parsed, + reqwest::Method::GET, + &HeaderMap::new(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.bytes().await.unwrap(), "fixture-body"); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + fixture.abort(); + } + + #[tokio::test] + async fn redirect_to_metadata_is_revalidated_with_private_opt_in() { + let (address, fixture) = fixture(Router::new().route( + "/redirect", + get(|| async { + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, "http://169.254.169.254/latest/meta-data/")], + ) + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let parsed = parse_proxy_request( + "", + Some(&format!( + "d=http%3A%2F%2Fredirect.test%3A{}%2Fredirect", + address.port() + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + assert!(matches!( + fetch_with_redirects( + &runtime, + &context, + &parsed, + reqwest::Method::GET, + &HeaderMap::new(), + ) + .await, + Err(ProxyError::Blocked) + )); + fixture.abort(); + } + + #[test] + fn playlist_rewriter_handles_plain_and_every_quoted_uri() { + let base = url::Url::parse("https://media.example/path/master.m3u8").unwrap(); + let body = concat!( + "#EXTM3U\r\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio.m3u8\",X=1,URI=\"backup.m3u8\"\r\n", + "segment.ts?token=1\r\n" + ); + let rewritten = rewrite_playlist_bounded(body, &base).unwrap(); + assert!(rewritten.starts_with("#EXTM3U\r\n")); + assert_eq!(rewritten.matches("/proxy/?d=").count(), 3); + assert!(rewritten.contains("https%3A%2F%2Fmedia.example%2Fpath%2Faudio.m3u8")); + assert!(rewritten.contains("https%3A%2F%2Fmedia.example%2Fpath%2Fsegment.ts%3Ftoken%3D1")); + assert!(rewritten.ends_with("\r\n")); + } + + #[test] + fn playlist_rewriter_fails_before_output_expansion_exceeds_limit() { + let base = url::Url::parse(&format!( + "https://media.example/{}/master.m3u8", + "a".repeat(15_000) + )) + .unwrap(); + let body = "segment.ts\n".repeat(1_200); + assert!(matches!( + rewrite_playlist_bounded(&body, &base), + Err(ProxyError::Upstream) + )); + } } From b03feb45f8871fca4e498e974f80b7d15a018932 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:07:54 -0400 Subject: [PATCH 06/25] test: cover proxy SSRF protections end to end --- server/tests/fixtures/localhost-cert.pem | 20 ++ server/tests/fixtures/localhost-key.pem | 28 +++ server/tests/proxy_security.rs | 284 +++++++++++++++++++++++ 3 files changed, 332 insertions(+) create mode 100644 server/tests/fixtures/localhost-cert.pem create mode 100644 server/tests/fixtures/localhost-key.pem create mode 100644 server/tests/proxy_security.rs diff --git a/server/tests/fixtures/localhost-cert.pem b/server/tests/fixtures/localhost-cert.pem new file mode 100644 index 0000000..beff554 --- /dev/null +++ b/server/tests/fixtures/localhost-cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDWzCCAkOgAwIBAgIUTXMPg6ts6Qj5NaZ9cBtoIRJvc0YwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDgxOTE5NDA0OFoXDTM2MDgx +NjE5NDA0OFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAn3/D4BM8orpw3Y4+q+tlDeRPVOflzSoGbPAIFIVUzdBr +Vj+OMBOdNcgZsCxKhc2AYGNHnujw5Bv6gJmqoluCsS6qMei8qKJt/OzWwzJVq4uU +KCJgYcKUVhhUNfbIQX+uYavFDKmxyOydQq46cePKdF6lpZllbSPenRN+wNcjICFP +B2yRsSLo84jItijCVz0iW/W/pFQtFXq5qeotYs9Km9x4jSga7KDDlRwYEqvVIN9V +3RL5/i33krOYCN5cRAH37QTnEJV4KGaWb1WfioqXTniGGg42p1Akrqw/nckdIOhH +u99NwL5IPhIv44RXHgYD0H5Cs6A194tW96fEcw8HZQIDAQABo4GkMIGhMB0GA1Ud +DgQWBBSvGMmR8+hzdCFwSHACUS5mGQFAGDAfBgNVHSMEGDAWgBSvGMmR8+hzdCFw +SHACUS5mGQFAGDAsBgNVHREEJTAjgglsb2NhbGhvc3SHBH8AAAGHEAAAAAAAAAAA +AAAAAAAAAAEwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAww +CgYIKwYBBQUHAwEwDQYJKoZIhvcNAQELBQADggEBAEW6hsPlfsNJLuNSodaqlrHC +Phgr+5att4LqRle6TDiAnyKAWSEFmyrdtyNTuqb/2eisfyGlyX8qo+wGR/bBLjXa +u3H1OE3b5tbCEyJhKH/rruqkX+KdwNHs1Sz+08AyRsuQ2R47RNZQ7PxBEqeav8GM +afwjJIvexinpS5X58qlK8lcSrjNa0pigLKl1Q0PXGWpYP6DlX02LE3uluzUDmRUP +mztK8Qn//raTEa7/voWmhAEuJwYg1xDI35h+utnJQeKRTphTA6TZXJkqAPlcIOw7 +B7PMQkvK3wfxlakk3dkO82b4UB992AC4qftpayYdaRtUFKFOsTIVZx/vbmSzltY= +-----END CERTIFICATE----- diff --git a/server/tests/fixtures/localhost-key.pem b/server/tests/fixtures/localhost-key.pem new file mode 100644 index 0000000..a3ec40b --- /dev/null +++ b/server/tests/fixtures/localhost-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCff8PgEzyiunDd +jj6r62UN5E9U5+XNKgZs8AgUhVTN0GtWP44wE501yBmwLEqFzYBgY0ee6PDkG/qA +maqiW4KxLqox6Lyoom387NbDMlWri5QoImBhwpRWGFQ19shBf65hq8UMqbHI7J1C +rjpx48p0XqWlmWVtI96dE37A1yMgIU8HbJGxIujziMi2KMJXPSJb9b+kVC0Vermp +6i1iz0qb3HiNKBrsoMOVHBgSq9Ug31XdEvn+LfeSs5gI3lxEAfftBOcQlXgoZpZv +VZ+KipdOeIYaDjanUCSurD+dyR0g6Ee7303Avkg+Ei/jhFceBgPQfkKzoDX3i1b3 +p8RzDwdlAgMBAAECggEABSJ/WKY15FqSifIWNBcCwf+Qyup2RvRskDLqslfCgv0L +35McjZFWv9BT0gydpOYYlRcidfazfpx5+Yw45pDXScNMqQkAPdY7f+d+AyW1MAMb +1MOajRB5vGB8dt+dfcz8UlpNyy+8Bfxmvv/4k18NJFGXDULoICswnlJDg+DJLt5m +ojgeRbEEgdF1V0f3dKIELk0NyrqH6sS4fv4CdsRh7VvZODUhhknyselyFsHRrOam +/SFLbyHK41Dl6kEd8ax+sobdMFL9QQLwpR05hM8Fw1OCiwW3FuwvaQPYdAvTfRCz +kH5dopbN5x9zGm6TbHp9v5H6kar9yNIJeeT6f+U0ZwKBgQDNjbeCK4K/XCU4WmWH +nnGhIwNLxFejALcSaolcZw3o2xxcw2g+ezqB+cx7szuiTO9JwkjRPi0f4C6dNbJk +uAuVw1LtM6Wjt8FXjl+Q7VfVGkFK8t/SmAAMMJLlSr9M5HpNVl9UFGZei6BwYaj7 +yKjlw2zh+9nCubLGjMJ+G12aCwKBgQDGpJbjqWJ/11ZmWNAvBWHIU+Ajx1s+wWOq +CF8feY9EN/h66L+2K8aZeKwrmzUtLeOk1UpDOwLXmnCbjqIGDTaqwo05rvgXavB5 +vZt7wJVGdYCN8O7Bp38NkXYt3h3pH4x1U8Xj1o5An5L8sgvT+dNaRFl+2iqzP88a +tYsYFpI6TwKBgDj62u7Lju/q5Cpt7I0en9MSOJytLbnyvczuGWuy6YkuC/uu81u2 +ny5eh4+WzKYd+4sPv025lZqrc2CC/ROsbRGz1m4IjhcHHiJeRiGPmXRqVcUn9GGV +XxYB9QZ4pPT0tO5xTfWpvgLhY7UjbUt2gVNHzAwM231+Ko+df2Cjx1unAoGAfkJv +hhVL/mYpDLS23qxVErf9Z8B3RtQ1PQZFMARcA8hvr+/wqyH4AeSpyq3Ehwr7/vFz +WnhPvir0GGv4oFAVx0QR7/A+0OOwJjFWerpWJ4rYQ2A44e5M25rxuwXLjTn4VTsC +NWdS09CZ5PRmgD3zERUQrYiOS5DrWta5qn3uRBECgYEAuATrYy43+BhwS2W1udui +f5AjhjsH4K2R5CmKpUs+wJY8G1uNdvRhaKGjMXYB8T8YOIfq7lJLzz4ykcEatXDv +vbXmxPCiqgZ0SP/ZJm8Ndrnu6aQk6pI6OHoeuKAiQEf8xyILaB6c9HdsXuvmzpvs +qHY8nEr5r5c/ooIN7t2HJD0= +-----END PRIVATE KEY----- diff --git a/server/tests/proxy_security.rs b/server/tests/proxy_security.rs new file mode 100644 index 0000000..889a713 --- /dev/null +++ b/server/tests/proxy_security.rs @@ -0,0 +1,284 @@ +use axum::{ + Router, + body::Body, + http::{HeaderMap, Response, StatusCode, header}, + routing::get, +}; +use futures_util::{StreamExt, stream}; +use serde_json::json; +use std::{convert::Infallible, time::Duration}; + +async fn range(headers: HeaderMap) -> Response { + let bytes = b"0123456789"; + if headers + .get(header::RANGE) + .and_then(|value| value.to_str().ok()) + == Some("bytes=2-5") + { + return Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_RANGE, "bytes 2-5/10") + .header(header::CONTENT_LENGTH, "4") + .body(Body::from(&bytes[2..=5])) + .unwrap(); + } + Response::new(Body::from(bytes.as_slice())) +} + +async fn stall() -> Response { + let first = stream::once(async { Ok::<_, Infallible>(bytes::Bytes::from_static(b"first")) }); + let stalled = stream::pending::>(); + Response::new(Body::from_stream(first.chain(stalled))) +} + +async fn start_fixture() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let router = Router::new() + .route("/ok", get(|| async { "fixture-ok" })) + .route("/range", get(range)) + .route( + "/playlist", + get(|| async { + ( + [ + (header::CONTENT_TYPE, "application/vnd.apple.mpegurl"), + (header::CONTENT_RANGE, "bytes 0-9/10"), + ], + "#EXTM3U\nsegment.ts\n", + ) + }), + ) + .route( + "/redirect-metadata", + get(|| async { + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, "http://169.254.169.254/latest/meta-data/")], + ) + }), + ) + .route("/stall", get(stall)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let task = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + (address, task) +} + +async fn start_tls_fixture() -> anyhow::Result<( + std::net::SocketAddr, + tokio::task::JoinHandle>, +)> { + // This private key is intentionally public test data. Never reuse it outside tests. + let tls = axum_server::tls_rustls::RustlsConfig::from_pem_file( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/localhost-cert.pem" + ), + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/localhost-key.pem" + ), + ) + .await?; + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + let app = Router::new().route("/ok", get(|| async { "secure-fixture-ok" })); + let task = tokio::spawn(async move { + axum_server::from_tcp_rustls(listener, tls)? + .serve(app.into_make_service()) + .await?; + Ok(()) + }); + Ok((address, task)) +} + +fn proxy_url(server: std::net::SocketAddr, target: &str) -> String { + format!("http://{server}/proxy/?d={}", urlencoding::encode(target)) +} + +#[tokio::test] +async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> anyhow::Result<()> { + let (fixture_addr, fixture_task) = start_fixture().await; + let (tls_fixture_addr, tls_fixture_task) = start_tls_fixture().await?; + let config = tempfile::tempdir()?; + let cache = tempfile::tempdir()?; + let config_dir = config.path().join("config"); + let server_config = stream_server::ServerConfig { + http_addr: "127.0.0.1:0".parse().unwrap(), + config_dir: Some(config_dir.clone()), + cache_dir: Some(cache.path().join("cache")), + ..stream_server::ServerConfig::embedded() + }; + let server = tokio::task::spawn_blocking(move || stream_server::start(server_config)).await??; + let base = format!("http://{}", server.http_addr()); + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()?; + let ok_target = format!("http://{fixture_addr}/ok"); + + let denied = client + .get(proxy_url(server.http_addr(), &ok_target)) + .send() + .await?; + let denied_status = denied.status(); + let denied_body = denied.text().await?; + assert_eq!( + denied_status, + StatusCode::FORBIDDEN, + "unexpected proxy response body: {denied_body:?}" + ); + assert_eq!(denied_body, "Proxy destination is blocked"); + + let unauthorized = client + .post(format!("{base}/settings")) + .json(&json!({"allowPrivateNetworkSources": true})) + .send() + .await?; + assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); + + let token = std::fs::read_to_string(config_dir.join("settings-control.token"))?; + assert_eq!(token.len(), 64); + let authorized = |payload: serde_json::Value| { + client + .post(format!("{base}/settings")) + .header("x-stream-server-settings-token", token.clone()) + .json(&payload) + }; + assert_eq!( + authorized(json!({"allowPrivateNetworkSources": true})) + .send() + .await? + .status(), + StatusCode::OK + ); + + let allowed = client + .get(proxy_url(server.http_addr(), &ok_target)) + .send() + .await?; + assert_eq!(allowed.status(), StatusCode::OK); + assert_eq!(allowed.text().await?, "fixture-ok"); + + let range_target = format!("http://{fixture_addr}/range"); + let range = client + .get(proxy_url(server.http_addr(), &range_target)) + .header(header::RANGE, "bytes=2-5") + .send() + .await?; + assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!(range.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(range.bytes().await?, &b"2345"[..]); + + let playlist_target = format!("http://{fixture_addr}/playlist"); + let playlist = client + .get(proxy_url(server.http_addr(), &playlist_target)) + .send() + .await?; + assert_eq!(playlist.status(), StatusCode::OK); + assert!(playlist.headers().get(header::CONTENT_RANGE).is_none()); + assert!(playlist.headers().get(header::CONTENT_ENCODING).is_none()); + assert!(playlist.text().await?.contains("/proxy/?d=")); + + for self_path in ["/heartbeat", "/settings", "/proxy"] { + let self_target = format!("{base}{self_path}"); + assert_eq!( + client + .get(proxy_url(server.http_addr(), &self_target)) + .send() + .await? + .status(), + StatusCode::FORBIDDEN, + "self-listener path was not blocked: {self_path}" + ); + } + let redirect_target = format!("http://{fixture_addr}/redirect-metadata"); + assert_eq!( + client + .get(proxy_url(server.http_addr(), &redirect_target)) + .send() + .await? + .status(), + StatusCode::FORBIDDEN + ); + + let tls_target = format!("https://127.0.0.1:{}/ok", tls_fixture_addr.port()); + let default_tls = tokio::time::timeout( + Duration::from_secs(5), + client + .get(proxy_url(server.http_addr(), &tls_target)) + .send(), + ) + .await??; + assert_eq!(default_tls.status(), StatusCode::BAD_GATEWAY); + assert_eq!(default_tls.text().await?, "Proxy upstream request failed"); + assert_eq!( + authorized(json!({"allowInvalidProxyTlsCertificates": true})) + .send() + .await? + .status(), + StatusCode::OK + ); + let allowed_tls = tokio::time::timeout( + Duration::from_secs(5), + client + .get(proxy_url(server.http_addr(), &tls_target)) + .send(), + ) + .await??; + assert_eq!(allowed_tls.status(), StatusCode::OK); + assert_eq!(allowed_tls.text().await?, "secure-fixture-ok"); + + let stall_target = format!("http://{fixture_addr}/stall"); + let stalled = client + .get(proxy_url(server.http_addr(), &stall_target)) + .send() + .await?; + let mut body = stalled.bytes_stream(); + assert_eq!(body.next().await.unwrap()?, &b"first"[..]); + assert_eq!( + authorized(json!({"allowPrivateNetworkSources": false})) + .send() + .await? + .status(), + StatusCode::OK + ); + let cancelled = tokio::time::timeout(Duration::from_secs(5), body.next()).await?; + assert!(cancelled.unwrap().is_err()); + + let settings: serde_json::Value = client + .get(format!("{base}/settings")) + .send() + .await? + .json() + .await?; + assert_eq!(settings["values"]["allowPrivateNetworkSources"], false); + assert!(!settings.to_string().contains(&token)); + + let diagnostics = tokio::time::timeout( + Duration::from_secs(5), + client.get(format!("{base}/diagnostics/export")).send(), + ) + .await?? + .error_for_status()? + .bytes() + .await?; + assert!( + !diagnostics + .windows(token.len()) + .any(|bytes| bytes == token.as_bytes()) + ); + + fixture_task.abort(); + let _ = fixture_task.await; + tls_fixture_task.abort(); + let _ = tls_fixture_task.await; + let shutdown = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + server.shutdown()?; + server.join() + }) + .await??; + assert_eq!(shutdown, Some(stream_server::ShutdownSource::External)); + Ok(()) +} From 7b43bc7358a69c11c4e2b423afba1eee250e3e8c Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:08:12 -0400 Subject: [PATCH 07/25] docs: expose secure proxy source controls --- README.md | 2 +- docs/network-source-security.md | 116 +++++++++++++ settings-gui/Cargo.toml | 2 +- settings-gui/src/lib.rs | 270 +++++++++++++++++++++++++++++-- settings-gui/src/main.rs | 52 +++++- settings-gui/ui/app.slint | 17 +- settings-gui/ui/components.slint | 8 +- 7 files changed, 447 insertions(+), 20 deletions(-) create mode 100644 docs/network-source-security.md diff --git a/README.md b/README.md index c1d26ba..63903af 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Stream Server is a **fully open-source** replacement for Stremio's proprietary ` - **🌐 Network Info**: `/network-info` endpoint for interface discovery - **💓 Heartbeat**: `/heartbeat` for health checks - **⚙️ Settings**: Runtime-configurable via `/settings` -- **🔒 BitTorrent Privacy Controls**: DHT, PeX, LSD, encryption, interface binding, ports, and proxy settings. See [BitTorrent Settings](docs/bittorrent-settings.md). +- **🔒 Privacy Controls**: Safe `/proxy` network-source defaults plus BitTorrent DHT, PeX, LSD, encryption, interface binding, ports, and proxy settings. See [Network Source Security](docs/network-source-security.md) and [BitTorrent Settings](docs/bittorrent-settings.md). --- diff --git a/docs/network-source-security.md b/docs/network-source-security.md new file mode 100644 index 0000000..e42ffdf --- /dev/null +++ b/docs/network-source-security.md @@ -0,0 +1,116 @@ +# Network source security + +Stream Server protects the `/proxy` endpoint from server-side request forgery (SSRF). By default, +`/proxy` can contact public HTTP and HTTPS destinations only, and HTTPS certificates must be valid. +These defaults prevent a webpage or LAN client that can reach Stream Server from using it to probe +services on your computer, local network, or cloud metadata endpoints. + +This protection applies to `/proxy` only. Subtitle, archive, FTP, NZB/NNTP, HLS/casting, remote +torrent, tracker, and updater network inputs are outside this policy. + +## Configure the options in the settings app + +Open **Settings > Privacy**. Two protected controls are available: + +- **Allow private/LAN proxy sources** lets `/proxy` reach loopback, private, link-local, CGNAT, + IPv6 ULA, and directly connected network sources. Enable it only for a media source you trust. +- **Allow invalid proxy TLS certificates** disables certificate verification for `/proxy` only. + Enable it only for a trusted self-signed HTTPS source. + +The standalone settings app enables these controls only when it connects to an IP-literal loopback +address and can read the local `settings-control.token` file. The embedded tray settings window is +trusted directly. A remote settings connection can still read settings and change ordinary options, +but cannot change either protected option. + +`BitTorrent SSRF mitigation` is separate. It maps to `btSsrfMitigation`, remains enabled by +default, and controls libtorrent behavior rather than `/proxy`. + +## Configure the local HTTP API + +Protected changes require all of the following: + +1. Connect directly from loopback (`127.0.0.1` or `::1`). +2. Read the per-install token from the configuration directory without printing it. +3. Send it in `x-stream-server-settings-token`. +4. Send JSON booleans for the protected options. + +On Windows PowerShell: + +```powershell +$tokenPath = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'stremio-server\settings-control.token' +$settingsToken = (Get-Content -Raw -LiteralPath $tokenPath).TrimEnd("`r", "`n") +$headers = @{ 'x-stream-server-settings-token' = $settingsToken } +$body = @{ allowPrivateNetworkSources = $true; allowInvalidProxyTlsCertificates = $false } | ConvertTo-Json -Compress +Invoke-RestMethod -Method Post -Uri 'http://127.0.0.1:11470/settings' -Headers $headers -ContentType 'application/json' -Body $body +Remove-Variable settingsToken, headers, body +``` + +On Linux or macOS with `curl`: + +```sh +token_file="${XDG_CONFIG_HOME:-$HOME/.config}/stremio-server/settings-control.token" +settings_token="$(tr -d '\r\n' < "$token_file")" +curl --fail-with-body --request POST 'http://127.0.0.1:11470/settings' \ + --header "x-stream-server-settings-token: ${settings_token}" \ + --header 'content-type: application/json' \ + --data '{"allowPrivateNetworkSources":true,"allowInvalidProxyTlsCertificates":false}' +unset settings_token +``` + +The token is not returned by the settings API or included in diagnostics exports. Treat the token +file as a local secret; do not paste it into logs, issue reports, or configuration files. + +## Configure files or environment variables + +The equivalent `settings.json` keys are: + +```json +{ + "allowPrivateNetworkSources": false, + "allowInvalidProxyTlsCertificates": false +} +``` + +Stop Stream Server before editing `settings.json`, then restart it. The server also accepts these +environment variables: + +- `STREMIO_ALLOW_PRIVATE_NETWORK_SOURCES` +- `STREMIO_ALLOW_INVALID_PROXY_TLS_CERTIFICATES` + +Accepted values are `1`, `true`, `yes`, or `on`, and `0`, `false`, `no`, or `off`, without leading +or trailing whitespace and case-insensitively. Environment values override the persisted file at +startup. A runtime GUI/API change can affect the current process, but the environment value wins +again after the next restart. + +## Destination policy + +| Destination class | Default | With private/LAN opt-in | +| --- | --- | --- | +| Public HTTP/HTTPS address | Allowed | Allowed | +| Loopback and RFC 1918 private address | Blocked | Allowed | +| CGNAT, IPv6 ULA, and non-metadata link-local address | Blocked | Allowed | +| Current directly connected network | Blocked | Allowed | +| Stream Server's own HTTP/HTTPS listener | Blocked | Blocked | +| Known cloud/container metadata address | Blocked | Blocked | +| Unspecified, multicast, broadcast, documentation, benchmark, reserved, or future-use address | Blocked | Blocked | + +Every DNS answer and every redirect destination must pass the policy. Stream Server pins validated +DNS results to the outbound connection, ignores system HTTP proxies for `/proxy`, blocks HTTPS to +HTTP redirect downgrades, and never permits its own listener through the proxy. + +The invalid-certificate option does not broaden the address policy. For example, a self-signed LAN +source requires both the private/LAN option and the invalid-certificate option. Prefer installing a +valid certificate whenever possible. + +## Troubleshooting + +- `400 Invalid proxy request`: the URL/options are malformed, use an unsupported scheme, contain an + unsafe custom header, or exceed an input limit. +- `403 Proxy destination is blocked`: the resolved address, a redirect, or the server's own listener + is denied. Enable private/LAN sources only if the destination is a trusted local media source. +- HTTP `403` JSON from `POST /settings`: a protected value changed without a valid local token, or + the request did not originate from loopback. +- `502 Proxy upstream request failed`: DNS, TLS, connection, redirect, response encoding, playlist + size, or read timeout validation failed. A self-signed source may require the TLS opt-in. +- `503 Proxy capacity is exhausted`: 64 proxy requests are already active. Retry after the response's + `Retry-After` delay. diff --git a/settings-gui/Cargo.toml b/settings-gui/Cargo.toml index 2bd2340..e9d359b 100644 --- a/settings-gui/Cargo.toml +++ b/settings-gui/Cargo.toml @@ -22,7 +22,7 @@ reqwest = { version = "0.13.4", features = ["json"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" slint = { version = "1.17.1", default-features = false, features = ["std", "backend-winit", "renderer-femtovg", "compat-1-2"] } -tokio = { version = "1.53.1", features = ["rt-multi-thread", "time"] } +tokio = { version = "1.53.1", features = ["rt-multi-thread", "time", "macros"] } async-trait = "0.1.92" [build-dependencies] diff --git a/settings-gui/src/lib.rs b/settings-gui/src/lib.rs index 0d4af26..599a0e7 100644 --- a/settings-gui/src/lib.rs +++ b/settings-gui/src/lib.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use reqwest::Client; +use reqwest::{Client, header::HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::Value; use slint::{ComponentHandle, ModelRc, SharedString, VecModel, Weak}; @@ -27,6 +27,10 @@ pub struct SettingsPayload { pub cache_size: f64, #[serde(rename = "proxyStreamsEnabled", default)] pub proxy_streams_enabled: bool, + #[serde(rename = "allowPrivateNetworkSources", default)] + pub allow_private_network_sources: bool, + #[serde(rename = "allowInvalidProxyTlsCertificates", default)] + pub allow_invalid_proxy_tls_certificates: bool, #[serde(rename = "btMaxConnections", default)] pub bt_max_connections: u64, #[serde(rename = "btHandshakeTimeout", default)] @@ -131,6 +135,10 @@ pub struct ChangelogSectionPayload { #[async_trait::async_trait] pub trait ServerConnector: Send + Sync + 'static { + fn can_update_protected_settings(&self) -> bool { + false + } + async fn get_settings(&self) -> Result; async fn apply_settings(&self, settings: SettingsPayload) -> Result<()>; async fn get_logs(&self) -> Result; @@ -142,16 +150,65 @@ pub trait ServerConnector: Send + Sync + 'static { pub struct HttpConnector { client: Client, server_url: String, + protected_client: Option, + settings_control_token: Option, } impl HttpConnector { pub fn new(client: Client, server_url: String) -> Self { - Self { client, server_url } + Self { + client, + server_url, + protected_client: None, + settings_control_token: None, + } + } + + pub fn with_settings_control_token(mut self, token: HeaderValue) -> Result { + let url = reqwest::Url::parse(&self.server_url) + .context("server URL is invalid for protected settings")?; + if !matches!(url.scheme(), "http" | "https") + || url + .host_str() + .and_then(|host| host.parse::().ok()) + .is_none_or(|ip| !ip.is_loopback()) + { + anyhow::bail!("protected settings require an IP-literal loopback server URL"); + } + + self.protected_client = Some( + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .build() + .context("failed to create protected settings client")?, + ); + self.settings_control_token = Some(token); + Ok(self) + } +} + +pub fn parse_settings_control_token(bytes: &[u8]) -> Result { + let token = bytes + .strip_suffix(b"\r\n") + .or_else(|| bytes.strip_suffix(b"\n")) + .unwrap_or(bytes); + if token.len() != 64 + || !token + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + anyhow::bail!("settings control token is invalid"); } + HeaderValue::from_bytes(token).context("settings control token is invalid") } #[async_trait::async_trait] impl ServerConnector for HttpConnector { + fn can_update_protected_settings(&self) -> bool { + self.protected_client.is_some() && self.settings_control_token.is_some() + } + async fn get_settings(&self) -> Result { let res = self .client @@ -169,12 +226,24 @@ impl ServerConnector for HttpConnector { async fn apply_settings(&self, settings: SettingsPayload) -> Result<()> { let value = serde_json::to_value(&settings)?; - self.client - .post(format!("{}/settings", self.server_url)) - .json(&value) - .send() - .await? - .error_for_status()?; + let url = format!("{}/settings", self.server_url); + if let (Some(client), Some(token)) = (&self.protected_client, &self.settings_control_token) + { + client + .post(url) + .header("x-stream-server-settings-token", token) + .json(&value) + .send() + .await? + .error_for_status()?; + } else { + self.client + .post(url) + .json(&value) + .send() + .await? + .error_for_status()?; + } Ok(()) } @@ -356,6 +425,7 @@ pub fn run(connector: Arc) -> Result<()> { ui.set_log_entries(ModelRc::new(VecModel::from(Vec::::new()))); ui.set_log_timeline_bins(ModelRc::new(VecModel::from(vec![1; 32]))); ui.set_log_target_options(ModelRc::new(VecModel::from(Vec::::new()))); + ui.set_protected_settings_enabled(connector.can_update_protected_settings()); // Hide (don't destroy) the window when the user clicks close so // the event loop stays alive and the window can be re-shown later. @@ -688,7 +758,12 @@ fn refresh_settings(handle: Handle, connector: Arc, weak: W let _ = slint::invoke_from_event_loop(move || { if let Some(ui) = weak.upgrade() { write_settings_to_ui(&ui, settings); - ui.set_status_text("Settings loaded".into()); + let status = if connector.can_update_protected_settings() { + "Settings loaded" + } else { + "Settings loaded; protected proxy controls require a local token" + }; + ui.set_status_text(status.into()); } }); } @@ -1451,6 +1526,8 @@ fn write_settings_to_ui(ui: &AppWindow, settings: SettingsPayload) { ui.set_cache_root(settings.cache_root.into()); ui.set_cache_size(settings.cache_size.to_string().into()); ui.set_proxy_streams_enabled(settings.proxy_streams_enabled); + ui.set_allow_private_network_sources(settings.allow_private_network_sources); + ui.set_allow_invalid_proxy_tls_certificates(settings.allow_invalid_proxy_tls_certificates); ui.set_bt_max_connections(settings.bt_max_connections.to_string().into()); ui.set_bt_handshake_timeout(settings.bt_handshake_timeout.to_string().into()); ui.set_bt_request_timeout(settings.bt_request_timeout.to_string().into()); @@ -1489,6 +1566,8 @@ fn read_settings_from_ui(ui: &AppWindow) -> Result { cache_root: ui.get_cache_root().to_string(), cache_size: parse_f64("cacheSize", ui.get_cache_size())?, proxy_streams_enabled: ui.get_proxy_streams_enabled(), + allow_private_network_sources: ui.get_allow_private_network_sources(), + allow_invalid_proxy_tls_certificates: ui.get_allow_invalid_proxy_tls_certificates(), bt_max_connections: parse_u64("btMaxConnections", ui.get_bt_max_connections())?, bt_handshake_timeout: parse_u64("btHandshakeTimeout", ui.get_bt_handshake_timeout())?, bt_request_timeout: parse_u64("btRequestTimeout", ui.get_bt_request_timeout())?, @@ -1555,3 +1634,176 @@ fn parse_f64(field: &str, value: SharedString) -> Result { .parse::() .with_context(|| format!("{field} must be a number")) } + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + io::{Read, Write}, + net::TcpListener, + sync::mpsc, + thread, + }; + + fn test_token() -> HeaderValue { + HeaderValue::from_static("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + } + + fn spawn_http_server( + responses: Vec, + ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (sent, received) = mpsc::channel(); + let task = thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + while !request.windows(4).any(|window| window == b"\r\n\r\n") { + let read = stream.read(&mut buffer).unwrap(); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + } + let _ = sent.send(String::from_utf8_lossy(&request).into_owned()); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + (format!("http://{address}"), received, task) + } + + fn empty_settings() -> SettingsPayload { + serde_json::from_value(serde_json::json!({})).unwrap() + } + + #[test] + fn protected_proxy_settings_round_trip_with_camel_case_names() { + let payload: SettingsPayload = serde_json::from_value(serde_json::json!({ + "allowPrivateNetworkSources": true, + "allowInvalidProxyTlsCertificates": true + })) + .unwrap(); + assert!(payload.allow_private_network_sources); + assert!(payload.allow_invalid_proxy_tls_certificates); + + let serialized = serde_json::to_value(payload).unwrap(); + assert_eq!(serialized["allowPrivateNetworkSources"], true); + assert_eq!(serialized["allowInvalidProxyTlsCertificates"], true); + } + + #[test] + fn token_parser_accepts_one_line_ending_and_rejects_surrounding_whitespace() { + let token = "a".repeat(64); + assert!(parse_settings_control_token(token.as_bytes()).is_ok()); + assert!(parse_settings_control_token(format!("{token}\n").as_bytes()).is_ok()); + assert!(parse_settings_control_token(format!("{token}\r\n").as_bytes()).is_ok()); + assert!(parse_settings_control_token(format!(" {token}").as_bytes()).is_err()); + assert!(parse_settings_control_token(format!("{token} \n").as_bytes()).is_err()); + assert!(parse_settings_control_token(b"abc").is_err()); + } + + #[test] + fn protected_token_requires_an_ip_literal_loopback_server() { + let token = test_token(); + for server_url in [ + "http://localhost:11470", + "http://192.168.1.10:11470", + "https://example.com", + ] { + assert!( + HttpConnector::new(Client::new(), server_url.to_string()) + .with_settings_control_token(token.clone()) + .is_err(), + "accepted protected token for {server_url}" + ); + } + + let connector = HttpConnector::new(Client::new(), "http://127.0.0.1:11470".to_string()) + .with_settings_control_token(token) + .unwrap(); + assert!(connector.can_update_protected_settings()); + } + + #[tokio::test] + async fn token_is_attached_only_to_the_protected_settings_post() { + let json = "{\"values\":{}}"; + let (url, requests, task) = spawn_http_server(vec![ + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{json}", + json.len() + ), + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(), + ]); + let connector = HttpConnector::new(Client::new(), url) + .with_settings_control_token(test_token()) + .unwrap(); + + connector.get_settings().await.unwrap(); + connector.apply_settings(empty_settings()).await.unwrap(); + let get_request = requests.recv_timeout(Duration::from_secs(5)).unwrap(); + let post_request = requests.recv_timeout(Duration::from_secs(5)).unwrap(); + task.join().unwrap(); + + assert!(get_request.starts_with("GET /settings ")); + assert!( + !get_request + .to_ascii_lowercase() + .contains("x-stream-server-settings-token") + ); + assert!(post_request.starts_with("POST /settings ")); + assert!(post_request.to_ascii_lowercase().contains( + "x-stream-server-settings-token: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + )); + } + + #[tokio::test] + async fn token_client_does_not_follow_redirects() { + let redirect_target = TcpListener::bind("127.0.0.1:0").unwrap(); + redirect_target.set_nonblocking(true).unwrap(); + let destination = redirect_target.local_addr().unwrap(); + let (url, _, task) = spawn_http_server(vec![format!( + "HTTP/1.1 307 Temporary Redirect\r\nLocation: http://{destination}/stolen\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + )]); + let connector = HttpConnector::new(Client::new(), url) + .with_settings_control_token(test_token()) + .unwrap(); + + connector.apply_settings(empty_settings()).await.unwrap(); + task.join().unwrap(); + assert!(matches!( + redirect_target.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + + #[tokio::test] + async fn token_client_bypasses_a_configured_http_proxy() { + let proxy = TcpListener::bind("127.0.0.1:0").unwrap(); + proxy.set_nonblocking(true).unwrap(); + let proxy_url = format!("http://{}", proxy.local_addr().unwrap()); + let (url, requests, target_task) = spawn_http_server(vec![ + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string(), + ]); + let ordinary_client = Client::builder() + .proxy(reqwest::Proxy::all(proxy_url).unwrap()) + .build() + .unwrap(); + let connector = HttpConnector::new(ordinary_client, url) + .with_settings_control_token(test_token()) + .unwrap(); + + connector.apply_settings(empty_settings()).await.unwrap(); + let request = requests.recv_timeout(Duration::from_secs(5)).unwrap(); + target_task.join().unwrap(); + assert!(request.starts_with("POST /settings ")); + assert!(matches!( + proxy.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } +} diff --git a/settings-gui/src/main.rs b/settings-gui/src/main.rs index 5bceb38..cbfe5bb 100644 --- a/settings-gui/src/main.rs +++ b/settings-gui/src/main.rs @@ -2,31 +2,69 @@ use anyhow::{Context, Result}; use reqwest::Client; -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; fn main() -> Result<()> { - let server_url = parse_server_url(); + let (server_url, token_path) = parse_arguments(); let http_client = Client::builder() .build() .context("failed to create HTTP client")?; - let connector = Arc::new(settings_gui::HttpConnector::new(http_client, server_url)); + let mut connector = settings_gui::HttpConnector::new(http_client, server_url.clone()); + if server_url_has_ip_literal_loopback(&server_url) { + let path = token_path.or_else(default_token_path); + if let Some(path) = path { + match std::fs::read(&path) { + Ok(bytes) => { + let token = + settings_gui::parse_settings_control_token(&bytes).with_context(|| { + format!("invalid settings token file: {}", path.display()) + })?; + connector = connector.with_settings_control_token(token)?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("failed to read settings token file: {}", path.display()) + }); + } + } + } + } + let connector = Arc::new(connector); settings_gui::run(connector) } -fn parse_server_url() -> String { +fn parse_arguments() -> (String, Option) { + let mut server_url = "http://127.0.0.1:11470".to_string(); + let mut token_path = None; let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { if arg == "--server-url" { if let Some(value) = args.next() { - return trim_server_url(value); + server_url = trim_server_url(value); } } else if let Some(value) = arg.strip_prefix("--server-url=") { - return trim_server_url(value.to_string()); + server_url = trim_server_url(value.to_string()); + } else if arg == "--settings-token-file" { + token_path = args.next().map(PathBuf::from); + } else if let Some(value) = arg.strip_prefix("--settings-token-file=") { + token_path = Some(PathBuf::from(value)); } } - "http://127.0.0.1:11470".to_string() + (server_url, token_path) } fn trim_server_url(value: String) -> String { value.trim_end_matches('/').to_string() } + +fn server_url_has_ip_literal_loopback(server_url: &str) -> bool { + reqwest::Url::parse(server_url) + .ok() + .and_then(|url| url.host_str()?.parse::().ok()) + .is_some_and(|ip| ip.is_loopback()) +} + +fn default_token_path() -> Option { + dirs::config_dir().map(|dir| dir.join("stremio-server").join("settings-control.token")) +} diff --git a/settings-gui/ui/app.slint b/settings-gui/ui/app.slint index 0c79dac..55838d7 100644 --- a/settings-gui/ui/app.slint +++ b/settings-gui/ui/app.slint @@ -92,6 +92,9 @@ export component AppWindow inherits Window { in-out property cache-root; in-out property cache-size; in-out property proxy-streams-enabled; + in-out property allow-private-network-sources; + in-out property allow-invalid-proxy-tls-certificates; + in-out property protected-settings-enabled: false; in-out property bt-max-connections; in-out property bt-handshake-timeout; in-out property bt-request-timeout; @@ -357,7 +360,19 @@ export component AppWindow inherits Window { ToggleRow { label: "Anonymous mode"; checked <=> root.bt-anonymous-mode; } ToggleRow { label: "Allow multiple connections per IP"; checked <=> root.bt-allow-multiple-connections-per-ip; } ToggleRow { label: "Validate HTTPS trackers"; checked <=> root.bt-validate-https-trackers; } - ToggleRow { label: "SSRF mitigation"; checked <=> root.bt-ssrf-mitigation; } + ToggleRow { + label: "Allow private/LAN proxy sources"; + description: "Lets /proxy reach devices and services on this computer or LAN. Known cloud metadata addresses remain blocked."; + enabled: root.protected-settings-enabled; + checked <=> root.allow-private-network-sources; + } + ToggleRow { + label: "Allow invalid proxy TLS certificates"; + description: "Disables certificate verification for /proxy only. Enable only for a trusted self-signed source."; + enabled: root.protected-settings-enabled; + checked <=> root.allow-invalid-proxy-tls-certificates; + } + ToggleRow { label: "BitTorrent SSRF mitigation"; checked <=> root.bt-ssrf-mitigation; } } // ---------------- Network ---------------- diff --git a/settings-gui/ui/components.slint b/settings-gui/ui/components.slint index bd1ff7e..a1829c8 100644 --- a/settings-gui/ui/components.slint +++ b/settings-gui/ui/components.slint @@ -135,6 +135,7 @@ export component IconButton inherits Rectangle { // A custom on/off switch bound to a bool. Two-way bindable via `checked`. export component Toggle inherits Rectangle { in-out property checked; + in property enabled: true; width: 42px; height: 24px; @@ -142,12 +143,15 @@ export component Toggle inherits Rectangle { border-width: 1px; border-color: root.checked ? Theme.accent : Theme.border; background: root.checked ? Theme.accent : Theme.surface-alt; + opacity: root.enabled ? 1 : 0.45; animate background, border-color { duration: 140ms; } touch := TouchArea { mouse-cursor: pointer; clicked => { - root.checked = !root.checked; + if root.enabled { + root.checked = !root.checked; + } } } @@ -372,6 +376,7 @@ export component FieldRow inherits HorizontalLayout { export component ToggleRow inherits HorizontalLayout { in property label; in property description; + in property enabled: true; in-out property checked; height: root.description != "" ? 56px : 40px; @@ -417,6 +422,7 @@ export component ToggleRow inherits HorizontalLayout { alignment: center; Toggle { checked <=> root.checked; + enabled: root.enabled; } } } From cc18dbd435fff050f0165086f7f9dbc9c30f2d84 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:37:54 -0400 Subject: [PATCH 08/25] security: close proxy SSRF audit gaps --- server/src/network_security/ip.rs | 38 ++- server/src/network_security/resolver.rs | 158 +++++++-- server/src/routes/proxy.rs | 411 ++++++++++++++++++++++-- server/src/routes/system.rs | 1 - server/src/settings_control.rs | 51 ++- server/tests/proxy_security.rs | 28 +- settings-gui/src/lib.rs | 42 ++- 7 files changed, 647 insertions(+), 82 deletions(-) diff --git a/server/src/network_security/ip.rs b/server/src/network_security/ip.rs index bc1a61f..32dd9ad 100644 --- a/server/src/network_security/ip.rs +++ b/server/src/network_security/ip.rs @@ -56,8 +56,10 @@ const ALWAYS_BLOCKED_V4: &[&str] = &[ ]; const METADATA_V4: &[&str] = &[ + "168.63.129.16/32", "169.254.169.254/32", "169.254.170.2/32", + "169.254.170.23/32", "100.100.100.200/32", "192.0.0.192/32", ]; @@ -76,7 +78,7 @@ const ALWAYS_BLOCKED_V6: &[&str] = &[ "ff00::/8", ]; -const METADATA_V6: &[&str] = &["fd00:ec2::254/128"]; +const METADATA_V6: &[&str] = &["fd00:ec2::23/128", "fd00:ec2::254/128", "fd20:ce::254/128"]; static PRIVATE_V4_NETS: LazyLock> = LazyLock::new(|| parse_networks(PRIVATE_V4)); static ALWAYS_BLOCKED_V4_NETS: LazyLock> = @@ -131,6 +133,12 @@ fn extract_ipv4_compatible(ip: Ipv6Addr) -> Option { None } +fn extract_ipv4_translatable(ip: Ipv6Addr) -> Option { + let octets = ip.octets(); + (octets[..8].iter().all(|byte| *byte == 0) && octets[8..12] == [0xff, 0xff, 0x00, 0x00]) + .then(|| Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15])) +} + pub(crate) fn extract_rfc6052(ip: Ipv6Addr, prefix: Nat64Prefix) -> Option { const VALID_LENGTHS: &[u8] = &[32, 40, 48, 56, 64, 96]; if !VALID_LENGTHS.contains(&prefix.length) { @@ -180,9 +188,16 @@ fn embedded_nat64(ip: Ipv6Addr, prefixes: &[Nat64Prefix]) -> Option { let value = u128::from(ip); let prefix = u128::from(LOCAL_USE.network); let mask = u128::MAX << 80; - (value & mask == prefix & mask).then(|| Ipv4Addr::from(value as u32)) + if value & mask != prefix & mask { + return None; + } + let rfc6052 = extract_rfc6052(ip, LOCAL_USE)?; + if rfc6052 == Ipv4Addr::UNSPECIFIED { + Some(Ipv4Addr::from(value as u32)) + } else { + Some(rfc6052) + } }) - .or_else(|| extract_rfc6052(ip, LOCAL_USE)) .or_else(|| { prefixes .iter() @@ -199,6 +214,7 @@ fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> De return classify_v4(embedded, local); } if extract_ipv4_compatible(ip).is_some() + || extract_ipv4_translatable(ip).is_some() || extract_6to4(ip).is_some() || extract_teredo_client(ip).is_some() { @@ -277,8 +293,10 @@ mod tests { fn ipv4_metadata_precedes_private_source_ranges() { let local = LocalNetworks::default(); for value in [ + "168.63.129.16", "169.254.169.254", "169.254.170.2", + "169.254.170.23", "100.100.100.200", "192.0.0.192", ] { @@ -355,6 +373,7 @@ mod tests { "2001:0000:4136:e378:8000:63bf:3fff:fdd2", "64:ff9b::7f00:1", "64:ff9b:1::7f00:1", + "64:ff9b:1:7f00:0:100::", ]; for value in cases { assert_ne!( @@ -370,6 +389,7 @@ mod tests { let local = LocalNetworks::default(); for value in [ "::93.184.216.34", + "::ffff:0:93.184.216.34", "2002:5db8:d822::", "2001:0000:4136:e378:8000:63bf:a247:27dd", ] { @@ -392,6 +412,7 @@ mod tests { ("::ffff:93.184.216.34", &[][..]), ("64:ff9b::5db8:d822", &[][..]), ("64:ff9b:1::5db8:d822", &[][..]), + ("64:ff9b:1:5db8:d8:2200::", &[][..]), ("2001:db8:64::5db8:d822", &discovered[..]), ] { assert_eq!( @@ -405,10 +426,13 @@ mod tests { #[test] fn ipv6_metadata_precedes_private_source_ranges() { let local = LocalNetworks::default(); - assert_eq!( - classify_ip("fd00:ec2::254".parse().unwrap(), &local, &[]), - DestinationClass::AlwaysBlocked - ); + for value in ["fd00:ec2::23", "fd00:ec2::254", "fd20:ce::254"] { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, &[]), + DestinationClass::AlwaysBlocked, + "{value}" + ); + } } #[test] diff --git a/server/src/network_security/resolver.rs b/server/src/network_security/resolver.rs index ef7f367..1b7a462 100644 --- a/server/src/network_security/resolver.rs +++ b/server/src/network_security/resolver.rs @@ -74,9 +74,12 @@ impl LocalNetworkProvider for SystemLocalNetworkProvider { } fn network_for_interface(interface: &if_addrs::Interface) -> Option { - let eligible = interface.is_oper_up() - || (interface.is_loopback() && interface.oper_status == if_addrs::IfOperStatus::Unknown); - if !eligible { + if matches!( + interface.oper_status, + if_addrs::IfOperStatus::Down + | if_addrs::IfOperStatus::NotPresent + | if_addrs::IfOperStatus::LowerLayerDown + ) { return None; } @@ -128,6 +131,7 @@ pub(crate) struct DestinationValidator { struct CachedNat64Prefixes { expires_at: Instant, prefixes: Vec, + failed: bool, } impl DestinationValidator { @@ -195,7 +199,7 @@ impl DestinationValidator { } resolved.sort_unstable(); resolved.dedup(); - let nat64 = self.nat64_prefixes_for(&resolved).await; + let nat64 = self.nat64_prefixes_for(&resolved, &local).await?; self.validate_addresses(&resolved, &local, &nat64, policy)?; return Ok(ResolvedDestination { url: canonical_url, @@ -210,7 +214,7 @@ impl DestinationValidator { .current() .await .map_err(|_| DestinationError::LocalNetworkUnavailable)?; - let nat64 = self.nat64_prefixes_for(&addresses).await; + let nat64 = self.nat64_prefixes_for(&addresses, &local).await?; self.validate_addresses(&addresses, &local, &nat64, policy)?; Ok(ResolvedDestination { url: canonical_url, @@ -262,6 +266,11 @@ impl DestinationValidator { (IpAddr::V6(_), IpAddr::V6(ip)) => { ip.is_loopback() || local.contains(IpAddr::V6(ip)) } + // An unspecified IPv6 socket can be dual-stack. Treat mapped + // IPv4 addresses on local interfaces as self-listener targets. + (IpAddr::V6(_), IpAddr::V4(ip)) => { + ip.is_loopback() || local.contains(IpAddr::V4(ip)) + } _ => false, }; } @@ -270,9 +279,21 @@ impl DestinationValidator { }) } - async fn nat64_prefixes_for(&self, addresses: &[SocketAddr]) -> Vec { - if !addresses.iter().any(SocketAddr::is_ipv6) { - return Vec::new(); + async fn nat64_prefixes_for( + &self, + addresses: &[SocketAddr], + local: &LocalNetworks, + ) -> Result, DestinationError> { + let needs_discovery = addresses.iter().any(|address| match address.ip() { + IpAddr::V4(_) => false, + IpAddr::V6(ip) => { + super::ip::normalized_embedded_ipv4(ip, &[]).is_none() + && super::ip::classify_ip(IpAddr::V6(ip), local, &[]) + == DestinationClass::Public + } + }); + if !needs_discovery { + return Ok(Vec::new()); } if let Some(prefixes) = self.cached_nat64_prefixes().await { @@ -284,14 +305,18 @@ impl DestinationValidator { return prefixes; } - let prefixes = self - .resolver - .resolve("ipv4only.arpa", 0) - .await - .ok() - .filter(|answers| !answers.is_empty() && answers.len() <= MAX_DNS_ANSWERS) - .map(|answers| discover_nat64_prefixes(&answers)) - .unwrap_or_default(); + let discovered = match self.resolver.resolve("ipv4only.arpa", 0).await { + Ok(answers) if !answers.is_empty() && answers.len() <= MAX_DNS_ANSWERS => { + let has_ipv6 = answers.iter().any(SocketAddr::is_ipv6); + let prefixes = discover_nat64_prefixes(&answers); + if has_ipv6 && prefixes.is_empty() { + Err(DestinationError::ResolutionFailed) + } else { + Ok(prefixes) + } + } + Ok(_) | Err(_) => Err(DestinationError::ResolutionFailed), + }; let expires_at = self .clock @@ -300,17 +325,24 @@ impl DestinationValidator { .unwrap_or_else(|| self.clock.now()); *self.nat64_cache.lock().await = Some(CachedNat64Prefixes { expires_at, - prefixes: prefixes.clone(), + prefixes: discovered.as_ref().cloned().unwrap_or_default(), + failed: discovered.is_err(), }); - prefixes + discovered } - async fn cached_nat64_prefixes(&self) -> Option> { + async fn cached_nat64_prefixes(&self) -> Option, DestinationError>> { let cache = self.nat64_cache.lock().await; cache .as_ref() .filter(|cached| self.clock.now() < cached.expires_at) - .map(|cached| cached.prefixes.clone()) + .map(|cached| { + if cached.failed { + Err(DestinationError::ResolutionFailed) + } else { + Ok(cached.prefixes.clone()) + } + }) } } @@ -355,9 +387,7 @@ fn discover_nat64_prefixes(answers: &[SocketAddr]) -> Vec { } } } - if seen == [true, true] - && !prefixes.contains(&prefix) - { + if seen == [true, true] && !prefixes.contains(&prefix) { prefixes.push(prefix); } } @@ -525,16 +555,20 @@ mod tests { } #[tokio::test] - async fn integer_loopback_is_blocked_before_connect() { + async fn alternate_ipv4_loopback_spellings_are_blocked_before_connect() { let resolver = FakeResolver::new(Vec::new()); let validator = validator(resolver.clone()); - let result = validator - .validate( - &Url::parse("http://2130706433/").unwrap(), - OutboundPolicy::default(), - ) - .await; - assert_eq!(result.unwrap_err(), DestinationError::Blocked); + for target in [ + "http://2130706433/", + "http://0x7f000001/", + "http://017700000001/", + "http://127.1/", + ] { + let result = validator + .validate(&Url::parse(target).unwrap(), OutboundPolicy::default()) + .await; + assert_eq!(result.unwrap_err(), DestinationError::Blocked, "{target}"); + } assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); } @@ -677,6 +711,32 @@ mod tests { ); } + #[tokio::test] + async fn ipv6_wildcard_listener_blocks_ipv4_mapped_local_interfaces() { + let validator = validator_with_listeners( + FakeResolver::new(vec!["[::ffff:8.8.8.10]:11470".parse().unwrap()]), + LocalNetworks { + interfaces: vec!["8.8.8.8/29".parse().unwrap()], + }, + vec![ListenerBinding { + address: "::".parse().unwrap(), + port: 11470, + }], + ); + assert_eq!( + validator + .validate( + &Url::parse("http://self-via-mapped.example:11470/").unwrap(), + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + } + #[tokio::test] async fn a_different_port_on_the_listener_host_remains_eligible() { let validator = validator_with_listeners( @@ -744,6 +804,38 @@ mod tests { assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn nat64_discovery_failure_rejects_unclassifiable_public_ipv6() { + let validator = validator(FakeResolver::failing()); + assert_eq!( + validator + .validate( + &Url::parse("http://[2001:4860:4860::8888]/").unwrap(), + OutboundPolicy::default(), + ) + .await + .unwrap_err(), + DestinationError::ResolutionFailed + ); + } + + #[tokio::test] + async fn nat64_discovery_failure_does_not_hide_standard_prefixes() { + let validator = validator(FakeResolver::failing()); + assert_eq!( + validator + .validate( + &Url::parse("http://[64:ff9b::a9fe:a9fe]/").unwrap(), + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + } + #[tokio::test] async fn nat64_discovery_cache_refreshes_after_five_minutes() { let resolver = FakeResolver::new(vec![ @@ -811,7 +903,7 @@ mod tests { } #[test] - fn local_interface_snapshots_ignore_down_links_but_keep_unknown_loopback() { + fn local_interface_snapshots_ignore_down_links_but_fail_closed_on_unknown_status() { fn interface( ip: std::net::Ipv4Addr, prefixlen: u8, @@ -854,7 +946,7 @@ mod tests { 24, if_addrs::IfOperStatus::Unknown, ); - assert!(network_for_interface(&unknown_public).is_none()); + assert!(network_for_interface(&unknown_public).is_some()); } #[tokio::test(start_paused = true)] diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 2ff6dad..bb7669b 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -262,6 +262,9 @@ async fn fetch_with_redirects( return Err(ProxyError::Upstream); } next.set_fragment(None); + if next.as_str().len() > MAX_TARGET_URL { + return Err(ProxyError::Upstream); + } if !same_authority(&destination.url, &next) { let _ = next.set_username(""); let _ = next.set_password(None); @@ -363,11 +366,16 @@ async fn handle_proxy( Ok(body) => body, Err(error) => return proxy_error_response(error), }; + let ProxyRequestContext { + cancellation, + capacity, + .. + } = context; return build_proxy_response( status, &upstream_headers, &request.response_headers, - Body::from(body), + buffered_proxy_body(Bytes::from(body), cancellation, capacity), true, ); } @@ -377,8 +385,23 @@ async fn handle_proxy( capacity, .. } = context; + let body = streaming_proxy_body(Box::pin(upstream.bytes_stream()), cancellation, capacity); + build_proxy_response( + status, + &upstream_headers, + &request.response_headers, + body, + false, + ) +} + +fn streaming_proxy_body( + stream: UpstreamByteStream, + cancellation: CancellationToken, + capacity: OwnedSemaphorePermit, +) -> Body { let stream = ProxyBodyState { - stream: Box::pin(upstream.bytes_stream()), + stream, cancellation, _capacity: capacity, terminal: false, @@ -419,13 +442,7 @@ async fn handle_proxy( }; next.map(|item| (item, state)) }); - build_proxy_response( - status, - &upstream_headers, - &request.response_headers, - Body::from_stream(stream), - false, - ) + Body::from_stream(stream) } type UpstreamByteStream = @@ -438,6 +455,54 @@ struct ProxyBodyState { terminal: bool, } +struct BufferedProxyBodyState { + bytes: Bytes, + offset: usize, + cancellation: CancellationToken, + _capacity: OwnedSemaphorePermit, + terminal: bool, +} + +fn buffered_proxy_body( + bytes: Bytes, + cancellation: CancellationToken, + capacity: OwnedSemaphorePermit, +) -> Body { + const CHUNK_SIZE: usize = 64 * 1024; + let stream = futures_util::stream::unfold( + BufferedProxyBodyState { + bytes, + offset: 0, + cancellation, + _capacity: capacity, + terminal: false, + }, + |mut state| async move { + if state.terminal || state.offset == state.bytes.len() { + return None; + } + if state.cancellation.is_cancelled() { + state.terminal = true; + return Some(( + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + "proxy policy changed", + )), + state, + )); + } + let end = state + .offset + .saturating_add(CHUNK_SIZE) + .min(state.bytes.len()); + let chunk = state.bytes.slice(state.offset..end); + state.offset = end; + Some((Ok::<_, std::io::Error>(chunk), state)) + }, + ); + Body::from_stream(stream) +} + const MAX_PLAYLIST_INPUT: usize = 8 * 1024 * 1024; async fn collect_playlist( @@ -608,18 +673,17 @@ fn rewrite_playlist_tag(line: &str, base_url: &Url, output: &mut String) -> Resu fn push_proxy_uri(output: &mut String, absolute: &Url) -> Result<(), ProxyError> { const PREFIX: &str = "/proxy/?d="; - let maximum_encoded = absolute - .as_str() + let encoded = urlencoding::encode(absolute.as_str()); + let required = encoded .len() - .checked_mul(3) - .and_then(|length| length.checked_add(PREFIX.len())) + .checked_add(PREFIX.len()) .ok_or(ProxyError::Upstream)?; let remaining = MAX_PLAYLIST_OUTPUT.saturating_sub(output.len()); - if maximum_encoded > remaining { + if required > remaining { return Err(ProxyError::Upstream); } push_playlist(output, PREFIX)?; - push_playlist(output, &urlencoding::encode(absolute.as_str())) + push_playlist(output, &encoded) } fn push_playlist(output: &mut String, value: &str) -> Result<(), ProxyError> { @@ -636,7 +700,10 @@ fn push_playlist(output: &mut String, value: &str) -> Result<(), ProxyError> { #[cfg(test)] mod tests { - use super::{ProxyError, fetch_with_redirects, parse_proxy_request, rewrite_playlist_bounded}; + use super::{ + MAX_PLAYLIST_INPUT, ProxyError, buffered_proxy_body, collect_playlist, + fetch_with_redirects, parse_proxy_request, rewrite_playlist_bounded, streaming_proxy_body, + }; use crate::network_security::{ Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, ProxyRuntime, @@ -644,8 +711,11 @@ mod tests { use async_trait::async_trait; use axum::{ Router, + body::Body, + extract::Path, http::{HeaderMap, StatusCode, header}, - routing::get, + response::{IntoResponse, Response}, + routing::{any, get}, }; use std::{ io, @@ -654,7 +724,7 @@ mod tests { Arc, atomic::{AtomicUsize, Ordering}, }, - time::Instant, + time::{Duration, Instant}, }; #[test] @@ -881,6 +951,311 @@ mod tests { fixture.abort(); } + #[tokio::test] + async fn oversized_redirect_target_is_rejected_before_resolution() { + let location = format!("https://example.com/{}", "a".repeat(16 * 1024)); + let (address, fixture) = fixture(Router::new().route( + "/redirect", + get(move || { + let location = location.clone(); + async move { + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, location)], + ) + } + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let parsed = parse_proxy_request( + "", + Some(&format!( + "d=http%3A%2F%2Fredirect.test%3A{}%2Fredirect", + address.port() + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + assert!(matches!( + fetch_with_redirects( + &runtime, + &context, + &parsed, + reqwest::Method::GET, + &HeaderMap::new(), + ) + .await, + Err(ProxyError::Upstream) + )); + fixture.abort(); + } + + #[tokio::test] + async fn redirect_limit_allows_five_hops_and_rejects_a_sixth() { + async fn chain(Path((kind, hop)): Path<(String, usize)>) -> Response { + if kind == "five" && hop == 5 { + return "done".into_response(); + } + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, format!("/{kind}/{}", hop + 1))], + ) + .into_response() + } + + let (address, fixture) = fixture(Router::new().route("/{kind}/{hop}", any(chain))).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + + let parsed = parse_proxy_request( + "", + Some(&format!( + "d=http%3A%2F%2Fredirect.test%3A{}%2Ffive%2F0", + address.port() + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + let (response, _) = fetch_with_redirects( + &runtime, + &context, + &parsed, + reqwest::Method::GET, + &HeaderMap::new(), + ) + .await + .unwrap(); + assert_eq!(response.text().await.unwrap(), "done"); + + let parsed = parse_proxy_request( + "", + Some(&format!( + "d=http%3A%2F%2Fredirect.test%3A{}%2Fsix%2F0", + address.port() + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + assert!(matches!( + fetch_with_redirects( + &runtime, + &context, + &parsed, + reqwest::Method::GET, + &HeaderMap::new(), + ) + .await, + Err(ProxyError::Upstream) + )); + fixture.abort(); + } + + #[tokio::test] + async fn redirects_preserve_method_and_strip_cross_authority_secrets() { + let (seen_tx, seen_rx) = tokio::sync::oneshot::channel(); + let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx))); + let final_handler = { + let seen_tx = seen_tx.clone(); + move |method: reqwest::Method, headers: HeaderMap| { + let seen_tx = seen_tx.clone(); + async move { + if let Some(sender) = seen_tx.lock().unwrap().take() { + let _ = sender.send((method, headers)); + } + "done" + } + } + }; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let redirect_location = format!("http://other.test:{}/final", address.port()); + let router = Router::new() + .route( + "/redirect", + any(move || { + let redirect_location = redirect_location.clone(); + async move { + ( + StatusCode::SEE_OTHER, + [(header::LOCATION, redirect_location)], + ) + } + }), + ) + .route("/final", any(final_handler)); + let fixture = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let parsed = parse_proxy_request( + "", + Some(&format!( + concat!( + "d=http%3A%2F%2Fuser%3Asecret%40redirect.test%3A{}%2Fredirect", + "&h=Authorization%3ABearer%20secret", + "&h=Cookie%3Asession%3Dsecret" + ), + address.port() + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + let (response, _) = fetch_with_redirects( + &runtime, + &context, + &parsed, + reqwest::Method::POST, + &HeaderMap::new(), + ) + .await + .unwrap(); + assert_eq!(response.text().await.unwrap(), "done"); + let (method, headers) = tokio::time::timeout(Duration::from_secs(2), seen_rx) + .await + .unwrap() + .unwrap(); + assert_eq!(method, reqwest::Method::POST); + assert!(!headers.contains_key(header::AUTHORIZATION)); + assert!(!headers.contains_key(header::COOKIE)); + fixture.abort(); + } + + #[tokio::test] + async fn buffered_playlist_body_retains_capacity_and_observes_cancellation() { + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + let permit = semaphore.clone().try_acquire_owned().unwrap(); + let cancellation = tokio_util::sync::CancellationToken::new(); + let body = buffered_proxy_body( + bytes::Bytes::from_static(b"#EXTM3U\nsegment.ts\n"), + cancellation.clone(), + permit, + ); + assert!(semaphore.clone().try_acquire_owned().is_err()); + cancellation.cancel(); + assert!(axum::body::to_bytes(body, usize::MAX).await.is_err()); + assert!(semaphore.try_acquire_owned().is_ok()); + } + + #[tokio::test(start_paused = true)] + async fn streaming_body_times_out_once_and_releases_capacity() { + let stream = futures_util::stream::pending::>(); + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + let permit = semaphore.clone().try_acquire_owned().unwrap(); + let body = streaming_proxy_body( + Box::pin(stream), + tokio_util::sync::CancellationToken::new(), + permit, + ); + let collect = tokio::spawn(axum::body::to_bytes(body, usize::MAX)); + tokio::task::yield_now().await; + assert!(semaphore.clone().try_acquire_owned().is_err()); + tokio::time::advance(Duration::from_secs(31)).await; + assert!(collect.await.unwrap().is_err()); + assert!(semaphore.try_acquire_owned().is_ok()); + } + + #[tokio::test] + async fn dropping_streaming_body_releases_capacity() { + let stream = futures_util::stream::pending::>(); + let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); + let permit = semaphore.clone().try_acquire_owned().unwrap(); + let body = streaming_proxy_body( + Box::pin(stream), + tokio_util::sync::CancellationToken::new(), + permit, + ); + assert!(semaphore.clone().try_acquire_owned().is_err()); + drop(body); + assert!(semaphore.try_acquire_owned().is_ok()); + } + + #[tokio::test] + async fn playlist_collection_accepts_exact_limit_and_rejects_streamed_overflow() { + let exact = bytes::Bytes::from(vec![b'a'; MAX_PLAYLIST_INPUT]); + let overflow_tail = bytes::Bytes::from_static(b"x"); + let (address, fixture) = fixture( + Router::new() + .route( + "/exact", + get({ + let exact = exact.clone(); + move || { + let exact = exact.clone(); + async move { + Body::from_stream(futures_util::stream::once(async move { + Ok::<_, std::io::Error>(exact) + })) + } + } + }), + ) + .route( + "/overflow", + get(move || { + let exact = exact.clone(); + let overflow_tail = overflow_tail.clone(); + async move { + Body::from_stream(futures_util::stream::iter([ + Ok::<_, std::io::Error>(exact), + Ok::<_, std::io::Error>(overflow_tail), + ])) + } + }), + ), + ) + .await; + let client = reqwest::Client::new(); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + + let context = runtime.try_request().unwrap(); + let response = client + .get(format!("http://{address}/exact")) + .send() + .await + .unwrap(); + assert_eq!( + collect_playlist(response, &context).await.unwrap().len(), + MAX_PLAYLIST_INPUT + ); + drop(context); + + let context = runtime.try_request().unwrap(); + let response = client + .get(format!("http://{address}/overflow")) + .send() + .await + .unwrap(); + assert!(matches!( + collect_playlist(response, &context).await, + Err(ProxyError::Upstream) + )); + fixture.abort(); + } + #[test] fn playlist_rewriter_handles_plain_and_every_quoted_uri() { let base = url::Url::parse("https://media.example/path/master.m3u8").unwrap(); diff --git a/server/src/routes/system.rs b/server/src/routes/system.rs index c3e7536..105eb77 100644 --- a/server/src/routes/system.rs +++ b/server/src/routes/system.rs @@ -788,7 +788,6 @@ pub async fn update_settings( *published = settings; state.proxy_runtime.finish_reconfigure(proxy_policy); drop(published); - drop(_persistence); // Apply updated torrent session settings dynamically. state diff --git a/server/src/settings_control.rs b/server/src/settings_control.rs index 3682605..d2c89be 100644 --- a/server/src/settings_control.rs +++ b/server/src/settings_control.rs @@ -1,7 +1,7 @@ use anyhow::{Context, bail}; use axum::http::HeaderMap; use std::{ - fs::{self, OpenOptions}, + fs, io::{self, Write}, net::SocketAddr, path::Path, @@ -97,17 +97,23 @@ fn create_token_file(path: &Path) -> io::Result<[u8; TOKEN_LENGTH]> { .try_into() .expect("two simple UUIDs are exactly 64 bytes"); - let mut options = OpenOptions::new(); - options.write(true).create_new(true); + let parent = path + .parent() + .ok_or_else(|| io::Error::other("settings control token path has no parent"))?; + let mut file = tempfile::Builder::new() + .prefix(".settings-control-token-") + .tempfile_in(parent)?; #[cfg(unix)] { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); + use std::os::unix::fs::PermissionsExt; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; } - let mut file = options.open(path)?; file.write_all(&token)?; - file.sync_all()?; - Ok(token) + file.as_file().sync_all()?; + file.persist_noclobber(path) + .map(|_| token) + .map_err(|error| error.error) } fn load_token_file(path: &Path) -> anyhow::Result<[u8; TOKEN_LENGTH]> { @@ -258,6 +264,35 @@ mod tests { ); } + #[test] + fn concurrent_token_creation_observes_only_a_complete_winner() { + let temp = tempfile::tempdir().unwrap(); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(16)); + let mut workers = Vec::new(); + for _ in 0..16 { + let path = temp.path().to_owned(); + let barrier = barrier.clone(); + workers.push(std::thread::spawn(move || { + barrier.wait(); + SettingsControl::load_or_create(&path).unwrap() + })); + } + let controls: Vec<_> = workers + .into_iter() + .map(|worker| worker.join().unwrap()) + .collect(); + let bytes = fs::read(temp.path().join("settings-control.token")).unwrap(); + assert_eq!(bytes.len(), 64); + let headers = headers_with(&bytes); + let peer = "127.0.0.1:40000".parse().unwrap(); + for control in controls { + assert_eq!( + control.authorize_http(peer, &headers), + SettingsMutationAuthority::HttpAuthorized + ); + } + } + #[test] fn existing_non_regular_or_invalid_token_is_never_replaced() { let temp = tempfile::tempdir().unwrap(); diff --git a/server/tests/proxy_security.rs b/server/tests/proxy_security.rs index 889a713..39f8bfe 100644 --- a/server/tests/proxy_security.rs +++ b/server/tests/proxy_security.rs @@ -84,7 +84,17 @@ async fn start_tls_fixture() -> anyhow::Result<( let listener = std::net::TcpListener::bind("127.0.0.1:0")?; listener.set_nonblocking(true)?; let address = listener.local_addr()?; - let app = Router::new().route("/ok", get(|| async { "secure-fixture-ok" })); + let app = Router::new() + .route("/ok", get(|| async { "secure-fixture-ok" })) + .route( + "/redirect-http", + get(|| async { + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, "http://example.com/")], + ) + }), + ); let task = tokio::spawn(async move { axum_server::from_tcp_rustls(listener, tls)? .serve(app.into_make_service()) @@ -159,6 +169,7 @@ async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> any .send() .await?; assert_eq!(allowed.status(), StatusCode::OK); + assert_eq!(allowed.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*"); assert_eq!(allowed.text().await?, "fixture-ok"); let range_target = format!("http://{fixture_addr}/range"); @@ -169,6 +180,7 @@ async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> any .await?; assert_eq!(range.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(range.headers()[header::CONTENT_RANGE], "bytes 2-5/10"); + assert_eq!(range.headers()[header::CONTENT_LENGTH], "4"); assert_eq!(range.bytes().await?, &b"2345"[..]); let playlist_target = format!("http://{fixture_addr}/playlist"); @@ -177,6 +189,7 @@ async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> any .send() .await?; assert_eq!(playlist.status(), StatusCode::OK); + assert!(playlist.headers().get(header::CONTENT_LENGTH).is_none()); assert!(playlist.headers().get(header::CONTENT_RANGE).is_none()); assert!(playlist.headers().get(header::CONTENT_ENCODING).is_none()); assert!(playlist.text().await?.contains("/proxy/?d=")); @@ -229,6 +242,19 @@ async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> any .await??; assert_eq!(allowed_tls.status(), StatusCode::OK); assert_eq!(allowed_tls.text().await?, "secure-fixture-ok"); + let downgrade_target = format!( + "https://127.0.0.1:{}/redirect-http", + tls_fixture_addr.port() + ); + let downgrade = tokio::time::timeout( + Duration::from_secs(5), + client + .get(proxy_url(server.http_addr(), &downgrade_target)) + .send(), + ) + .await??; + assert_eq!(downgrade.status(), StatusCode::BAD_GATEWAY); + assert_eq!(downgrade.text().await?, "Proxy upstream request failed"); let stall_target = format!("http://{fixture_addr}/stall"); let stalled = client diff --git a/settings-gui/src/lib.rs b/settings-gui/src/lib.rs index 599a0e7..4c89824 100644 --- a/settings-gui/src/lib.rs +++ b/settings-gui/src/lib.rs @@ -164,7 +164,7 @@ impl HttpConnector { } } - pub fn with_settings_control_token(mut self, token: HeaderValue) -> Result { + pub fn with_settings_control_token(mut self, mut token: HeaderValue) -> Result { let url = reqwest::Url::parse(&self.server_url) .context("server URL is invalid for protected settings")?; if !matches!(url.scheme(), "http" | "https") @@ -183,6 +183,7 @@ impl HttpConnector { .build() .context("failed to create protected settings client")?, ); + token.set_sensitive(true); self.settings_control_token = Some(token); Ok(self) } @@ -200,7 +201,9 @@ pub fn parse_settings_control_token(bytes: &[u8]) -> Result { { anyhow::bail!("settings control token is invalid"); } - HeaderValue::from_bytes(token).context("settings control token is invalid") + let mut value = HeaderValue::from_bytes(token).context("settings control token is invalid")?; + value.set_sensitive(true); + Ok(value) } #[async_trait::async_trait] @@ -227,7 +230,8 @@ impl ServerConnector for HttpConnector { async fn apply_settings(&self, settings: SettingsPayload) -> Result<()> { let value = serde_json::to_value(&settings)?; let url = format!("{}/settings", self.server_url); - if let (Some(client), Some(token)) = (&self.protected_client, &self.settings_control_token) + let response = if let (Some(client), Some(token)) = + (&self.protected_client, &self.settings_control_token) { client .post(url) @@ -235,15 +239,13 @@ impl ServerConnector for HttpConnector { .json(&value) .send() .await? - .error_for_status()?; } else { - self.client - .post(url) - .json(&value) - .send() - .await? - .error_for_status()?; + self.client.post(url).json(&value).send().await? + }; + if response.status().is_redirection() { + anyhow::bail!("settings endpoint returned an unexpected redirect"); } + response.error_for_status()?; Ok(()) } @@ -1699,9 +1701,21 @@ mod tests { #[test] fn token_parser_accepts_one_line_ending_and_rejects_surrounding_whitespace() { let token = "a".repeat(64); - assert!(parse_settings_control_token(token.as_bytes()).is_ok()); - assert!(parse_settings_control_token(format!("{token}\n").as_bytes()).is_ok()); - assert!(parse_settings_control_token(format!("{token}\r\n").as_bytes()).is_ok()); + assert!( + parse_settings_control_token(token.as_bytes()) + .unwrap() + .is_sensitive() + ); + assert!( + parse_settings_control_token(format!("{token}\n").as_bytes()) + .unwrap() + .is_sensitive() + ); + assert!( + parse_settings_control_token(format!("{token}\r\n").as_bytes()) + .unwrap() + .is_sensitive() + ); assert!(parse_settings_control_token(format!(" {token}").as_bytes()).is_err()); assert!(parse_settings_control_token(format!("{token} \n").as_bytes()).is_err()); assert!(parse_settings_control_token(b"abc").is_err()); @@ -1773,7 +1787,7 @@ mod tests { .with_settings_control_token(test_token()) .unwrap(); - connector.apply_settings(empty_settings()).await.unwrap(); + assert!(connector.apply_settings(empty_settings()).await.is_err()); task.join().unwrap(); assert!(matches!( redirect_target.accept(), From 7d96b6e577779a7d90f15af0f223b09610d9933a Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:42:50 -0400 Subject: [PATCH 09/25] security: enforce raw proxy input limit --- server/src/routes/proxy.rs | 49 +++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index bb7669b..0016697 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -5,7 +5,7 @@ use crate::{ use axum::{ Router, body::Body, - extract::{Path, RawQuery, State}, + extract::{OriginalUri, Path, RawQuery, State}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, response::{IntoResponse, Response}, routing::any, @@ -52,12 +52,20 @@ struct ParsedProxyRequest { response_headers: HeaderMap, } +#[cfg(test)] fn parse_proxy_request( rest: &str, raw_query: Option<&str>, ) -> Result { - let input_length = rest - .len() + parse_proxy_request_with_raw_length(rest, raw_query, rest.len()) +} + +fn parse_proxy_request_with_raw_length( + rest: &str, + raw_query: Option<&str>, + raw_rest_length: usize, +) -> Result { + let input_length = raw_rest_length .checked_add(raw_query.map_or(0, str::len)) .ok_or(ProxyError::InvalidRequest)?; if input_length > MAX_PROXY_INPUT { @@ -293,22 +301,28 @@ async fn proxy_root_handler( headers: HeaderMap, method: Method, ) -> Response { - handle_proxy(state, String::new(), raw_query, headers, method).await + handle_proxy(state, String::new(), 0, raw_query, headers, method).await } async fn proxy_path_handler( State(state): State, Path(rest): Path, + OriginalUri(original_uri): OriginalUri, RawQuery(raw_query): RawQuery, headers: HeaderMap, method: Method, ) -> Response { - handle_proxy(state, rest, raw_query, headers, method).await + let raw_rest_length = original_uri + .path() + .strip_prefix("/proxy/") + .map_or_else(|| original_uri.path().len(), str::len); + handle_proxy(state, rest, raw_rest_length, raw_query, headers, method).await } async fn handle_proxy( state: AppState, rest: String, + raw_rest_length: usize, raw_query: Option, headers: HeaderMap, method: Method, @@ -317,10 +331,11 @@ async fn handle_proxy( Ok(context) => context, Err(_) => return proxy_error_response(ProxyError::Capacity), }; - let request = match parse_proxy_request(&rest, raw_query.as_deref()) { - Ok(request) => request, - Err(error) => return proxy_error_response(error), - }; + let request = + match parse_proxy_request_with_raw_length(&rest, raw_query.as_deref(), raw_rest_length) { + Ok(request) => request, + Err(error) => return proxy_error_response(error), + }; let (upstream, final_url) = match fetch_with_redirects( &state.proxy_runtime, &context, @@ -701,8 +716,9 @@ fn push_playlist(output: &mut String, value: &str) -> Result<(), ProxyError> { #[cfg(test)] mod tests { use super::{ - MAX_PLAYLIST_INPUT, ProxyError, buffered_proxy_body, collect_playlist, - fetch_with_redirects, parse_proxy_request, rewrite_playlist_bounded, streaming_proxy_body, + MAX_PLAYLIST_INPUT, MAX_PROXY_INPUT, ProxyError, buffered_proxy_body, collect_playlist, + fetch_with_redirects, parse_proxy_request, parse_proxy_request_with_raw_length, + rewrite_playlist_bounded, streaming_proxy_body, }; use crate::network_security::{ Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, @@ -812,6 +828,17 @@ mod tests { parse_proxy_request("", Some(&query)), Err(ProxyError::InvalidRequest) )); + + // Axum percent-decodes wildcard path captures. The raw URI length must + // remain authoritative so encoded input cannot shrink under the cap. + assert!(matches!( + parse_proxy_request_with_raw_length( + "d=https%3A%2F%2Fexample.com", + None, + MAX_PROXY_INPUT + 1, + ), + Err(ProxyError::InvalidRequest) + )); } struct FixtureResolver { From 22897cd3a43fac88ae56469829f52383501b1a87 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:45:07 -0400 Subject: [PATCH 10/25] security: preserve obsolete IPv6 wrapper denials --- server/src/network_security/ip.rs | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/server/src/network_security/ip.rs b/server/src/network_security/ip.rs index 32dd9ad..e2d28d9 100644 --- a/server/src/network_security/ip.rs +++ b/server/src/network_security/ip.rs @@ -210,7 +210,7 @@ pub(crate) fn normalized_embedded_ipv4(ip: Ipv6Addr, nat64: &[Nat64Prefix]) -> O } fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> DestinationClass { - if let Some(embedded) = normalized_embedded_ipv4(ip, nat64) { + if let Some(embedded) = ip.to_ipv4_mapped() { return classify_v4(embedded, local); } if extract_ipv4_compatible(ip).is_some() @@ -220,6 +220,9 @@ fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> De { return DestinationClass::AlwaysBlocked; } + if let Some(embedded) = embedded_nat64(ip, nat64) { + return classify_v4(embedded, local); + } let ip = IpAddr::V6(ip); if contains(&METADATA_V6_NETS, ip) || contains(&ALWAYS_BLOCKED_V6_NETS, ip) { @@ -401,6 +404,36 @@ mod tests { } } + #[test] + fn discovered_nat64_prefix_cannot_override_an_obsolete_wrapper() { + let local = LocalNetworks::default(); + let translated_prefix = [Nat64Prefix { + network: "::ffff:0:0:0".parse().unwrap(), + length: 96, + }]; + assert_eq!( + classify_ip( + "::ffff:0:93.184.216.34".parse().unwrap(), + &local, + &translated_prefix, + ), + DestinationClass::AlwaysBlocked + ); + + let compatible_prefix = [Nat64Prefix { + network: "::".parse().unwrap(), + length: 96, + }]; + assert_eq!( + classify_ip( + "::93.184.216.34".parse().unwrap(), + &local, + &compatible_prefix, + ), + DestinationClass::AlwaysBlocked + ); + } + #[test] fn mapped_and_nat64_public_ipv4_remain_public() { let local = LocalNetworks::default(); From 9c26067b479b614be3ea6b8349d64fbe35a6125a Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:48:05 -0400 Subject: [PATCH 11/25] security: preserve native IPv6 deny precedence --- server/src/network_security/ip.rs | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/server/src/network_security/ip.rs b/server/src/network_security/ip.rs index e2d28d9..d6bc38e 100644 --- a/server/src/network_security/ip.rs +++ b/server/src/network_security/ip.rs @@ -220,14 +220,15 @@ fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> De { return DestinationClass::AlwaysBlocked; } + let native = IpAddr::V6(ip); + if contains(&METADATA_V6_NETS, native) || contains(&ALWAYS_BLOCKED_V6_NETS, native) { + return DestinationClass::AlwaysBlocked; + } if let Some(embedded) = embedded_nat64(ip, nat64) { return classify_v4(embedded, local); } - let ip = IpAddr::V6(ip); - if contains(&METADATA_V6_NETS, ip) || contains(&ALWAYS_BLOCKED_V6_NETS, ip) { - DestinationClass::AlwaysBlocked - } else if local.contains(ip) || contains(&PRIVATE_V6_NETS, ip) { + if local.contains(native) || contains(&PRIVATE_V6_NETS, native) { DestinationClass::PrivateSource } else { DestinationClass::Public @@ -434,11 +435,24 @@ mod tests { ); } + #[test] + fn discovered_nat64_prefix_cannot_override_native_always_blocked_space() { + let local = LocalNetworks::default(); + let discovered = [Nat64Prefix { + network: "2001:db8::".parse().unwrap(), + length: 96, + }]; + assert_eq!( + classify_ip("2001:db8::5db8:d822".parse().unwrap(), &local, &discovered,), + DestinationClass::AlwaysBlocked + ); + } + #[test] fn mapped_and_nat64_public_ipv4_remain_public() { let local = LocalNetworks::default(); let discovered = [Nat64Prefix { - network: "2001:db8:64::".parse().unwrap(), + network: "2001:4860:64::".parse().unwrap(), length: 96, }]; for (value, prefixes) in [ @@ -446,7 +460,7 @@ mod tests { ("64:ff9b::5db8:d822", &[][..]), ("64:ff9b:1::5db8:d822", &[][..]), ("64:ff9b:1:5db8:d8:2200::", &[][..]), - ("2001:db8:64::5db8:d822", &discovered[..]), + ("2001:4860:64::5db8:d822", &discovered[..]), ] { assert_eq!( classify_ip(value.parse().unwrap(), &local, prefixes), From ab136d372415dec70309f0e1befd194848096f27 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:12:00 -0400 Subject: [PATCH 12/25] security: close proxy audit validation gaps --- server/src/network_security/ip.rs | 63 ++++++- server/src/network_security/resolver.rs | 216 +++++++++++++++++++++--- server/src/routes/proxy.rs | 52 +++++- server/src/settings_control.rs | 18 +- server/tests/proxy_security.rs | 25 +++ settings-gui/src/lib.rs | 45 +++-- settings-gui/src/main.rs | 9 +- 7 files changed, 373 insertions(+), 55 deletions(-) diff --git a/server/src/network_security/ip.rs b/server/src/network_security/ip.rs index d6bc38e..f9e57b2 100644 --- a/server/src/network_security/ip.rs +++ b/server/src/network_security/ip.rs @@ -11,7 +11,7 @@ pub(crate) enum DestinationClass { AlwaysBlocked, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct LocalNetworks { pub(crate) interfaces: Vec, } @@ -20,6 +20,10 @@ impl LocalNetworks { pub(crate) fn contains(&self, ip: IpAddr) -> bool { self.interfaces.iter().any(|network| network.contains(&ip)) } + + pub(crate) fn contains_address(&self, ip: IpAddr) -> bool { + self.interfaces.iter().any(|network| network.addr() == ip) + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -57,6 +61,9 @@ const ALWAYS_BLOCKED_V4: &[&str] = &[ const METADATA_V4: &[&str] = &[ "168.63.129.16/32", + "169.254.0.23/32", + "169.254.10.10/32", + "169.254.42.42/32", "169.254.169.254/32", "169.254.170.2/32", "169.254.170.23/32", @@ -73,12 +80,18 @@ const ALWAYS_BLOCKED_V6: &[&str] = &[ "2001::/23", "2001:db8::/32", "2620:4f:8000::/48", + "3ffe::/16", "3fff::/20", "5f00::/16", "ff00::/8", ]; -const METADATA_V6: &[&str] = &["fd00:ec2::23/128", "fd00:ec2::254/128", "fd20:ce::254/128"]; +const METADATA_V6: &[&str] = &[ + "fd00:42::42/128", + "fd00:ec2::23/128", + "fd00:ec2::254/128", + "fd20:ce::254/128", +]; static PRIVATE_V4_NETS: LazyLock> = LazyLock::new(|| parse_networks(PRIVATE_V4)); static ALWAYS_BLOCKED_V4_NETS: LazyLock> = @@ -88,6 +101,8 @@ static PRIVATE_V6_NETS: LazyLock> = LazyLock::new(|| parse_networks(P static ALWAYS_BLOCKED_V6_NETS: LazyLock> = LazyLock::new(|| parse_networks(ALWAYS_BLOCKED_V6)); static METADATA_V6_NETS: LazyLock> = LazyLock::new(|| parse_networks(METADATA_V6)); +static GLOBAL_UNICAST_V6_NET: LazyLock = + LazyLock::new(|| "2000::/3".parse().expect("global IPv6 network is valid")); fn parse_networks(values: &[&str]) -> Vec { values @@ -201,10 +216,16 @@ fn embedded_nat64(ip: Ipv6Addr, prefixes: &[Nat64Prefix]) -> Option { .or_else(|| { prefixes .iter() + .filter(|prefix| nat64_prefix_address_space_is_valid(**prefix)) .find_map(|prefix| extract_rfc6052(ip, *prefix)) }) } +fn nat64_prefix_address_space_is_valid(prefix: Nat64Prefix) -> bool { + let ip = IpAddr::V6(prefix.network); + GLOBAL_UNICAST_V6_NET.contains(&ip) || prefix.network.segments()[0] & 0xfe00 == 0xfc00 +} + pub(crate) fn normalized_embedded_ipv4(ip: Ipv6Addr, nat64: &[Nat64Prefix]) -> Option { ip.to_ipv4_mapped().or_else(|| embedded_nat64(ip, nat64)) } @@ -230,8 +251,10 @@ fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> De if local.contains(native) || contains(&PRIVATE_V6_NETS, native) { DestinationClass::PrivateSource - } else { + } else if GLOBAL_UNICAST_V6_NET.contains(&native) { DestinationClass::Public + } else { + DestinationClass::AlwaysBlocked } } @@ -298,6 +321,9 @@ mod tests { let local = LocalNetworks::default(); for value in [ "168.63.129.16", + "169.254.0.23", + "169.254.10.10", + "169.254.42.42", "169.254.169.254", "169.254.170.2", "169.254.170.23", @@ -342,12 +368,27 @@ mod tests { let private = ["::1", "fc00::1", "fdff:ffff::1", "fe80::1", "febf:ffff::1"]; let always_blocked = [ "::", + "200::1", + "400::1", + "800::1", + "1000::1", "100::1", "100:0:0:1::1", "2001:db8::1", "2620:4f:8000::1", + "3ffe::1", "3fff::1", + "4000::1", "5f00::1", + "6000::1", + "8000::1", + "a000::1", + "c000::1", + "e000::1", + "f000::1", + "f800::1", + "fe00::1", + "fec0::1", "ff00::1", ]; @@ -446,6 +487,15 @@ mod tests { classify_ip("2001:db8::5db8:d822".parse().unwrap(), &local, &discovered,), DestinationClass::AlwaysBlocked ); + + let reserved = [Nat64Prefix { + network: "4000::".parse().unwrap(), + length: 96, + }]; + assert_eq!( + classify_ip("4000::5db8:d822".parse().unwrap(), &local, &reserved), + DestinationClass::AlwaysBlocked + ); } #[test] @@ -473,7 +523,12 @@ mod tests { #[test] fn ipv6_metadata_precedes_private_source_ranges() { let local = LocalNetworks::default(); - for value in ["fd00:ec2::23", "fd00:ec2::254", "fd20:ce::254"] { + for value in [ + "fd00:42::42", + "fd00:ec2::23", + "fd00:ec2::254", + "fd20:ce::254", + ] { assert_eq!( classify_ip(value.parse().unwrap(), &local, &[]), DestinationClass::AlwaysBlocked, diff --git a/server/src/network_security/resolver.rs b/server/src/network_security/resolver.rs index 1b7a462..bdc1743 100644 --- a/server/src/network_security/resolver.rs +++ b/server/src/network_security/resolver.rs @@ -61,11 +61,14 @@ impl LocalNetworkProvider for SystemLocalNetworkProvider { async fn current(&self) -> io::Result { tokio::task::spawn_blocking(|| { let interfaces = if_addrs::get_if_addrs()?; + let mut networks: Vec<_> = interfaces + .iter() + .filter_map(network_for_interface) + .collect(); + networks.sort_unstable(); + networks.dedup(); Ok(LocalNetworks { - interfaces: interfaces - .iter() - .filter_map(network_for_interface) - .collect(), + interfaces: networks, }) }) .await @@ -87,9 +90,7 @@ fn network_for_interface(interface: &if_addrs::Interface) -> Option (IpAddr::V4(address.ip), address.prefixlen), if_addrs::IfAddr::V6(address) => (IpAddr::V6(address.ip), address.prefixlen), }; - ipnet::IpNet::new(ip, prefix) - .ok() - .map(|network| network.trunc()) + ipnet::IpNet::new(ip, prefix).ok() } #[derive(Clone, Debug)] @@ -132,6 +133,7 @@ struct CachedNat64Prefixes { expires_at: Instant, prefixes: Vec, failed: bool, + local_networks: LocalNetworks, } impl DestinationValidator { @@ -199,7 +201,7 @@ impl DestinationValidator { } resolved.sort_unstable(); resolved.dedup(); - let nat64 = self.nat64_prefixes_for(&resolved, &local).await?; + let nat64 = self.nat64_prefixes_for(&resolved, &local, policy).await?; self.validate_addresses(&resolved, &local, &nat64, policy)?; return Ok(ResolvedDestination { url: canonical_url, @@ -214,7 +216,7 @@ impl DestinationValidator { .current() .await .map_err(|_| DestinationError::LocalNetworkUnavailable)?; - let nat64 = self.nat64_prefixes_for(&addresses, &local).await?; + let nat64 = self.nat64_prefixes_for(&addresses, &local, policy).await?; self.validate_addresses(&addresses, &local, &nat64, policy)?; Ok(ResolvedDestination { url: canonical_url, @@ -261,15 +263,15 @@ impl DestinationValidator { if listener.address.is_unspecified() { return match (listener.address, target_ip) { (IpAddr::V4(_), IpAddr::V4(ip)) => { - ip.is_loopback() || local.contains(IpAddr::V4(ip)) + ip.is_loopback() || local.contains_address(IpAddr::V4(ip)) } (IpAddr::V6(_), IpAddr::V6(ip)) => { - ip.is_loopback() || local.contains(IpAddr::V6(ip)) + ip.is_loopback() || local.contains_address(IpAddr::V6(ip)) } // An unspecified IPv6 socket can be dual-stack. Treat mapped // IPv4 addresses on local interfaces as self-listener targets. (IpAddr::V6(_), IpAddr::V4(ip)) => { - ip.is_loopback() || local.contains(IpAddr::V4(ip)) + ip.is_loopback() || local.contains_address(IpAddr::V4(ip)) } _ => false, }; @@ -283,25 +285,35 @@ impl DestinationValidator { &self, addresses: &[SocketAddr], local: &LocalNetworks, + policy: OutboundPolicy, ) -> Result, DestinationError> { let needs_discovery = addresses.iter().any(|address| match address.ip() { IpAddr::V4(_) => false, IpAddr::V6(ip) => { - super::ip::normalized_embedded_ipv4(ip, &[]).is_none() - && super::ip::classify_ip(IpAddr::V6(ip), local, &[]) - == DestinationClass::Public + if super::ip::normalized_embedded_ipv4(ip, &[]).is_some() { + return false; + } + match super::ip::classify_ip(IpAddr::V6(ip), local, &[]) { + DestinationClass::Public => true, + DestinationClass::PrivateSource => { + policy.allow_private_network_sources + && !ip.is_loopback() + && !ip.is_unicast_link_local() + } + DestinationClass::AlwaysBlocked => false, + } } }); if !needs_discovery { return Ok(Vec::new()); } - if let Some(prefixes) = self.cached_nat64_prefixes().await { + if let Some(prefixes) = self.cached_nat64_prefixes(local).await { return prefixes; } let _refresh = self.nat64_refresh.lock().await; - if let Some(prefixes) = self.cached_nat64_prefixes().await { + if let Some(prefixes) = self.cached_nat64_prefixes(local).await { return prefixes; } @@ -327,15 +339,21 @@ impl DestinationValidator { expires_at, prefixes: discovered.as_ref().cloned().unwrap_or_default(), failed: discovered.is_err(), + local_networks: local.clone(), }); discovered } - async fn cached_nat64_prefixes(&self) -> Option, DestinationError>> { + async fn cached_nat64_prefixes( + &self, + local: &LocalNetworks, + ) -> Option, DestinationError>> { let cache = self.nat64_cache.lock().await; cache .as_ref() - .filter(|cached| self.clock.now() < cached.expires_at) + .filter(|cached| { + self.clock.now() < cached.expires_at && cached.local_networks == *local + }) .map(|cached| { if cached.failed { Err(DestinationError::ResolutionFailed) @@ -491,6 +509,40 @@ mod tests { } } + struct MutableResolver { + answer: Mutex>, + calls: AtomicUsize, + } + + impl MutableResolver { + fn replace(&self, answer: Vec) { + *self.answer.lock().unwrap() = answer; + } + } + + #[async_trait] + impl DnsResolver for MutableResolver { + async fn resolve(&self, _host: &str, _port: u16) -> io::Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self.answer.lock().unwrap().clone()) + } + } + + struct MutableLocalNetworks(Mutex); + + impl MutableLocalNetworks { + fn replace(&self, networks: LocalNetworks) { + *self.0.lock().unwrap() = networks; + } + } + + #[async_trait] + impl LocalNetworkProvider for MutableLocalNetworks { + async fn current(&self) -> io::Result { + Ok(self.0.lock().unwrap().clone()) + } + } + struct FixedClock(Instant); impl Clock for FixedClock { @@ -692,7 +744,7 @@ mod tests { ); let wildcard = validator_with_listeners( - FakeResolver::new(vec!["8.8.8.10:11470".parse().unwrap()]), + FakeResolver::new(vec!["8.8.8.8:11470".parse().unwrap()]), local, vec![ListenerBinding { address: "0.0.0.0".parse().unwrap(), @@ -711,10 +763,52 @@ mod tests { ); } + #[tokio::test] + async fn wildcard_listener_blocks_only_this_hosts_interface_address() { + let local = LocalNetworks { + interfaces: vec!["8.8.8.9/29".parse().unwrap()], + }; + let listeners = vec![ListenerBinding { + address: "0.0.0.0".parse().unwrap(), + port: 11470, + }]; + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + + let own_address = validator_with_listeners( + FakeResolver::new(vec!["8.8.8.9:11470".parse().unwrap()]), + local.clone(), + listeners.clone(), + ); + assert_eq!( + own_address + .validate(&Url::parse("http://own.example:11470/").unwrap(), policy) + .await + .unwrap_err(), + DestinationError::Blocked + ); + + let neighbor = validator_with_listeners( + FakeResolver::new(vec!["8.8.8.10:11470".parse().unwrap()]), + local, + listeners, + ); + assert!( + neighbor + .validate( + &Url::parse("http://neighbor.example:11470/").unwrap(), + policy, + ) + .await + .is_ok() + ); + } + #[tokio::test] async fn ipv6_wildcard_listener_blocks_ipv4_mapped_local_interfaces() { let validator = validator_with_listeners( - FakeResolver::new(vec!["[::ffff:8.8.8.10]:11470".parse().unwrap()]), + FakeResolver::new(vec!["[::ffff:8.8.8.8]:11470".parse().unwrap()]), LocalNetworks { interfaces: vec!["8.8.8.8/29".parse().unwrap()], }, @@ -804,6 +898,30 @@ mod tests { assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn discovered_ula_nat64_prefix_exposes_embedded_metadata() { + let resolver = FakeResolver::new(vec![ + "[fd12:3456:789a::c000:aa]:0".parse().unwrap(), + "[fd12:3456:789a::c000:ab]:0".parse().unwrap(), + ]); + let validator = validator(resolver.clone()); + let target = Url::parse("http://[fd12:3456:789a::a9fe:a9fe]/").unwrap(); + + assert_eq!( + validator + .validate( + &target, + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn nat64_discovery_failure_rejects_unclassifiable_public_ipv6() { let validator = validator(FakeResolver::failing()); @@ -863,6 +981,60 @@ mod tests { assert_eq!(resolver.calls.load(Ordering::SeqCst), 2); } + #[tokio::test] + async fn nat64_cache_is_not_reused_after_the_local_network_changes() { + let resolver = Arc::new(MutableResolver { + answer: Mutex::new(vec![ + "[2001:4860:64::c000:aa]:0".parse().unwrap(), + "[2001:4860:64::c000:ab]:0".parse().unwrap(), + ]), + calls: AtomicUsize::new(0), + }); + let local = Arc::new(MutableLocalNetworks(Mutex::new(LocalNetworks { + interfaces: vec!["192.168.1.0/24".parse().unwrap()], + }))); + let validator = DestinationValidator::new( + resolver.clone(), + local.clone(), + Arc::new(FixedClock(Instant::now())), + Vec::new(), + ); + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + + assert_eq!( + validator + .validate( + &Url::parse("http://[2001:4860:64::a9fe:a9fe]/").unwrap(), + policy, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + + resolver.replace(vec![ + "[2600:1900:64::c000:aa]:0".parse().unwrap(), + "[2600:1900:64::c000:ab]:0".parse().unwrap(), + ]); + local.replace(LocalNetworks { + interfaces: vec!["10.0.0.0/24".parse().unwrap()], + }); + + assert_eq!( + validator + .validate( + &Url::parse("http://[2600:1900:64::a9fe:a9fe]/").unwrap(), + policy, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn concurrent_nat64_cache_misses_share_one_discovery() { let resolver = Arc::new(BlockingResolver { @@ -928,7 +1100,7 @@ mod tests { let up = interface("8.8.8.8".parse().unwrap(), 24, if_addrs::IfOperStatus::Up); assert_eq!( network_for_interface(&up).unwrap().to_string(), - "8.8.8.0/24" + "8.8.8.8/24" ); let down = interface("8.8.4.4".parse().unwrap(), 24, if_addrs::IfOperStatus::Down); diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 0016697..64af829 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -123,13 +123,23 @@ fn parse_proxy_request_with_raw_length( } target.set_fragment(None); if !path_tail.is_empty() { - target = target + let declared = target.clone(); + let joined = target .join(path_tail) .map_err(|_| ProxyError::InvalidRequest)?; + if declared.scheme() != joined.scheme() + || !same_authority(&declared, &joined) + || declared.username() != joined.username() + || declared.password() != joined.password() + { + return Err(ProxyError::InvalidRequest); + } + target = joined; } if let Some(query) = upstream_query { target.set_query(Some(query)); } + target.set_fragment(None); if target.as_str().len() > MAX_TARGET_URL { return Err(ProxyError::InvalidRequest); } @@ -276,16 +286,16 @@ async fn fetch_with_redirects( if !same_authority(&destination.url, &next) { let _ = next.set_username(""); let _ = next.set_password(None); - custom_headers.remove(header::AUTHORIZATION); - custom_headers.remove(header::COOKIE); - custom_headers.remove(header::PROXY_AUTHORIZATION); + custom_headers.clear(); } target = next; } } fn same_authority(left: &Url, right: &Url) -> bool { - left.host() == right.host() && left.port_or_known_default() == right.port_or_known_default() + left.scheme() == right.scheme() + && left.host() == right.host() + && left.port_or_known_default() == right.port_or_known_default() } pub fn router() -> Router { @@ -358,7 +368,7 @@ async fn handle_proxy( || final_url.path().ends_with(".m3u") || content_type.to_ascii_lowercase().contains("mpegurl"); - if playlist { + if playlist && status != StatusCode::PARTIAL_CONTENT { if upstream_headers .get(header::CONTENT_ENCODING) .is_some_and(|value| { @@ -718,7 +728,7 @@ mod tests { use super::{ MAX_PLAYLIST_INPUT, MAX_PROXY_INPUT, ProxyError, buffered_proxy_body, collect_playlist, fetch_with_redirects, parse_proxy_request, parse_proxy_request_with_raw_length, - rewrite_playlist_bounded, streaming_proxy_body, + rewrite_playlist_bounded, same_authority, streaming_proxy_body, }; use crate::network_security::{ Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, @@ -742,6 +752,7 @@ mod tests { }, time::{Duration, Instant}, }; + use url::Url; #[test] fn parse_core_path_format_preserves_tail_query() { @@ -758,6 +769,29 @@ mod tests { assert_eq!(parsed.response_headers[header::CONTENT_TYPE], "video/mp4"); } + #[test] + fn parse_path_tail_cannot_replace_the_declared_authority() { + for tail in ["https://evil.example/steal", "//evil.example/steal"] { + let rest = + format!("d=https%3A%2F%2Ftrusted.example&h=Authorization%3ABearer%20secret/{tail}"); + assert!( + matches!( + parse_proxy_request(&rest, None), + Err(ProxyError::InvalidRequest) + ), + "{tail}" + ); + } + } + + #[test] + fn redirect_origin_requires_the_same_scheme() { + let http = Url::parse("http://example.test:443/source").unwrap(); + let https = Url::parse("https://example.test:443/destination").unwrap(); + + assert!(!same_authority(&http, &https)); + } + #[test] fn parse_query_format_accepts_full_url_and_repeated_options_last_wins() { let parsed = parse_proxy_request( @@ -1138,7 +1172,8 @@ mod tests { concat!( "d=http%3A%2F%2Fuser%3Asecret%40redirect.test%3A{}%2Fredirect", "&h=Authorization%3ABearer%20secret", - "&h=Cookie%3Asession%3Dsecret" + "&h=Cookie%3Asession%3Dsecret", + "&h=X-Api-Key%3Asecret" ), address.port() )), @@ -1162,6 +1197,7 @@ mod tests { assert_eq!(method, reqwest::Method::POST); assert!(!headers.contains_key(header::AUTHORIZATION)); assert!(!headers.contains_key(header::COOKIE)); + assert!(!headers.contains_key("x-api-key")); fixture.abort(); } diff --git a/server/src/settings_control.rs b/server/src/settings_control.rs index d2c89be..12f1349 100644 --- a/server/src/settings_control.rs +++ b/server/src/settings_control.rs @@ -54,7 +54,7 @@ impl SettingsControl { peer: SocketAddr, headers: &HeaderMap, ) -> SettingsMutationAuthority { - if !peer.ip().is_loopback() { + if !is_loopback(peer.ip()) { return SettingsMutationAuthority::Untrusted; } let Some(candidate) = headers.get(SETTINGS_TOKEN_HEADER) else { @@ -90,6 +90,15 @@ impl SettingsControl { } } +fn is_loopback(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(ip) => ip.is_loopback(), + std::net::IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map_or_else(|| ip.is_loopback(), |mapped| mapped.is_loopback()), + } +} + fn create_token_file(path: &Path) -> io::Result<[u8; TOKEN_LENGTH]> { let raw = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let token: [u8; TOKEN_LENGTH] = raw @@ -198,6 +207,13 @@ mod tests { control.authorize_http("[::1]:40000".parse::().unwrap(), &headers), SettingsMutationAuthority::HttpAuthorized ); + assert_eq!( + control.authorize_http( + "[::ffff:127.0.0.1]:40000".parse::().unwrap(), + &headers, + ), + SettingsMutationAuthority::HttpAuthorized + ); } #[test] diff --git a/server/tests/proxy_security.rs b/server/tests/proxy_security.rs index 39f8bfe..196464c 100644 --- a/server/tests/proxy_security.rs +++ b/server/tests/proxy_security.rs @@ -47,6 +47,18 @@ async fn start_fixture() -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) ) }), ) + .route( + "/playlist-partial", + get(|| async { + Response::builder() + .status(StatusCode::PARTIAL_CONTENT) + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .header(header::CONTENT_RANGE, "bytes 0-10/100") + .header(header::CONTENT_LENGTH, "11") + .body(Body::from("segment.ts\n")) + .unwrap() + }), + ) .route( "/redirect-metadata", get(|| async { @@ -194,6 +206,19 @@ async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> any assert!(playlist.headers().get(header::CONTENT_ENCODING).is_none()); assert!(playlist.text().await?.contains("/proxy/?d=")); + let partial_playlist_target = format!("http://{fixture_addr}/playlist-partial"); + let partial_playlist = client + .get(proxy_url(server.http_addr(), &partial_playlist_target)) + .send() + .await?; + assert_eq!(partial_playlist.status(), StatusCode::PARTIAL_CONTENT); + assert_eq!( + partial_playlist.headers()[header::CONTENT_RANGE], + "bytes 0-10/100" + ); + assert_eq!(partial_playlist.headers()[header::CONTENT_LENGTH], "11"); + assert_eq!(partial_playlist.text().await?, "segment.ts\n"); + for self_path in ["/heartbeat", "/settings", "/proxy"] { let self_target = format!("{base}{self_path}"); assert_eq!( diff --git a/settings-gui/src/lib.rs b/settings-gui/src/lib.rs index 4c89824..b6f51c3 100644 --- a/settings-gui/src/lib.rs +++ b/settings-gui/src/lib.rs @@ -165,14 +165,7 @@ impl HttpConnector { } pub fn with_settings_control_token(mut self, mut token: HeaderValue) -> Result { - let url = reqwest::Url::parse(&self.server_url) - .context("server URL is invalid for protected settings")?; - if !matches!(url.scheme(), "http" | "https") - || url - .host_str() - .and_then(|host| host.parse::().ok()) - .is_none_or(|ip| !ip.is_loopback()) - { + if !server_url_has_ip_literal_loopback(&self.server_url) { anyhow::bail!("protected settings require an IP-literal loopback server URL"); } @@ -189,6 +182,28 @@ impl HttpConnector { } } +pub fn server_url_has_ip_literal_loopback(server_url: &str) -> bool { + let Ok(url) = reqwest::Url::parse(server_url) else { + return false; + }; + if !matches!(url.scheme(), "http" | "https") { + return false; + } + let Some(ip) = url + .host_str() + .map(|host| host.trim_matches(['[', ']'])) + .and_then(|host| host.parse::().ok()) + else { + return false; + }; + match ip { + std::net::IpAddr::V4(ip) => ip.is_loopback(), + std::net::IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map_or_else(|| ip.is_loopback(), |mapped| mapped.is_loopback()), + } +} + pub fn parse_settings_control_token(bytes: &[u8]) -> Result { let token = bytes .strip_suffix(b"\r\n") @@ -1737,10 +1752,16 @@ mod tests { ); } - let connector = HttpConnector::new(Client::new(), "http://127.0.0.1:11470".to_string()) - .with_settings_control_token(token) - .unwrap(); - assert!(connector.can_update_protected_settings()); + for server_url in [ + "http://127.0.0.1:11470", + "http://[::1]:11470", + "http://[::ffff:127.0.0.1]:11470", + ] { + let connector = HttpConnector::new(Client::new(), server_url.to_string()) + .with_settings_control_token(token.clone()) + .unwrap(); + assert!(connector.can_update_protected_settings(), "{server_url}"); + } } #[tokio::test] diff --git a/settings-gui/src/main.rs b/settings-gui/src/main.rs index cbfe5bb..ccab521 100644 --- a/settings-gui/src/main.rs +++ b/settings-gui/src/main.rs @@ -10,7 +10,7 @@ fn main() -> Result<()> { .build() .context("failed to create HTTP client")?; let mut connector = settings_gui::HttpConnector::new(http_client, server_url.clone()); - if server_url_has_ip_literal_loopback(&server_url) { + if settings_gui::server_url_has_ip_literal_loopback(&server_url) { let path = token_path.or_else(default_token_path); if let Some(path) = path { match std::fs::read(&path) { @@ -58,13 +58,6 @@ fn trim_server_url(value: String) -> String { value.trim_end_matches('/').to_string() } -fn server_url_has_ip_literal_loopback(server_url: &str) -> bool { - reqwest::Url::parse(server_url) - .ok() - .and_then(|url| url.host_str()?.parse::().ok()) - .is_some_and(|ip| ip.is_loopback()) -} - fn default_token_path() -> Option { dirs::config_dir().map(|dir| dir.join("stremio-server").join("settings-control.token")) } From 9e398121aa842c585123223a3300d3361329c251 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:47:51 -0400 Subject: [PATCH 13/25] security: parse proxy targets from raw request URIs --- server/src/lib.rs | 2 +- server/src/routes/proxy.rs | 445 +++++++++++++++++++++++++-------- server/tests/proxy_security.rs | 39 +++ 3 files changed, 385 insertions(+), 101 deletions(-) diff --git a/server/src/lib.rs b/server/src/lib.rs index 0d3a2f6..a547c61 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -870,7 +870,7 @@ pub fn build_router(state: AppState) -> Router { .nest("/tgz", routes::archive::router()) .nest("/nzb", routes::nzb::router()) .nest("/local-addon", local_addon::get_router()) - .merge(routes::proxy::router()) + .nest_service("/proxy", routes::proxy::service(state.clone())) .nest("/ftp", routes::ftp::router()) .route("/samples/{filename}", get(routes::system::get_samples)) .route("/hlsv2/status", get(routes::hls::hls_status)) diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 64af829..1fad5e1 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -5,8 +5,8 @@ use crate::{ use axum::{ Router, body::Body, - extract::{OriginalUri, Path, RawQuery, State}, - http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}, + extract::{OriginalUri, State}, + http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri, header}, response::{IntoResponse, Response}, routing::any, }; @@ -57,38 +57,60 @@ fn parse_proxy_request( rest: &str, raw_query: Option<&str>, ) -> Result { - parse_proxy_request_with_raw_length(rest, raw_query, rest.len()) + let mut suffix = if rest.is_empty() { + String::new() + } else { + format!("/{rest}") + }; + if let Some(query) = raw_query { + suffix.push('?'); + suffix.push_str(query); + } + parse_proxy_suffix(&suffix) } -fn parse_proxy_request_with_raw_length( - rest: &str, - raw_query: Option<&str>, - raw_rest_length: usize, -) -> Result { - let input_length = raw_rest_length - .checked_add(raw_query.map_or(0, str::len)) - .ok_or(ProxyError::InvalidRequest)?; - if input_length > MAX_PROXY_INPUT { +fn parse_proxy_suffix(raw_suffix: &str) -> Result { + if raw_suffix.len() > MAX_PROXY_INPUT { return Err(ProxyError::InvalidRequest); } - let query_has_target = raw_query.is_some_and(|query| { - url::form_urlencoded::parse(query.as_bytes()).any(|(key, _)| key == "d") - }); - let (encoded_options, path_tail, upstream_query) = if query_has_target { - (raw_query.unwrap_or_default(), "", None) + let (encoded_options, path_tail, upstream_query) = if raw_suffix.is_empty() { + ("", None, None) + } else if let Some(query) = raw_suffix.strip_prefix('?') { + (query, None, None) + } else if raw_suffix == "/" { + ("", None, None) + } else if let Some(query) = raw_suffix.strip_prefix("/?") { + (query, None, None) } else { - let (options, tail) = rest.split_once('/').unwrap_or((rest, "")); - (options, tail, raw_query) + let path_and_query = raw_suffix + .strip_prefix('/') + .ok_or(ProxyError::InvalidRequest)?; + let (raw_path, upstream_query) = match path_and_query.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (path_and_query, None), + }; + let (options, tail) = match raw_path.split_once('/') { + Some((options, tail)) => (options, tail), + None => (raw_path, ""), + }; + (options, Some(tail), upstream_query) }; let mut target = None; let mut request_headers = HeaderMap::new(); let mut response_headers = HeaderMap::new(); let mut option_count = 0usize; - for (key, value) in url::form_urlencoded::parse(encoded_options.as_bytes()) { - match key.as_ref() { - "d" => target = Some(value.into_owned()), + for option in encoded_options.split('&') { + let (key, value) = option.split_once('=').unwrap_or((option, "")); + let key = strict_percent_decode(key, true)?; + let value = strict_percent_decode(value, true)?; + match key.as_str() { + "d" => { + if target.replace(value).is_some() { + return Err(ProxyError::InvalidRequest); + } + } "h" | "r" => { option_count = option_count .checked_add(1) @@ -114,41 +136,74 @@ fn parse_proxy_request_with_raw_length( } let target = target.ok_or(ProxyError::InvalidRequest)?; - if target.len() > MAX_TARGET_URL { - return Err(ProxyError::InvalidRequest); - } let mut target = Url::parse(&target).map_err(|_| ProxyError::InvalidRequest)?; - if !matches!(target.scheme(), "http" | "https") || target.host().is_none() { - return Err(ProxyError::InvalidRequest); + if let Some(path_tail) = path_tail { + let path_tail = strict_percent_decode(path_tail, false)?; + target.set_path(if path_tail.is_empty() { + "/" + } else { + &path_tail + }); + target.set_query(upstream_query); } - target.set_fragment(None); - if !path_tail.is_empty() { - let declared = target.clone(); - let joined = target - .join(path_tail) - .map_err(|_| ProxyError::InvalidRequest)?; - if declared.scheme() != joined.scheme() - || !same_authority(&declared, &joined) - || declared.username() != joined.username() - || declared.password() != joined.password() - { - return Err(ProxyError::InvalidRequest); + let target = validate_proxy_target(target)?; + + Ok(ParsedProxyRequest { + target, + request_headers, + response_headers, + }) +} + +fn strict_percent_decode(value: &str, plus_as_space: bool) -> Result { + fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, } - target = joined; } - if let Some(query) = upstream_query { - target.set_query(Some(query)); + + let bytes = value.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0usize; + while index < bytes.len() { + match bytes[index] { + b'%' => { + let high = bytes + .get(index + 1) + .and_then(|byte| hex_value(*byte)) + .ok_or(ProxyError::InvalidRequest)?; + let low = bytes + .get(index + 2) + .and_then(|byte| hex_value(*byte)) + .ok_or(ProxyError::InvalidRequest)?; + decoded.push((high << 4) | low); + index += 3; + } + b'+' if plus_as_space => { + decoded.push(b' '); + index += 1; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8(decoded).map_err(|_| ProxyError::InvalidRequest) +} + +fn validate_proxy_target(mut target: Url) -> Result { + if !matches!(target.scheme(), "http" | "https") || target.host().is_none() { + return Err(ProxyError::InvalidRequest); } target.set_fragment(None); if target.as_str().len() > MAX_TARGET_URL { return Err(ProxyError::InvalidRequest); } - - Ok(ParsedProxyRequest { - target, - request_headers, - response_headers, - }) + Ok(target) } fn parse_custom_header(value: &str) -> Result<(HeaderName, HeaderValue), ProxyError> { @@ -298,66 +353,59 @@ fn same_authority(left: &Url, right: &Url) -> bool { && left.port_or_known_default() == right.port_or_known_default() } -pub fn router() -> Router { - Router::new() - .route("/proxy", any(proxy_root_handler)) - .route("/proxy/", any(proxy_root_handler)) - .route("/proxy/{*rest}", any(proxy_path_handler)) +pub fn service(state: AppState) -> Router { + Router::new().fallback(any(proxy_handler)).with_state(state) } -async fn proxy_root_handler( +async fn proxy_handler( State(state): State, - RawQuery(raw_query): RawQuery, + OriginalUri(original_uri): OriginalUri, headers: HeaderMap, method: Method, ) -> Response { - handle_proxy(state, String::new(), 0, raw_query, headers, method).await + handle_proxy(&state.proxy_runtime, original_uri, headers, method).await } -async fn proxy_path_handler( - State(state): State, - Path(rest): Path, - OriginalUri(original_uri): OriginalUri, - RawQuery(raw_query): RawQuery, +async fn handle_proxy( + runtime: &ProxyRuntime, + original_uri: Uri, headers: HeaderMap, method: Method, ) -> Response { - let raw_rest_length = original_uri - .path() - .strip_prefix("/proxy/") - .map_or_else(|| original_uri.path().len(), str::len); - handle_proxy(state, rest, raw_rest_length, raw_query, headers, method).await + let raw_target = original_uri + .path_and_query() + .map_or_else(|| original_uri.path(), |value| value.as_str()); + let raw_suffix = match raw_target.strip_prefix("/proxy") { + Some(suffix) if suffix.is_empty() || suffix.starts_with('/') || suffix.starts_with('?') => { + suffix + } + _ => return proxy_error_response(ProxyError::InvalidRequest), + }; + handle_proxy_suffix(runtime, raw_suffix, headers, method).await } -async fn handle_proxy( - state: AppState, - rest: String, - raw_rest_length: usize, - raw_query: Option, +async fn handle_proxy_suffix( + runtime: &ProxyRuntime, + raw_suffix: &str, headers: HeaderMap, method: Method, ) -> Response { - let context = match state.proxy_runtime.try_request() { + if raw_suffix.len() > MAX_PROXY_INPUT { + return proxy_error_response(ProxyError::InvalidRequest); + } + let context = match runtime.try_request() { Ok(context) => context, Err(_) => return proxy_error_response(ProxyError::Capacity), }; - let request = - match parse_proxy_request_with_raw_length(&rest, raw_query.as_deref(), raw_rest_length) { - Ok(request) => request, - Err(error) => return proxy_error_response(error), - }; - let (upstream, final_url) = match fetch_with_redirects( - &state.proxy_runtime, - &context, - &request, - method, - &headers, - ) - .await - { - Ok(response) => response, + let request = match parse_proxy_suffix(raw_suffix) { + Ok(request) => request, Err(error) => return proxy_error_response(error), }; + let (upstream, final_url) = + match fetch_with_redirects(runtime, &context, &request, method, &headers).await { + Ok(response) => response, + Err(error) => return proxy_error_response(error), + }; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); let content_type = upstream_headers @@ -726,9 +774,10 @@ fn push_playlist(output: &mut String, value: &str) -> Result<(), ProxyError> { #[cfg(test)] mod tests { use super::{ - MAX_PLAYLIST_INPUT, MAX_PROXY_INPUT, ProxyError, buffered_proxy_body, collect_playlist, - fetch_with_redirects, parse_proxy_request, parse_proxy_request_with_raw_length, - rewrite_playlist_bounded, same_authority, streaming_proxy_body, + MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, ProxyError, + buffered_proxy_body, collect_playlist, fetch_with_redirects, handle_proxy_suffix, + parse_proxy_request, parse_proxy_suffix, rewrite_playlist_bounded, same_authority, + streaming_proxy_body, }; use crate::network_security::{ Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, @@ -739,7 +788,7 @@ mod tests { Router, body::Body, extract::Path, - http::{HeaderMap, StatusCode, header}, + http::{HeaderMap, Method, StatusCode, header}, response::{IntoResponse, Response}, routing::{any, get}, }; @@ -771,19 +820,218 @@ mod tests { #[test] fn parse_path_tail_cannot_replace_the_declared_authority() { - for tail in ["https://evil.example/steal", "//evil.example/steal"] { + for (tail, expected_path) in [ + ("https://evil.example/steal", "/https://evil.example/steal"), + ("//evil.example/steal", "//evil.example/steal"), + ] { let rest = format!("d=https%3A%2F%2Ftrusted.example&h=Authorization%3ABearer%20secret/{tail}"); + let parsed = parse_proxy_request(&rest, None).unwrap(); + assert_eq!(parsed.target.host_str(), Some("trusted.example"), "{tail}"); + assert_eq!(parsed.target.path(), expected_path, "{tail}"); + } + } + + #[test] + fn parse_selects_the_form_structurally() { + let parsed = parse_proxy_request( + "d=https%3A%2F%2Ftrusted.example%2Fold%2Fbase/media/file", + Some("d=https%3A%2F%2Fevil.example&h=Host%3Aevil&r=Set-Cookie%3Abad"), + ) + .unwrap(); + assert_eq!( + parsed.target.as_str(), + "https://trusted.example/media/file?d=https%3A%2F%2Fevil.example&h=Host%3Aevil&r=Set-Cookie%3Abad" + ); + + assert!(matches!( + parse_proxy_request("foo", Some("d=https%3A%2F%2Fexample.com")), + Err(ProxyError::InvalidRequest) + )); + } + + #[test] + fn parse_requires_exactly_one_decoded_lowercase_target_key() { + for (rest, query) in [ + ("", Some("x=value")), + ( + "", + Some("d=https%3A%2F%2Fone.example&d=https%3A%2F%2Ftwo.example"), + ), + ( + "", + Some("d=https%3A%2F%2Fone.example&%64=https%3A%2F%2Ftwo.example"), + ), + ("", Some("D=https%3A%2F%2Fexample.com")), + ] { + assert!(matches!( + parse_proxy_request(rest, query), + Err(ProxyError::InvalidRequest) + )); + } + } + + #[test] + fn parse_form_decodes_options_exactly_once() { + let parsed = parse_proxy_request( + "", + Some("d=https%3A%2F%2Fexample.com%2F%252F&h=X-Test%3Aa%26b%3Dc%2Bd+e"), + ) + .unwrap(); + assert_eq!(parsed.target.as_str(), "https://example.com/%2F"); + assert_eq!(parsed.request_headers["x-test"], "a&b=c+d e"); + } + + #[test] + fn parse_path_decodes_once_with_path_semantics_and_replaces_base_components() { + let parsed = parse_proxy_request( + "d=https%3A%2F%2Fuser%3Apass%40example.com%2Fold%3Fbase%3D1%23fragment/a+b%2Fc%3Fd%23e", + Some("outer=a%2Bb"), + ) + .unwrap(); + assert_eq!( + parsed.target.as_str(), + "https://user:pass@example.com/a+b/c%3Fd%23e?outer=a%2Bb" + ); + + let no_tail = parse_proxy_request( + "d=https%3A%2F%2Fuser%3Apass%40example.com%2Fold%3Fbase%3D1%23fragment", + None, + ) + .unwrap(); + assert_eq!(no_tail.target.as_str(), "https://user:pass@example.com/"); + + let explicit_empty_query = + parse_proxy_request("d=https%3A%2F%2Fexample.com%2Fold%3Fbase%3D1", Some("")).unwrap(); + assert_eq!( + explicit_empty_query.target.as_str(), + "https://example.com/?" + ); + } + + #[test] + fn parse_rejects_malformed_percent_encoding_and_utf8() { + for invalid in ["%", "%0", "%GG", "%FF"] { + let query = format!("d=https%3A%2F%2Fexample.com&unknown={invalid}"); + assert!( + matches!( + parse_proxy_request("", Some(&query)), + Err(ProxyError::InvalidRequest) + ), + "{invalid}" + ); + + let rest = format!("d=https%3A%2F%2Fexample.com/{invalid}"); assert!( matches!( parse_proxy_request(&rest, None), Err(ProxyError::InvalidRequest) ), - "{tail}" + "{invalid}" ); } } + #[test] + fn parse_query_preserves_userinfo_and_clears_fragment() { + let parsed = parse_proxy_request( + "", + Some("d=https%3A%2F%2Fuser%3Apass%40example.com%2Fvideo%3Fx%3D1%23fragment"), + ) + .unwrap(); + assert_eq!( + parsed.target.as_str(), + "https://user:pass@example.com/video?x=1" + ); + } + + #[test] + fn parse_accepts_exact_raw_and_canonical_limits() { + let prefix = "?d=https%3A%2F%2Fexample.com&unknown="; + let exact_raw = format!("{prefix}{}", "a".repeat(MAX_PROXY_INPUT - prefix.len())); + assert_eq!(exact_raw.len(), MAX_PROXY_INPUT); + assert!(parse_proxy_suffix(&exact_raw).is_ok()); + assert!(matches!( + parse_proxy_suffix(&format!("{exact_raw}a")), + Err(ProxyError::InvalidRequest) + )); + + let target_prefix = "https://example.com/"; + let exact_target = format!( + "{target_prefix}{}", + "a".repeat(MAX_TARGET_URL - target_prefix.len()) + ); + let exact_query = format!("d={exact_target}"); + assert_eq!( + parse_proxy_request("", Some(&exact_query)) + .unwrap() + .target + .as_str() + .len(), + MAX_TARGET_URL + ); + let oversized_query = format!("d={exact_target}a"); + assert!(matches!( + parse_proxy_request("", Some(&oversized_query)), + Err(ProxyError::InvalidRequest) + )); + } + + #[test] + fn parse_preserves_header_count_and_pair_limits() { + let options = (0..64) + .map(|index| format!("h=X-{index}%3Avalue")) + .collect::>() + .join("&"); + let query = format!("d=https%3A%2F%2Fexample.com&{options}"); + assert_eq!( + parse_proxy_request("", Some(&query)) + .unwrap() + .request_headers + .len(), + 64 + ); + + let exact_pair = format!( + "d=https%3A%2F%2Fexample.com&h=X:{}", + "a".repeat(MAX_HEADER_PAIR - 2) + ); + assert!(parse_proxy_request("", Some(&exact_pair)).is_ok()); + let oversized_pair = format!("{exact_pair}a"); + assert!(matches!( + parse_proxy_request("", Some(&oversized_pair)), + Err(ProxyError::InvalidRequest) + )); + } + + #[tokio::test] + async fn overlong_raw_suffix_precedes_capacity_and_dns() { + let (runtime, resolver) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let permits = (0..64) + .map(|_| runtime.try_request().unwrap()) + .collect::>(); + let prefix = "?d=http%3A%2F%2Fblocked.example&unknown="; + let raw_suffix = format!("{prefix}{}", "a".repeat(MAX_PROXY_INPUT + 1 - prefix.len())); + let response = + handle_proxy_suffix(&runtime, &raw_suffix, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); + + let response = handle_proxy_suffix( + &runtime, + "?d=http%3A%2F%2Fblocked.example", + HeaderMap::new(), + Method::GET, + ) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); + drop(permits); + } + #[test] fn redirect_origin_requires_the_same_scheme() { let http = Url::parse("http://example.test:443/source").unwrap(); @@ -863,14 +1111,11 @@ mod tests { Err(ProxyError::InvalidRequest) )); - // Axum percent-decodes wildcard path captures. The raw URI length must - // remain authoritative so encoded input cannot shrink under the cap. + let prefix = "/d=https%3A%2F%2Fexample.com/"; + let raw_suffix = format!("{prefix}{}", "a".repeat(MAX_PROXY_INPUT + 1 - prefix.len())); + assert_eq!(raw_suffix.len(), MAX_PROXY_INPUT + 1); assert!(matches!( - parse_proxy_request_with_raw_length( - "d=https%3A%2F%2Fexample.com", - None, - MAX_PROXY_INPUT + 1, - ), + parse_proxy_suffix(&raw_suffix), Err(ProxyError::InvalidRequest) )); } diff --git a/server/tests/proxy_security.rs b/server/tests/proxy_security.rs index 196464c..d3d8fb7 100644 --- a/server/tests/proxy_security.rs +++ b/server/tests/proxy_security.rs @@ -120,6 +120,45 @@ fn proxy_url(server: std::net::SocketAddr, target: &str) -> String { format!("http://{server}/proxy/?d={}", urlencoding::encode(target)) } +#[tokio::test] +async fn normal_encoded_core_path_form_reaches_destination_policy() -> anyhow::Result<()> { + let config = tempfile::tempdir()?; + let cache = tempfile::tempdir()?; + let server_config = stream_server::ServerConfig { + http_addr: "127.0.0.1:0".parse().unwrap(), + config_dir: Some(config.path().join("config")), + cache_dir: Some(cache.path().join("cache")), + ..stream_server::ServerConfig::embedded() + }; + let server = tokio::task::spawn_blocking(move || stream_server::start(server_config)).await??; + let client = reqwest::Client::new(); + + for path in [ + "/proxy?d=http%3A%2F%2F127.0.0.1%3A1%2Fmedia", + "/proxy/?d=http%3A%2F%2F127.0.0.1%3A1%2Fmedia", + "/proxy/d=http%3A%2F%2F127.0.0.1%3A1/media", + ] { + let response = client + .get(format!("http://{}{path}", server.http_addr())) + .send() + .await?; + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "unexpected response for {path}: {:?}", + response.text().await? + ); + } + + let shutdown = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + server.shutdown()?; + server.join() + }) + .await??; + assert_eq!(shutdown, Some(stream_server::ShutdownSource::External)); + Ok(()) +} + #[tokio::test] async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> anyhow::Result<()> { let (fixture_addr, fixture_task) = start_fixture().await; From 75b97a0345cc3c8f05eeac944e0b130157f78ccd Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:14:38 -0400 Subject: [PATCH 14/25] security: isolate proxy responses and routing headers --- server/src/routes/proxy.rs | 394 +++++++++++++++++++++++++++++++++++-- 1 file changed, 378 insertions(+), 16 deletions(-) diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 1fad5e1..487a648 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -210,6 +210,13 @@ fn parse_custom_header(value: &str) -> Result<(HeaderName, HeaderValue), ProxyEr let (name, value) = value.split_once(':').ok_or(ProxyError::InvalidRequest)?; let name = name.trim(); let value = value.trim(); + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + { + return Err(ProxyError::InvalidRequest); + } if name .len() .checked_add(value.len()) @@ -224,8 +231,9 @@ fn parse_custom_header(value: &str) -> Result<(HeaderName, HeaderValue), ProxyEr } fn request_header_forbidden(name: &HeaderName) -> bool { + let name = name.as_str(); matches!( - name.as_str(), + name, "host" | "connection" | "keep-alive" @@ -237,18 +245,19 @@ fn request_header_forbidden(name: &HeaderName) -> bool { | "transfer-encoding" | "upgrade" | "content-length" - ) + | "forwarded" + | "via" + | "proxy-connection" + | "http2-settings" + | "x-real-ip" + | "x-host" + ) || name.starts_with("x-forwarded-") + || name.starts_with("x-original-") + || name.starts_with("x-rewrite-") } fn response_header_forbidden(name: &HeaderName) -> bool { - request_header_forbidden(name) - || matches!( - name.as_str(), - "set-cookie" - | "access-control-allow-origin" - | "access-control-allow-methods" - | "access-control-allow-headers" - ) + name != header::CONTENT_TYPE } async fn fetch_with_redirects( @@ -312,6 +321,10 @@ async fn fetch_with_redirects( result = send => result.map_err(|_| ProxyError::Upstream)?, }; + if response.status() == StatusCode::SWITCHING_PROTOCOLS { + return Err(ProxyError::Upstream); + } + if !REDIRECT_STATUSES.contains(&response.status()) { return Ok((response, destination.url)); } @@ -393,6 +406,9 @@ async fn handle_proxy_suffix( if raw_suffix.len() > MAX_PROXY_INPUT { return proxy_error_response(ProxyError::InvalidRequest); } + if method == Method::CONNECT { + return proxy_error_response(ProxyError::InvalidRequest); + } let context = match runtime.try_request() { Ok(context) => context, Err(_) => return proxy_error_response(ProxyError::Capacity), @@ -669,9 +685,31 @@ fn build_proxy_response( header::ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("*"), ); + apply_route_owned_headers(&mut response); response } +fn apply_route_owned_headers(response: &mut Response) { + response.headers_mut().insert( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static( + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox", + ), + ); + response.headers_mut().insert( + HeaderName::from_static("x-content-type-options"), + HeaderValue::from_static("nosniff"), + ); + response.headers_mut().insert( + HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("no-referrer"), + ); + response.headers_mut().insert( + HeaderName::from_static("x-frame-options"), + HeaderValue::from_static("DENY"), + ); +} + fn proxy_error_response(error: ProxyError) -> Response { let (status, message) = match error { ProxyError::InvalidRequest => (StatusCode::BAD_REQUEST, "Invalid proxy request"), @@ -690,6 +728,7 @@ fn proxy_error_response(error: ProxyError) -> Response { .headers_mut() .insert(header::RETRY_AFTER, HeaderValue::from_static("1")); } + apply_route_owned_headers(&mut response); response } @@ -775,9 +814,9 @@ fn push_playlist(output: &mut String, value: &str) -> Result<(), ProxyError> { mod tests { use super::{ MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, ProxyError, - buffered_proxy_body, collect_playlist, fetch_with_redirects, handle_proxy_suffix, - parse_proxy_request, parse_proxy_suffix, rewrite_playlist_bounded, same_authority, - streaming_proxy_body, + buffered_proxy_body, collect_playlist, fetch_with_redirects, handle_proxy, + handle_proxy_suffix, parse_proxy_request, parse_proxy_suffix, proxy_error_response, + rewrite_playlist_bounded, same_authority, streaming_proxy_body, }; use crate::network_security::{ Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, @@ -788,7 +827,7 @@ mod tests { Router, body::Body, extract::Path, - http::{HeaderMap, Method, StatusCode, header}, + http::{HeaderMap, Method, StatusCode, Uri, header}, response::{IntoResponse, Response}, routing::{any, get}, }; @@ -801,8 +840,24 @@ mod tests { }, time::{Duration, Instant}, }; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; use url::Url; + fn assert_response_isolated(response: &Response) { + let expected = [ + ( + "content-security-policy", + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox", + ), + ("x-content-type-options", "nosniff"), + ("referrer-policy", "no-referrer"), + ("x-frame-options", "DENY"), + ]; + for (name, value) in expected { + assert_eq!(response.headers().get(name).unwrap(), value, "{name}"); + } + } + #[test] fn parse_core_path_format_preserves_tail_query() { let parsed = parse_proxy_request( @@ -1045,13 +1100,123 @@ mod tests { let parsed = parse_proxy_request( "", Some( - "d=https%3A%2F%2Fexample.com%2Fvideo%3Fx%3D1&h=X-Test%3Afirst&h=X-Test%3Asecond&r=X-Reply%3Aok", + "d=https%3A%2F%2Fexample.com%2Fvideo%3Fx%3D1&h=X-Test%3Afirst&h=X-Test%3Asecond&r=Content-Type%3Atext%2Fplain&r=content-type%3Avideo%2Fmp4", ), ) .unwrap(); assert_eq!(parsed.target.as_str(), "https://example.com/video?x=1"); assert_eq!(parsed.request_headers["x-test"], "second"); - assert_eq!(parsed.response_headers["x-reply"], "ok"); + assert_eq!(parsed.response_headers.len(), 1); + assert_eq!(parsed.response_headers[header::CONTENT_TYPE], "video/mp4"); + } + + #[test] + fn parse_rejects_non_token_alias_custom_header_names() { + for name in ["X_Test", "X.Test", "", "X-É"] { + let header = format!("{name}: value"); + let option = urlencoding::encode(&header); + let query = format!("d=https%3A%2F%2Fexample.com&h={option}"); + assert!( + matches!( + parse_proxy_request("", Some(&query)), + Err(ProxyError::InvalidRequest) + ), + "{name:?}" + ); + } + + let parsed = parse_proxy_request( + "", + Some("d=https%3A%2F%2Fexample.com&h=X-Api-Key2%3Asecret&r=Content-Type%3Atext%2Fplain"), + ) + .unwrap(); + assert_eq!(parsed.request_headers["x-api-key2"], "secret"); + assert_eq!(parsed.response_headers[header::CONTENT_TYPE], "text/plain"); + } + + #[test] + fn parse_rejects_routing_header_aliases_case_insensitively() { + for name in [ + "FoRwArDeD", + "vIa", + "Proxy-Connection", + "HTTP2-Settings", + "X-FoRwArDeD-For", + "x-ORIGINAL-Uri", + "X-Rewrite-URL", + "x-REAL-ip", + "X-hOsT", + ] { + let header = format!("{name}: attacker.example"); + let option = urlencoding::encode(&header); + let query = format!("d=https%3A%2F%2Fexample.com&h={option}"); + assert!( + matches!( + parse_proxy_request("", Some(&query)), + Err(ProxyError::InvalidRequest) + ), + "{name}" + ); + } + } + + #[test] + fn parse_allows_legitimate_initial_hop_request_headers() { + let parsed = parse_proxy_request( + "", + Some(concat!( + "d=https%3A%2F%2Fexample.com", + "&h=Authorization%3ABearer%20secret", + "&h=Cookie%3Asession%3Dsecret", + "&h=Origin%3Ahttps%3A%2F%2Fapp.example", + "&h=Referer%3Ahttps%3A%2F%2Fapp.example%2Fplayer", + "&h=X-Api-Key%3Asecret" + )), + ) + .unwrap(); + + assert_eq!( + parsed.request_headers[header::AUTHORIZATION], + "Bearer secret" + ); + assert_eq!(parsed.request_headers[header::COOKIE], "session=secret"); + assert_eq!( + parsed.request_headers[header::ORIGIN], + "https://app.example" + ); + assert_eq!( + parsed.request_headers[header::REFERER], + "https://app.example/player" + ); + assert_eq!(parsed.request_headers["x-api-key"], "secret"); + } + + #[test] + fn parse_limits_custom_response_headers_to_content_type() { + for name in [ + "Content-Security-Policy", + "X-Content-Type-Options", + "Referrer-Policy", + "X-Frame-Options", + "Clear-Site-Data", + "Service-Worker-Allowed", + "Access-Control-Allow-Origin", + "Cache-Control", + "ETag", + "Content-Range", + "X-Reply", + ] { + let header = format!("{name}: attacker-value"); + let option = urlencoding::encode(&header); + let query = format!("d=https%3A%2F%2Fexample.com&r={option}"); + assert!( + matches!( + parse_proxy_request("", Some(&query)), + Err(ProxyError::InvalidRequest) + ), + "{name}" + ); + } } #[test] @@ -1092,6 +1257,23 @@ mod tests { } } + #[test] + fn every_stable_error_response_has_route_owned_isolation_headers() { + for (error, status) in [ + (ProxyError::InvalidRequest, StatusCode::BAD_REQUEST), + (ProxyError::Blocked, StatusCode::FORBIDDEN), + (ProxyError::Upstream, StatusCode::BAD_GATEWAY), + (ProxyError::Capacity, StatusCode::SERVICE_UNAVAILABLE), + ] { + let response = proxy_error_response(error); + assert_eq!(response.status(), status); + assert_response_isolated(&response); + if error == ProxyError::Capacity { + assert_eq!(response.headers()[header::RETRY_AFTER], "1"); + } + } + } + #[test] fn parse_enforces_option_and_target_limits_before_network_access() { let options = (0..65) @@ -1177,6 +1359,186 @@ mod tests { (address, task) } + #[tokio::test] + async fn active_html_and_svg_responses_receive_fixed_isolation_headers() { + async fn active(Path(kind): Path) -> Response { + let (content_type, body) = if kind == "html" { + ( + "text/html", + "", + ) + } else { + ( + "image/svg+xml", + "", + ) + }; + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header("content-security-policy", "script-src *") + .header("x-content-type-options", "unsafe") + .header("referrer-policy", "unsafe-url") + .header("x-frame-options", "ALLOWALL") + .body(Body::from(body)) + .unwrap() + } + + let (address, fixture) = fixture(Router::new().route("/{kind}", get(active))).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + + for (kind, expected_content_type) in [("html", "text/html"), ("svg", "image/svg+xml")] { + let target = format!("http://active.test:{}/{kind}", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::OK, "{kind}"); + assert_eq!( + response.headers()[header::CONTENT_TYPE], + expected_content_type, + "{kind}" + ); + assert_response_isolated(&response); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!(!body.is_empty(), "{kind}"); + } + fixture.abort(); + } + + #[tokio::test] + async fn connect_is_rejected_before_resolver_or_upstream_work() { + let upstream_calls = Arc::new(AtomicUsize::new(0)); + let calls = upstream_calls.clone(); + let (address, fixture) = fixture(Router::new().fallback(any(move || { + let calls = calls.clone(); + async move { + calls.fetch_add(1, Ordering::SeqCst); + "unexpected upstream request" + } + }))) + .await; + let (runtime, resolver) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://connect.test:{}/", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::CONNECT).await; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_response_isolated(&response); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); + assert_eq!(upstream_calls.load(Ordering::SeqCst), 0); + fixture.abort(); + } + + #[tokio::test] + async fn upstream_switching_protocols_is_rejected_as_isolated_bad_gateway() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let fixture = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + let read = stream.read(&mut request).await.unwrap(); + assert!(read > 0); + stream + .write_all( + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: fixture\r\n\r\n", + ) + .await + .unwrap(); + }); + let (runtime, resolver) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://upgrade.test:{}/upgrade", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_response_isolated(&response); + assert_eq!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + "Proxy upstream request failed" + ); + fixture.await.unwrap(); + } + + #[tokio::test] + async fn legitimate_custom_request_headers_reach_the_initial_hop() { + let (seen_tx, seen_rx) = tokio::sync::oneshot::channel(); + let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx))); + let (address, fixture) = fixture(Router::new().route( + "/headers", + get(move |headers: HeaderMap| { + let seen_tx = seen_tx.clone(); + async move { + if let Some(sender) = seen_tx.lock().unwrap().take() { + let _ = sender.send(headers); + } + "ok" + } + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://headers.test:{}/headers", address.port()); + let uri: Uri = format!( + concat!( + "/proxy/?d={}", + "&h=Authorization%3ABearer%20secret", + "&h=Cookie%3Asession%3Dsecret", + "&h=Origin%3Ahttps%3A%2F%2Fapp.example", + "&h=Referer%3Ahttps%3A%2F%2Fapp.example%2Fplayer", + "&h=X-Api-Key%3Asecret" + ), + urlencoding::encode(&target) + ) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + let headers = seen_rx.await.unwrap(); + assert_eq!(headers[header::AUTHORIZATION], "Bearer secret"); + assert_eq!(headers[header::COOKIE], "session=secret"); + assert_eq!(headers[header::ORIGIN], "https://app.example"); + assert_eq!(headers[header::REFERER], "https://app.example/player"); + assert_eq!(headers["x-api-key"], "secret"); + fixture.abort(); + } + #[tokio::test] async fn dns_pinning_resolves_each_hop_exactly_once() { let (address, fixture) = fixture(Router::new().route( From 11fc81d980d81c904de74775bdadfeb6b6a07054 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:40:54 -0400 Subject: [PATCH 15/25] security: enforce proxy redirect and cache boundaries --- server/src/routes/proxy.rs | 1152 ++++++++++++++++++++++++++++++++++-- 1 file changed, 1093 insertions(+), 59 deletions(-) diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 487a648..298a40f 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -52,6 +52,13 @@ struct ParsedProxyRequest { response_headers: HeaderMap, } +struct FetchedProxyResponse { + response: reqwest::Response, + final_url: Url, + effective_custom_request_headers: HeaderMap, + effective_response_headers: HeaderMap, +} + #[cfg(test)] fn parse_proxy_request( rest: &str, @@ -266,7 +273,7 @@ async fn fetch_with_redirects( request: &ParsedProxyRequest, method: Method, incoming: &HeaderMap, -) -> Result<(reqwest::Response, Url), ProxyError> { +) -> Result { const REDIRECT_STATUSES: &[StatusCode] = &[ StatusCode::MOVED_PERMANENTLY, StatusCode::FOUND, @@ -284,6 +291,12 @@ async fn fetch_with_redirects( let mut target = request.target.clone(); let mut custom_headers = request.request_headers.clone(); + let mut automatic_headers = HeaderMap::new(); + for name in AUTOMATIC_REQUEST_HEADERS { + if let Some(value) = incoming.get(name) { + automatic_headers.insert(name.clone(), value.clone()); + } + } let mut redirects = 0usize; loop { let destination = runtime.validate(context, &target).await?; @@ -298,12 +311,7 @@ async fn fetch_with_redirects( builder = builder.resolve_to_addrs(domain, &destination.addrs); } let client = builder.build().map_err(|_| ProxyError::Upstream)?; - let mut headers = HeaderMap::new(); - for name in AUTOMATIC_REQUEST_HEADERS { - if let Some(value) = incoming.get(name) { - headers.insert(name.clone(), value.clone()); - } - } + let mut headers = automatic_headers.clone(); for (name, value) in &custom_headers { headers.insert(name.clone(), value.clone()); } @@ -326,7 +334,12 @@ async fn fetch_with_redirects( } if !REDIRECT_STATUSES.contains(&response.status()) { - return Ok((response, destination.url)); + return Ok(FetchedProxyResponse { + response, + final_url: destination.url, + effective_custom_request_headers: custom_headers, + effective_response_headers: request.response_headers.clone(), + }); } if redirects >= 5 { return Err(ProxyError::Upstream); @@ -351,16 +364,31 @@ async fn fetch_with_redirects( if next.as_str().len() > MAX_TARGET_URL { return Err(ProxyError::Upstream); } - if !same_authority(&destination.url, &next) { - let _ = next.set_username(""); - let _ = next.set_password(None); - custom_headers.clear(); - } + apply_redirect_origin_policy( + &destination.url, + &mut next, + &mut automatic_headers, + &mut custom_headers, + ); target = next; } } -fn same_authority(left: &Url, right: &Url) -> bool { +fn apply_redirect_origin_policy( + current: &Url, + next: &mut Url, + automatic_headers: &mut HeaderMap, + custom_headers: &mut HeaderMap, +) { + if !same_origin(current, next) { + let _ = next.set_username(""); + let _ = next.set_password(None); + custom_headers.clear(); + automatic_headers.remove(header::IF_RANGE); + } +} + +fn same_origin(left: &Url, right: &Url) -> bool { left.scheme() == right.scheme() && left.host() == right.host() && left.port_or_known_default() == right.port_or_known_default() @@ -417,31 +445,36 @@ async fn handle_proxy_suffix( Ok(request) => request, Err(error) => return proxy_error_response(error), }; - let (upstream, final_url) = - match fetch_with_redirects(runtime, &context, &request, method, &headers).await { - Ok(response) => response, - Err(error) => return proxy_error_response(error), - }; + let credential_bearing = !request.target.username().is_empty() + || request.target.password().is_some() + || !request.request_headers.is_empty(); + let request_method = method.clone(); + let fetched = match fetch_with_redirects(runtime, &context, &request, method, &headers).await { + Ok(response) => response, + Err(error) => return proxy_error_response(error), + }; + let FetchedProxyResponse { + response: upstream, + final_url, + effective_custom_request_headers: _effective_custom_request_headers, + effective_response_headers, + } = fetched; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); - let content_type = upstream_headers + let content_type_playlist = effective_response_headers .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or(""); + .or_else(|| upstream_headers.get(header::CONTENT_TYPE)) + .is_some_and(content_type_is_playlist); let playlist = final_url.path().ends_with(".m3u8") || final_url.path().ends_with(".m3u") - || content_type.to_ascii_lowercase().contains("mpegurl"); - - if playlist && status != StatusCode::PARTIAL_CONTENT { - if upstream_headers - .get(header::CONTENT_ENCODING) - .is_some_and(|value| { - value - .to_str() - .map(|value| !value.eq_ignore_ascii_case("identity")) - .unwrap_or(true) - }) - { + || content_type_playlist; + let transform = playlist + && request_method != Method::HEAD + && status == StatusCode::OK + && !cache_control_forbids_transform(&upstream_headers); + + if transform { + if !content_encoding_is_identity_only(&upstream_headers) { return proxy_error_response(ProxyError::Upstream); } let body = match collect_playlist(upstream, &context).await { @@ -463,9 +496,10 @@ async fn handle_proxy_suffix( return build_proxy_response( status, &upstream_headers, - &request.response_headers, + &effective_response_headers, buffered_proxy_body(Bytes::from(body), cancellation, capacity), true, + credential_bearing, ); } @@ -478,12 +512,192 @@ async fn handle_proxy_suffix( build_proxy_response( status, &upstream_headers, - &request.response_headers, + &effective_response_headers, body, false, + credential_bearing, ) } +fn content_type_is_playlist(value: &HeaderValue) -> bool { + let media_type = trim_ascii_ows( + value + .as_bytes() + .split(|byte| *byte == b';') + .next() + .unwrap_or_default(), + ); + let mut parts = media_type.split(|byte| *byte == b'/'); + let Some(kind) = parts.next() else { + return false; + }; + let Some(subtype) = parts.next() else { + return false; + }; + if parts.next().is_some() + || kind.is_empty() + || subtype.is_empty() + || !kind.iter().chain(subtype).all(|byte| is_http_token(*byte)) + { + return false; + } + subtype + .windows(b"mpegurl".len()) + .any(|candidate| candidate.eq_ignore_ascii_case(b"mpegurl")) +} + +fn cache_control_forbids_transform(headers: &HeaderMap) -> bool { + headers + .get_all(header::CACHE_CONTROL) + .iter() + .any(|value| cache_control_has_no_transform(value.as_bytes()).unwrap_or(true)) +} + +fn cache_control_has_no_transform(value: &[u8]) -> Option { + let mut start = 0usize; + let mut quoted = false; + let mut escaped = false; + let mut no_transform = false; + for (index, byte) in value.iter().copied().enumerate() { + if quoted { + if escaped { + if !is_quoted_header_byte(byte) { + return None; + } + escaped = false; + } else { + match byte { + b'\\' => escaped = true, + b'"' => quoted = false, + byte if !is_quoted_header_byte(byte) => return None, + _ => {} + } + } + } else { + match byte { + b'"' => quoted = true, + b',' => { + no_transform |= cache_control_directive(&value[start..index])?; + start = index + 1; + } + byte if byte >= 0x80 || (byte < 0x20 && !matches!(byte, b' ' | b'\t')) => { + return None; + } + 0x7f => return None, + _ => {} + } + } + } + if quoted || escaped { + return None; + } + no_transform |= cache_control_directive(&value[start..])?; + Some(no_transform) +} + +fn cache_control_directive(value: &[u8]) -> Option { + let value = trim_ascii_ows(value); + let name_length = value + .iter() + .position(|byte| !is_http_token(*byte)) + .unwrap_or(value.len()); + if name_length == 0 { + return None; + } + let name = &value[..name_length]; + let remainder = trim_ascii_ows(&value[name_length..]); + if !remainder.is_empty() { + let parameter = trim_ascii_ows(remainder.strip_prefix(b"=")?); + if parameter.is_empty() { + return None; + } + if parameter[0] == b'"' { + if !valid_quoted_header_value(parameter) { + return None; + } + } else if !parameter.iter().all(|byte| is_http_token(*byte)) { + return None; + } + } + Some(name.eq_ignore_ascii_case(b"no-transform")) +} + +fn valid_quoted_header_value(value: &[u8]) -> bool { + if value.len() < 2 || value[0] != b'"' { + return false; + } + let mut escaped = false; + for (index, byte) in value[1..].iter().copied().enumerate() { + if escaped { + if !is_quoted_header_byte(byte) { + return false; + } + escaped = false; + continue; + } + match byte { + b'\\' => escaped = true, + b'"' => return trim_ascii_ows(&value[index + 2..]).is_empty(), + byte if !is_quoted_header_byte(byte) => return false, + _ => {} + } + } + false +} + +fn is_quoted_header_byte(byte: u8) -> bool { + matches!(byte, b'\t' | b' '..=b'~' | 0x80..=0xff) +} + +fn is_http_token(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +fn content_encoding_is_identity_only(headers: &HeaderMap) -> bool { + headers + .get_all(header::CONTENT_ENCODING) + .iter() + .all(|value| { + value.as_bytes().split(|byte| *byte == b',').all(|coding| { + let coding = trim_ascii_ows(coding); + !coding.is_empty() && coding.eq_ignore_ascii_case(b"identity") + }) + }) +} + +fn trim_ascii_ows(mut value: &[u8]) -> &[u8] { + while value + .first() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + value = &value[1..]; + } + while value + .last() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + value = &value[..value.len() - 1]; + } + value +} + fn streaming_proxy_body( stream: UpstreamByteStream, cancellation: CancellationToken, @@ -643,6 +857,7 @@ fn build_proxy_response( custom: &HeaderMap, body: Body, rewritten: bool, + credential_bearing: bool, ) -> Response { const SAFE_RESPONSE_HEADERS: &[HeaderName] = &[ header::ACCEPT_RANGES, @@ -654,25 +869,44 @@ fn build_proxy_response( header::SERVER, header::DATE, header::CONTENT_ENCODING, + header::CACHE_CONTROL, + header::EXPIRES, + header::PRAGMA, + header::VARY, ]; let mut response = Response::new(body); *response.status_mut() = status; for name in SAFE_RESPONSE_HEADERS { - if rewritten - && matches!( - *name, - header::CONTENT_LENGTH | header::CONTENT_RANGE | header::CONTENT_ENCODING - ) - { - continue; - } - if let Some(value) = upstream.get(name) { - response.headers_mut().insert(name.clone(), value.clone()); + for value in upstream.get_all(name).iter() { + response.headers_mut().append(name.clone(), value.clone()); } } for (name, value) in custom { response.headers_mut().insert(name.clone(), value.clone()); } + if rewritten { + for name in [ + header::CONTENT_LENGTH, + header::CONTENT_RANGE, + header::CONTENT_ENCODING, + header::ETAG, + header::LAST_MODIFIED, + ] { + response.headers_mut().remove(name); + } + response + .headers_mut() + .insert(header::ACCEPT_RANGES, HeaderValue::from_static("none")); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + } else if credential_bearing { + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); + } response.headers_mut().insert( header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"), @@ -728,6 +962,10 @@ fn proxy_error_response(error: ProxyError) -> Response { .headers_mut() .insert(header::RETRY_AFTER, HeaderValue::from_static("1")); } + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("private, no-store"), + ); apply_route_owned_headers(&mut response); response } @@ -814,9 +1052,9 @@ fn push_playlist(output: &mut String, value: &str) -> Result<(), ProxyError> { mod tests { use super::{ MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, ProxyError, - buffered_proxy_body, collect_playlist, fetch_with_redirects, handle_proxy, - handle_proxy_suffix, parse_proxy_request, parse_proxy_suffix, proxy_error_response, - rewrite_playlist_bounded, same_authority, streaming_proxy_body, + apply_redirect_origin_policy, buffered_proxy_body, collect_playlist, fetch_with_redirects, + handle_proxy, handle_proxy_suffix, parse_proxy_request, parse_proxy_suffix, + proxy_error_response, rewrite_playlist_bounded, same_origin, streaming_proxy_body, }; use crate::network_security::{ Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, @@ -827,7 +1065,7 @@ mod tests { Router, body::Body, extract::Path, - http::{HeaderMap, Method, StatusCode, Uri, header}, + http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}, response::{IntoResponse, Response}, routing::{any, get}, }; @@ -1088,11 +1326,39 @@ mod tests { } #[test] - fn redirect_origin_requires_the_same_scheme() { + fn redirect_origin_uses_scheme_canonical_host_and_effective_port() { let http = Url::parse("http://example.test:443/source").unwrap(); let https = Url::parse("https://example.test:443/destination").unwrap(); + let implicit_http = Url::parse("http://EXAMPLE.test/source").unwrap(); + let explicit_http = Url::parse("http://example.test:80/destination").unwrap(); + let other_port = Url::parse("http://example.test:81/destination").unwrap(); - assert!(!same_authority(&http, &https)); + assert!(!same_origin(&http, &https)); + assert!(same_origin(&implicit_http, &explicit_http)); + assert!(!same_origin(&implicit_http, &other_port)); + } + + #[test] + fn http_to_https_same_host_and_port_clears_cross_origin_state() { + let current = Url::parse("http://example.test:443/source").unwrap(); + let mut next = + Url::parse("https://redirect-user:redirect-pass@example.test:443/destination").unwrap(); + let mut automatic = HeaderMap::from_iter([ + (header::RANGE, "bytes=10-".parse().unwrap()), + (header::IF_RANGE, "origin-a-validator".parse().unwrap()), + ]); + let mut custom = HeaderMap::from_iter([ + (header::AUTHORIZATION, "Bearer secret".parse().unwrap()), + ("x-api-key".parse().unwrap(), "secret".parse().unwrap()), + ]); + + apply_redirect_origin_policy(¤t, &mut next, &mut automatic, &mut custom); + + assert!(next.username().is_empty()); + assert!(next.password().is_none()); + assert!(!automatic.contains_key(header::IF_RANGE)); + assert_eq!(automatic[header::RANGE], "bytes=10-"); + assert!(custom.is_empty()); } #[test] @@ -1263,11 +1529,16 @@ mod tests { (ProxyError::InvalidRequest, StatusCode::BAD_REQUEST), (ProxyError::Blocked, StatusCode::FORBIDDEN), (ProxyError::Upstream, StatusCode::BAD_GATEWAY), + (ProxyError::Cancelled, StatusCode::BAD_GATEWAY), (ProxyError::Capacity, StatusCode::SERVICE_UNAVAILABLE), ] { let response = proxy_error_response(error); assert_eq!(response.status(), status); assert_response_isolated(&response); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); if error == ProxyError::Capacity { assert_eq!(response.headers()[header::RETRY_AFTER], "1"); } @@ -1562,7 +1833,7 @@ mod tests { ) .unwrap(); let context = runtime.try_request().unwrap(); - let (response, _) = fetch_with_redirects( + let fetched = fetch_with_redirects( &runtime, &context, &parsed, @@ -1571,8 +1842,8 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(response.bytes().await.unwrap(), "fixture-body"); + assert_eq!(fetched.response.status(), StatusCode::OK); + assert_eq!(fetched.response.bytes().await.unwrap(), "fixture-body"); assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); fixture.abort(); } @@ -1696,7 +1967,7 @@ mod tests { ) .unwrap(); let context = runtime.try_request().unwrap(); - let (response, _) = fetch_with_redirects( + let fetched = fetch_with_redirects( &runtime, &context, &parsed, @@ -1705,7 +1976,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.text().await.unwrap(), "done"); + assert_eq!(fetched.response.text().await.unwrap(), "done"); let parsed = parse_proxy_request( "", @@ -1787,7 +2058,7 @@ mod tests { ) .unwrap(); let context = runtime.try_request().unwrap(); - let (response, _) = fetch_with_redirects( + let fetched = fetch_with_redirects( &runtime, &context, &parsed, @@ -1796,7 +2067,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.text().await.unwrap(), "done"); + assert_eq!(fetched.response.text().await.unwrap(), "done"); let (method, headers) = tokio::time::timeout(Duration::from_secs(2), seen_rx) .await .unwrap() @@ -1808,6 +2079,769 @@ mod tests { fixture.abort(); } + #[tokio::test] + async fn redirect_origin_changes_drop_if_range_and_custom_headers_but_keep_range() { + let (cross_tx, cross_rx) = tokio::sync::oneshot::channel(); + let cross_tx = Arc::new(std::sync::Mutex::new(Some(cross_tx))); + let (same_tx, same_rx) = tokio::sync::oneshot::channel(); + let same_tx = Arc::new(std::sync::Mutex::new(Some(same_tx))); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let cross_location = format!( + "http://redirect-user:redirect-secret@other.test:{}/final-cross", + address.port() + ); + let router = Router::new() + .route( + "/redirect-cross", + any(move || { + let cross_location = cross_location.clone(); + async move { + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, cross_location)], + ) + } + }), + ) + .route( + "/final-cross", + any(move |headers: HeaderMap| { + let cross_tx = cross_tx.clone(); + async move { + if let Some(sender) = cross_tx.lock().unwrap().take() { + let _ = sender.send(headers); + } + "cross" + } + }), + ) + .route( + "/redirect-same", + any(|| async { + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, "/final-same")], + ) + }), + ) + .route( + "/final-same", + any(move |headers: HeaderMap| { + let same_tx = same_tx.clone(); + async move { + if let Some(sender) = same_tx.lock().unwrap().take() { + let _ = sender.send(headers); + } + "same" + } + }), + ); + let fixture = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let incoming = HeaderMap::from_iter([ + (header::RANGE, "bytes=10-".parse().unwrap()), + (header::IF_RANGE, "origin-a-validator".parse().unwrap()), + ]); + + for path in ["redirect-cross", "redirect-same"] { + let parsed = parse_proxy_request( + "", + Some(&format!( + concat!( + "d=http%3A%2F%2Fuser%3Asecret%40redirect.test%3A{}%2F{}", + "&h=Authorization%3ABearer%20secret", + "&h=X-Api-Key%3Asecret", + "&r=Content-Type%3Avideo%2Fmp4" + ), + address.port(), + path + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + let fetched = + fetch_with_redirects(&runtime, &context, &parsed, reqwest::Method::GET, &incoming) + .await + .unwrap(); + assert_eq!(fetched.response.status(), StatusCode::OK); + assert_eq!( + fetched.effective_response_headers[header::CONTENT_TYPE], + "video/mp4" + ); + if path == "redirect-cross" { + assert!(fetched.effective_custom_request_headers.is_empty()); + assert!(fetched.final_url.username().is_empty()); + assert!(fetched.final_url.password().is_none()); + } else { + assert_eq!( + fetched.effective_custom_request_headers[header::AUTHORIZATION], + "Bearer secret" + ); + assert_eq!( + fetched.effective_custom_request_headers["x-api-key"], + "secret" + ); + } + } + + let cross = cross_rx.await.unwrap(); + assert_eq!(cross[header::RANGE], "bytes=10-"); + assert!(!cross.contains_key(header::IF_RANGE)); + assert!(!cross.contains_key(header::AUTHORIZATION)); + assert!(!cross.contains_key("x-api-key")); + + let same = same_rx.await.unwrap(); + assert_eq!(same[header::RANGE], "bytes=10-"); + assert_eq!(same[header::IF_RANGE], "origin-a-validator"); + assert_eq!(same[header::AUTHORIZATION], "Bearer secret"); + assert_eq!(same["x-api-key"], "secret"); + fixture.abort(); + } + + #[tokio::test] + async fn relative_redirects_resolve_from_the_current_path_and_preserve_method() { + let (seen_tx, seen_rx) = tokio::sync::oneshot::channel(); + let seen_tx = Arc::new(std::sync::Mutex::new(Some(seen_tx))); + let router = Router::new() + .route( + "/a/b", + any(|| async { + ( + StatusCode::FOUND, + [(header::LOCATION, "next?from=relative")], + ) + }), + ) + .route( + "/a/next", + any(move |method: Method, uri: Uri| { + let seen_tx = seen_tx.clone(); + async move { + if let Some(sender) = seen_tx.lock().unwrap().take() { + let _ = sender.send((method, uri)); + } + "correct" + } + }), + ) + .route("/next", any(|| async { "wrong-root" })); + let (address, fixture) = fixture(router).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let parsed = parse_proxy_request( + "", + Some(&format!( + "d=http%3A%2F%2Fredirect.test%3A{}%2Fa%2Fb", + address.port() + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + let fetched = fetch_with_redirects( + &runtime, + &context, + &parsed, + reqwest::Method::POST, + &HeaderMap::new(), + ) + .await + .unwrap(); + + assert_eq!(fetched.final_url.path(), "/a/next"); + assert_eq!(fetched.final_url.query(), Some("from=relative")); + assert_eq!(fetched.response.text().await.unwrap(), "correct"); + let (method, uri) = seen_rx.await.unwrap(); + assert_eq!(method, Method::POST); + assert_eq!( + uri.path_and_query().unwrap().as_str(), + "/a/next?from=relative" + ); + fixture.abort(); + } + + #[tokio::test] + async fn public_unmodified_response_passes_safe_cache_metadata() { + let router = Router::new().route( + "/asset.bin", + get(|| async { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .header(header::CACHE_CONTROL, "stale-if-error=60") + .header(header::EXPIRES, "Thu, 20 Aug 2026 12:00:00 GMT") + .header(header::PRAGMA, "custom-extension") + .header(header::VARY, "Accept-Language") + .header(header::VARY, "Origin") + .body(Body::from("asset")) + .unwrap() + }), + ); + let (address, fixture) = fixture(router).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://cache.test:{}/asset.bin", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + + assert_eq!( + response + .headers() + .get_all(header::CACHE_CONTROL) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(), + ["public, max-age=3600", "stale-if-error=60"] + ); + assert_eq!( + response.headers()[header::EXPIRES], + "Thu, 20 Aug 2026 12:00:00 GMT" + ); + assert_eq!(response.headers()[header::PRAGMA], "custom-extension"); + assert_eq!( + response + .headers() + .get_all(header::VARY) + .iter() + .map(|value| value.to_str().unwrap()) + .collect::>(), + ["Accept-Language", "Origin"] + ); + assert_eq!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + "asset" + ); + fixture.abort(); + } + + #[tokio::test] + async fn no_transform_and_incomplete_playlist_representations_stream_unchanged() { + async fn representation(method: Method, Path(kind): Path) -> Response { + let (status, cache_control) = match kind.trim_end_matches(".m3u8") { + "no-transform" => ( + StatusCode::OK, + "public, max-age=60, No-TrAnSfOrM, stale-if-error=30", + ), + "partial" => (StatusCode::PARTIAL_CONTENT, "public, max-age=60"), + "missing" => (StatusCode::NOT_FOUND, "public, max-age=60"), + "multiple" => (StatusCode::MULTIPLE_CHOICES, "public, max-age=60"), + "head" => (StatusCode::OK, "public, max-age=60"), + _ => unreachable!(), + }; + let body = if method == Method::HEAD { + Body::empty() + } else { + Body::from("#EXTM3U\nsegment.ts\n") + }; + Response::builder() + .status(status) + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .header(header::CONTENT_LENGTH, "19") + .header(header::CONTENT_RANGE, "bytes 0-18/19") + .header(header::CONTENT_ENCODING, "identity") + .header(header::ETAG, "\"source-validator\"") + .header(header::LAST_MODIFIED, "Wed, 19 Aug 2026 12:00:00 GMT") + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CACHE_CONTROL, cache_control) + .body(body) + .unwrap() + } + + let (address, fixture) = fixture(Router::new().route("/{kind}", any(representation))).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + + for (kind, method, status) in [ + ("no-transform", Method::GET, StatusCode::OK), + ("partial", Method::GET, StatusCode::PARTIAL_CONTENT), + ("missing", Method::GET, StatusCode::NOT_FOUND), + ("multiple", Method::GET, StatusCode::MULTIPLE_CHOICES), + ("head", Method::HEAD, StatusCode::OK), + ] { + let target = format!("http://media.test:{}/{kind}.m3u8", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + let response = handle_proxy(&runtime, uri, HeaderMap::new(), method).await; + assert_eq!(response.status(), status, "{kind}"); + assert_eq!(response.headers()[header::CONTENT_LENGTH], "19", "{kind}"); + assert_eq!( + response.headers()[header::CONTENT_RANGE], + "bytes 0-18/19", + "{kind}" + ); + assert_eq!( + response.headers()[header::CONTENT_ENCODING], + "identity", + "{kind}" + ); + assert_eq!( + response.headers()[header::ETAG], + "\"source-validator\"", + "{kind}" + ); + assert_eq!( + response.headers()[header::LAST_MODIFIED], + "Wed, 19 Aug 2026 12:00:00 GMT", + "{kind}" + ); + assert_eq!(response.headers()[header::ACCEPT_RANGES], "bytes", "{kind}"); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + if kind == "no-transform" { + "public, max-age=60, No-TrAnSfOrM, stale-if-error=30" + } else { + "public, max-age=60" + }, + "{kind}" + ); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + if kind == "head" { + assert!(body.is_empty()); + } else { + assert_eq!(body, "#EXTM3U\nsegment.ts\n", "{kind}"); + } + } + fixture.abort(); + } + + #[tokio::test] + async fn effective_content_type_controls_rewriting_and_transformed_metadata_is_final() { + let router = Router::new() + .route( + "/opaque.bin", + get(|| async { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CONTENT_LENGTH, "11") + .header(header::CONTENT_RANGE, "bytes 0-10/11") + .header(header::CONTENT_ENCODING, "identity") + .header(header::ETAG, "\"opaque-etag\"") + .header(header::LAST_MODIFIED, "Wed, 19 Aug 2026 12:00:00 GMT") + .header(header::ACCEPT_RANGES, "bytes") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(Body::from("segment.ts\n")) + .unwrap() + }), + ) + .route( + "/manifest.bin", + get(|| async { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .header(header::ETAG, "\"manifest-etag\"") + .body(Body::from("segment.ts\n")) + .unwrap() + }), + ); + let (address, fixture) = fixture(router).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + + let opaque = format!("http://media.test:{}/opaque.bin", address.port()); + let uri: Uri = format!( + "/proxy/?d={}&r=Content-Type%3Aapplication%2Fvnd.apple.mpegurl", + urlencoding::encode(&opaque) + ) + .parse() + .unwrap(); + let transformed = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!( + transformed.headers()[header::CONTENT_TYPE], + "application/vnd.apple.mpegurl" + ); + for removed in [ + header::CONTENT_LENGTH, + header::CONTENT_RANGE, + header::CONTENT_ENCODING, + header::ETAG, + header::LAST_MODIFIED, + ] { + assert!(!transformed.headers().contains_key(removed)); + } + assert_eq!(transformed.headers()[header::ACCEPT_RANGES], "none"); + assert_eq!( + transformed.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + let transformed_body = axum::body::to_bytes(transformed.into_body(), usize::MAX) + .await + .unwrap(); + assert!(String::from_utf8_lossy(&transformed_body).contains("/proxy/?d=")); + + let manifest = format!("http://media.test:{}/manifest.bin", address.port()); + let uri: Uri = format!( + "/proxy/?d={}&r=Content-Type%3Atext%2Fplain", + urlencoding::encode(&manifest) + ) + .parse() + .unwrap(); + let raw = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(raw.headers()[header::CONTENT_TYPE], "text/plain"); + assert_eq!(raw.headers()[header::ETAG], "\"manifest-etag\""); + assert_eq!( + axum::body::to_bytes(raw.into_body(), usize::MAX) + .await + .unwrap(), + "segment.ts\n" + ); + fixture.abort(); + } + + #[tokio::test] + async fn request_credentials_force_no_store_even_after_redirect_clearing() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let redirect_location = format!("http://other.test:{}/asset.bin", address.port()); + let router = Router::new() + .route( + "/redirect", + get(move || { + let redirect_location = redirect_location.clone(); + async move { + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, redirect_location)], + ) + } + }), + ) + .route( + "/asset.bin", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(Body::from("asset")) + .unwrap() + }), + ); + let fixture = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + + for query in [ + format!( + "d={}", + urlencoding::encode(&format!( + "http://user:secret@cache.test:{}/asset.bin", + address.port() + )) + ), + format!( + "d={}&h=X-Api-Key%3Asecret", + urlencoding::encode(&format!("http://cache.test:{}/asset.bin", address.port())) + ), + format!( + "d={}&h=X-Api-Key%3Asecret", + urlencoding::encode(&format!("http://redirect.test:{}/redirect", address.port())) + ), + ] { + let uri: Uri = format!("/proxy/?{query}").parse().unwrap(); + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + } + fixture.abort(); + } + + #[tokio::test] + async fn credential_bearing_upstream_and_rewrite_errors_are_no_store() { + let (unreachable_runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let uri: Uri = "/proxy/?d=http%3A%2F%2Funreachable.test%3A1%2Fasset&h=X-Api-Key%3Asecret" + .parse() + .unwrap(); + let upstream_error = + handle_proxy(&unreachable_runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(upstream_error.status(), StatusCode::BAD_GATEWAY); + assert_eq!( + upstream_error.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert_response_isolated(&upstream_error); + + let (address, fixture) = fixture(Router::new().route( + "/invalid.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from(bytes::Bytes::from_static(b"#EXTM3U\n\xff\n"))) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://playlist.test:{}/invalid.m3u8", address.port()); + let uri: Uri = format!( + "/proxy/?d={}&h=X-Api-Key%3Asecret", + urlencoding::encode(&target) + ) + .parse() + .unwrap(); + let rewrite_error = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(rewrite_error.status(), StatusCode::BAD_GATEWAY); + assert_eq!( + rewrite_error.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert_response_isolated(&rewrite_error); + fixture.abort(); + } + + #[tokio::test] + async fn playlist_content_encoding_requires_only_identity_in_every_field_and_coding() { + async fn encoded(Path(kind): Path) -> Response { + let mut response = Response::new(Body::from("#EXTM3U\nsegment.ts\n")); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/vnd.apple.mpegurl"), + ); + match kind.trim_end_matches(".m3u8") { + "separate-gzip" => { + response.headers_mut().append( + header::CONTENT_ENCODING, + HeaderValue::from_static("identity"), + ); + response + .headers_mut() + .append(header::CONTENT_ENCODING, HeaderValue::from_static("gzip")); + } + "mixed-list" => { + response.headers_mut().insert( + header::CONTENT_ENCODING, + HeaderValue::from_static("identity, gzip"), + ); + } + "repeated-identity" => { + response.headers_mut().append( + header::CONTENT_ENCODING, + HeaderValue::from_static("identity"), + ); + response.headers_mut().append( + header::CONTENT_ENCODING, + HeaderValue::from_static("IDENTITY"), + ); + } + "identity-list" => { + response.headers_mut().insert( + header::CONTENT_ENCODING, + HeaderValue::from_static(" identity ,\tIDENTITY "), + ); + } + "non-ascii" => { + response.headers_mut().insert( + header::CONTENT_ENCODING, + HeaderValue::from_bytes(b"identity, \x80").unwrap(), + ); + } + _ => unreachable!(), + } + response + } + + let (address, fixture) = fixture(Router::new().route("/{kind}", get(encoded))).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + for (kind, expected) in [ + ("separate-gzip", StatusCode::BAD_GATEWAY), + ("mixed-list", StatusCode::BAD_GATEWAY), + ("repeated-identity", StatusCode::OK), + ("identity-list", StatusCode::OK), + ("non-ascii", StatusCode::BAD_GATEWAY), + ] { + let target = format!("http://encoding.test:{}/{kind}.m3u8", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), expected, "{kind}"); + if expected == StatusCode::OK { + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + String::from_utf8_lossy(&body).contains("/proxy/?d="), + "{kind}" + ); + } else { + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store", + "{kind}" + ); + } + } + fixture.abort(); + } + + #[tokio::test] + async fn playlist_semantic_headers_are_byte_safe_and_quote_aware() { + async fn semantic_headers(Path(kind): Path) -> Response { + let mut response = Response::new(Body::from("#EXTM3U\nsegment.ts\n")); + response + .headers_mut() + .insert(header::ETAG, HeaderValue::from_static("\"source\"")); + match kind.trim_end_matches(".bin") { + "actual-no-transform" => { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/vnd.apple.mpegurl"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_bytes(b"extension=\"\x80\", no-transform").unwrap(), + ); + } + "quoted-no-transform" => { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/vnd.apple.mpegurl"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("extension=\"no-transform\""), + ); + } + "invalid-cache-control" => { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/vnd.apple.mpegurl"), + ); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("extension=\"unterminated"), + ); + } + "obs-content-type" => { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_bytes( + b"application/vnd.apple.mpegurl; extension=\"\x80\"", + ) + .unwrap(), + ); + } + "invalid-content-type" => { + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application /vnd.apple.mpegurl"), + ); + } + _ => unreachable!(), + } + response + } + + let (address, fixture) = + fixture(Router::new().route("/{kind}", get(semantic_headers))).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + for (kind, rewritten) in [ + ("actual-no-transform", false), + ("quoted-no-transform", true), + ("invalid-cache-control", false), + ("obs-content-type", true), + ("invalid-content-type", false), + ] { + let target = format!("http://semantic.test:{}/{kind}.bin", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::OK, "{kind}"); + if rewritten { + assert!(!response.headers().contains_key(header::ETAG), "{kind}"); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + String::from_utf8_lossy(&body).contains("/proxy/?d="), + "{kind}" + ); + } else { + assert_eq!(response.headers()[header::ETAG], "\"source\"", "{kind}"); + assert_eq!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + "#EXTM3U\nsegment.ts\n", + "{kind}" + ); + } + } + fixture.abort(); + } + #[tokio::test] async fn buffered_playlist_body_retains_capacity_and_observes_cancellation() { let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); From 2280e58f427d8a050b321cecbb18983eceed297a Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:41:40 -0400 Subject: [PATCH 16/25] security: preserve safe HLS proxy options --- server/src/routes/proxy.rs | 1668 ++++++++++++++++++++++++++++++++++-- 1 file changed, 1613 insertions(+), 55 deletions(-) diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 298a40f..41b3e6d 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -13,15 +13,21 @@ use axum::{ use bytes::Bytes; use futures_util::{Stream, StreamExt}; use reqwest::{Client, Method}; -use std::{pin::Pin, time::Duration}; +use std::{ + collections::{HashMap, HashSet}, + pin::Pin, + time::Duration, +}; use tokio::sync::OwnedSemaphorePermit; use tokio_util::sync::CancellationToken; -use url::Url; +use url::{Position, Url}; const MAX_PROXY_INPUT: usize = 64 * 1024; const MAX_TARGET_URL: usize = 16 * 1024; const MAX_CUSTOM_OPTIONS: usize = 64; const MAX_HEADER_PAIR: usize = 8 * 1024; +const RAW_CANONICAL_PATH_KEY: &str = "x-stream-path"; +const RAW_CANONICAL_PATH_OPTION: &str = "&x-stream-path=raw"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ProxyError { @@ -108,6 +114,7 @@ fn parse_proxy_suffix(raw_suffix: &str) -> Result Result { + if value != "raw" || raw_canonical_path { + return Err(ProxyError::InvalidRequest); + } + raw_canonical_path = true; + } _ => {} } } + if raw_canonical_path && path_tail.is_none_or(str::is_empty) { + return Err(ProxyError::InvalidRequest); + } + let target = target.ok_or(ProxyError::InvalidRequest)?; let mut target = Url::parse(&target).map_err(|_| ProxyError::InvalidRequest)?; if let Some(path_tail) = path_tail { - let path_tail = strict_percent_decode(path_tail, false)?; - target.set_path(if path_tail.is_empty() { - "/" + let decoded_path; + let path_tail = if raw_canonical_path { + validate_percent_encoding(path_tail)?; + if let Some(upstream_query) = upstream_query { + validate_percent_encoding(upstream_query)?; + } + path_tail } else { - &path_tail - }); + decoded_path = strict_percent_decode(path_tail, false)?; + &decoded_path + }; + target.set_path(if path_tail.is_empty() { "/" } else { path_tail }); target.set_query(upstream_query); } let target = validate_proxy_target(target)?; @@ -162,6 +185,28 @@ fn parse_proxy_suffix(raw_suffix: &str) -> Result Result<(), ProxyError> { + let bytes = value.as_bytes(); + let mut index = 0usize; + while index < bytes.len() { + if bytes[index] == b'%' { + if bytes + .get(index + 1) + .is_none_or(|byte| !byte.is_ascii_hexdigit()) + || bytes + .get(index + 2) + .is_none_or(|byte| !byte.is_ascii_hexdigit()) + { + return Err(ProxyError::InvalidRequest); + } + index += 3; + } else { + index += 1; + } + } + Ok(()) +} + fn strict_percent_decode(value: &str, plus_as_space: bool) -> Result { fn hex_value(byte: u8) -> Option { match byte { @@ -456,7 +501,7 @@ async fn handle_proxy_suffix( let FetchedProxyResponse { response: upstream, final_url, - effective_custom_request_headers: _effective_custom_request_headers, + effective_custom_request_headers, effective_response_headers, } = fetched; let status = upstream.status(); @@ -483,8 +528,14 @@ async fn handle_proxy_suffix( }; let body = match String::from_utf8(body) .map_err(|_| ProxyError::Upstream) - .and_then(|body| rewrite_playlist_bounded(&body, &final_url)) - { + .and_then(|body| { + rewrite_playlist_with_options( + &body, + &final_url, + &effective_custom_request_headers, + &effective_response_headers, + ) + }) { Ok(body) => body, Err(error) => return proxy_error_response(error), }; @@ -972,8 +1023,21 @@ fn proxy_error_response(error: ProxyError) -> Response { const MAX_PLAYLIST_OUTPUT: usize = 16 * 1024 * 1024; +#[cfg(test)] fn rewrite_playlist_bounded(body: &str, base_url: &Url) -> Result { - let mut output = String::with_capacity(body.len().min(MAX_PLAYLIST_OUTPUT)); + rewrite_playlist_with_options(body, base_url, &HeaderMap::new(), &HeaderMap::new()) +} + +fn rewrite_playlist_with_options( + body: &str, + base_url: &Url, + request_headers: &HeaderMap, + response_headers: &HeaderMap, +) -> Result { + let mut output = String::new(); + output + .try_reserve_exact(body.len().min(MAX_PLAYLIST_OUTPUT)) + .map_err(|_| ProxyError::Upstream)?; for line_with_ending in body.split_inclusive('\n') { let (line, ending) = if let Some(line) = line_with_ending.strip_suffix("\r\n") { (line, "\r\n") @@ -983,14 +1047,24 @@ fn rewrite_playlist_bounded(body: &str, base_url: &Url) -> Result Result Result<(), ProxyError> { - let mut remaining = line; - while let Some(start) = remaining.find("URI=\"") { - let value_start = start + 5; - push_playlist(output, &remaining[..value_start])?; - let after_start = &remaining[value_start..]; - let Some(end) = after_start.find('"') else { - push_playlist(output, after_start)?; - return Ok(()); +fn rewrite_playlist_tag( + line: &str, + base_url: &Url, + request_headers: &HeaderMap, + response_headers: &HeaderMap, + output: &mut String, +) -> Result<(), ProxyError> { + let Some(colon) = line.find(':') else { + return push_playlist(output, line); + }; + let bytes = line.as_bytes(); + let mut attribute_start = colon + 1; + let mut copied = 0usize; + while attribute_start < line.len() { + let mut key_start = attribute_start; + while bytes + .get(key_start) + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + key_start += 1; + } + let mut scan_start = attribute_start; + if line[key_start..].starts_with("URI=\"") { + let value_start = key_start + 5; + let Some(value_end) = line[value_start..] + .find('"') + .map(|offset| value_start + offset) + else { + break; + }; + push_playlist(output, &line[copied..value_start])?; + let value = &line[value_start..value_end]; + rewrite_playlist_reference(value, base_url, request_headers, response_headers, output)?; + copied = value_end; + scan_start = value_end + 1; + } + + let mut quoted = false; + let mut next_attribute = None; + for (offset, byte) in bytes[scan_start..].iter().copied().enumerate() { + match byte { + b'"' => quoted = !quoted, + b',' if !quoted => { + next_attribute = Some(scan_start + offset + 1); + break; + } + _ => {} + } + } + let Some(next_attribute) = next_attribute else { + break; + }; + attribute_start = next_attribute; + } + push_playlist(output, &line[copied..]) +} + +struct HlsVariableReplacement { + token: String, + placeholder: String, + removed_with_fragment: bool, +} + +struct HlsVariablePath { + base_target: String, + path: String, + query: Option, +} + +struct ResolvedHlsReference { + target: String, + same_origin: bool, + variable_path: Option, +} + +fn rewrite_playlist_reference( + reference: &str, + base_url: &Url, + request_headers: &HeaderMap, + response_headers: &HeaderMap, + output: &mut String, +) -> Result<(), ProxyError> { + let Some(resolved) = resolve_hls_reference(reference, base_url)? else { + return push_playlist(output, reference); + }; + push_proxy_uri(output, &resolved, request_headers, response_headers) +} + +fn resolve_hls_reference( + reference: &str, + base_url: &Url, +) -> Result, ProxyError> { + let scheme_colon = reference + .bytes() + .take(MAX_TARGET_URL + 1) + .inspect(|_| { + #[cfg(test)] + HLS_SCHEME_PRESCAN_BYTES.with(|scans| scans.set(scans.get() + 1)); + }) + .position(|byte| matches!(byte, b':' | b'/' | b'?' | b'#')) + .filter(|index| reference.as_bytes()[*index] == b':'); + if let Some(colon) = scheme_colon { + let scheme = &reference[..colon]; + if valid_url_scheme(scheme) + && !scheme.eq_ignore_ascii_case("http") + && !scheme.eq_ignore_ascii_case("https") + { + return Ok(None); + } + } + if reference.len() > MAX_TARGET_URL { + return Err(ProxyError::Upstream); + } + let variables = hls_variable_ranges(reference); + if scheme_colon.is_some_and(|colon| variables.iter().any(|(start, _)| *start < colon)) { + return Ok(None); + } + + let authority = authority_range(reference, scheme_colon); + if authority.is_some_and(|(authority_start, authority_end)| { + variables + .iter() + .any(|(start, end)| *start < authority_end && *end > authority_start) + }) { + return Ok(None); + } + + let mut placeholder_generator = HlsPlaceholderGenerator::new(reference, base_url.as_str()); + let (substituted, mut replacements) = + substitute_hls_variables(reference, &variables, &mut placeholder_generator)?; + let Some(mut absolute) = resolve_substituted_hls_reference(base_url, &substituted)? else { + return Ok(None); + }; + if !hls_placeholders_are_safe(&absolute, &replacements) { + placeholder_generator.occupy(absolute.as_str()); + let (retry, retry_replacements) = + substitute_hls_variables(reference, &variables, &mut placeholder_generator)?; + let Some(retry_absolute) = resolve_substituted_hls_reference(base_url, &retry)? else { + return Ok(None); + }; + if !hls_placeholders_are_safe(&retry_absolute, &retry_replacements) { + return Ok(None); + } + absolute = retry_absolute; + replacements = retry_replacements; + } + let same_origin = same_origin(base_url, &absolute); + let target = restore_hls_variables(absolute.as_str(), &replacements)?; + let variable_path = if replacements + .iter() + .any(|replacement| !replacement.removed_with_fragment) + { + let path = restore_hls_variables(absolute.path(), &replacements)?; + let query = absolute + .query() + .map(|query| restore_hls_variables(query, &replacements)) + .transpose()?; + if validate_percent_encoding(&path).is_err() + || query + .as_deref() + .is_some_and(|query| validate_percent_encoding(query).is_err()) + { + return Ok(None); + } + absolute.set_path("/"); + absolute.set_query(None); + Some(HlsVariablePath { + base_target: absolute.into(), + path, + query, + }) + } else { + None + }; + Ok(Some(ResolvedHlsReference { + target, + same_origin, + variable_path, + })) +} + +fn substitute_hls_variables( + reference: &str, + variables: &[(usize, usize)], + placeholder_generator: &mut HlsPlaceholderGenerator, +) -> Result<(String, Vec), ProxyError> { + let fragment = reference.find('#'); + let mut replacements = Vec::with_capacity(variables.len()); + let mut substituted = String::new(); + substituted + .try_reserve_exact(reference.len()) + .map_err(|_| ProxyError::Upstream)?; + let mut copied = 0usize; + for (start, end) in variables.iter().copied() { + push_target(&mut substituted, &reference[copied..start])?; + let placeholder = placeholder_generator.next().ok_or(ProxyError::Upstream)?; + push_target(&mut substituted, &placeholder)?; + replacements.push(HlsVariableReplacement { + token: reference[start..end].to_owned(), + placeholder, + removed_with_fragment: fragment.is_some_and(|fragment| start > fragment), + }); + copied = end; + } + push_target(&mut substituted, &reference[copied..])?; + Ok((substituted, replacements)) +} + +fn resolve_substituted_hls_reference( + base_url: &Url, + substituted: &str, +) -> Result, ProxyError> { + let absolute = match base_url.join(substituted) { + Ok(absolute) => absolute, + Err(_) => return Ok(None), + }; + if !matches!(absolute.scheme(), "http" | "https") || absolute.host().is_none() { + return Ok(None); + } + validate_proxy_target(absolute) + .map(Some) + .map_err(|_| ProxyError::Upstream) +} + +fn hls_placeholders_are_safe(canonical: &Url, replacements: &[HlsVariableReplacement]) -> bool { + let mut placeholder_indices = HashMap::with_capacity(replacements.len()); + for (index, replacement) in replacements.iter().enumerate() { + let Ok(placeholder) = <[u8; 4]>::try_from(replacement.placeholder.as_bytes()) else { + return false; + }; + if placeholder_indices.insert(placeholder, index).is_some() { + return false; + } + } + let serialized = canonical.as_str(); + let path_query = &canonical[Position::BeforePath..Position::AfterQuery]; + let path_query_start = serialized.len() - canonical[Position::BeforePath..].len(); + let path_query_end = path_query_start + path_query.len(); + let mut occurrences = vec![(0usize, 0usize); replacements.len()]; + for (start, window) in serialized.as_bytes().windows(4).enumerate() { + if let Some(index) = <[u8; 4]>::try_from(window) + .ok() + .and_then(|placeholder| placeholder_indices.get(&placeholder)) + { + occurrences[*index].0 = occurrences[*index].0.saturating_add(1); + if start >= path_query_start && start + window.len() <= path_query_end { + occurrences[*index].1 = occurrences[*index].1.saturating_add(1); + } + } + } + replacements + .iter() + .zip(occurrences) + .all(|(replacement, (total, in_path_query))| { + if replacement.removed_with_fragment { + total == 0 + } else { + total == 1 && in_path_query == 1 + } + }) +} + +fn valid_url_scheme(value: &str) -> bool { + let mut bytes = value.bytes(); + bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic()) + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) +} + +fn authority_range(reference: &str, scheme_colon: Option) -> Option<(usize, usize)> { + let start = if reference.starts_with("//") { + 2 + } else { + let colon = scheme_colon?; + reference + .get(colon + 1..)? + .starts_with("//") + .then_some(colon + 3)? + }; + let end = reference[start..] + .bytes() + .position(|byte| matches!(byte, b'/' | b'?' | b'#')) + .map_or(reference.len(), |offset| start + offset); + Some((start, end)) +} + +#[cfg(test)] +thread_local! { + static HLS_VARIABLE_RANGE_SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; + static HLS_SCHEME_PRESCAN_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +fn hls_variable_ranges(value: &str) -> Vec<(usize, usize)> { + #[cfg(test)] + HLS_VARIABLE_RANGE_SCANS.with(|scans| scans.set(scans.get() + 1)); + + let bytes = value.as_bytes(); + let mut variables = Vec::new(); + let mut index = 0usize; + while index + 3 < bytes.len() { + if bytes[index] != b'{' || bytes[index + 1] != b'$' { + index += 1; + continue; + } + let name_start = index + 2; + let mut end = name_start; + while end < bytes.len() + && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'-')) + { + end += 1; + } + if end > name_start && bytes.get(end) == Some(&b'}') { + variables.push((index, end + 1)); + index = end + 1; + } else { + index += 1; + } + } + variables +} + +struct HlsPlaceholderGenerator { + occupied: HashSet<[u8; 4]>, + next: usize, +} + +impl HlsPlaceholderGenerator { + const DIGITS: &[u8; 36] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + + fn new(reference: &str, base: &str) -> Self { + let mut generator = Self { + occupied: HashSet::new(), + next: 0, }; - let value = &after_start[..end]; - if let Ok(absolute) = base_url.join(value) { - push_proxy_uri(output, &absolute)?; + generator.occupy(reference); + generator.occupy(base); + generator + } + + fn occupy(&mut self, value: &str) { + for window in value.as_bytes().windows(4) { + if window[0].eq_ignore_ascii_case(&b'x') { + self.occupied.insert([ + window[0].to_ascii_lowercase(), + window[1].to_ascii_lowercase(), + window[2].to_ascii_lowercase(), + window[3].to_ascii_lowercase(), + ]); + } + } + } + + fn next(&mut self) -> Option { + while self.next < 36usize.pow(3) { + let number = self.next; + self.next += 1; + let candidate = [ + b'x', + Self::DIGITS[(number / (36 * 36)) % 36], + Self::DIGITS[(number / 36) % 36], + Self::DIGITS[number % 36], + ]; + if self.occupied.insert(candidate) { + return std::str::from_utf8(&candidate).ok().map(str::to_owned); + } + } + None + } +} + +fn restore_hls_variables( + canonical: &str, + replacements: &[HlsVariableReplacement], +) -> Result { + let ordinary = replacements + .iter() + .map(|replacement| (replacement.placeholder.as_str(), replacement.token.as_str())) + .collect::>(); + let mut restored = String::new(); + restored + .try_reserve_exact(canonical.len()) + .map_err(|_| ProxyError::Upstream)?; + let mut index = 0; + while index < canonical.len() { + let replacement = canonical + .get(index..index.saturating_add(4)) + .and_then(|candidate| ordinary.get(candidate).copied()); + if let Some(token) = replacement { + push_target(&mut restored, token)?; + index += 4; } else { - push_playlist(output, value)?; + let character = canonical[index..] + .chars() + .next() + .ok_or(ProxyError::Upstream)?; + let end = index + character.len_utf8(); + push_target(&mut restored, &canonical[index..end])?; + index = end; } - remaining = &after_start[end..]; } - push_playlist(output, remaining) + Ok(restored) +} + +fn push_target(output: &mut String, value: &str) -> Result<(), ProxyError> { + reserve_bounded(output, value.len(), MAX_TARGET_URL)?; + output.push_str(value); + Ok(()) } -fn push_proxy_uri(output: &mut String, absolute: &Url) -> Result<(), ProxyError> { - const PREFIX: &str = "/proxy/?d="; - let encoded = urlencoding::encode(absolute.as_str()); - let required = encoded +fn push_proxy_uri( + output: &mut String, + resolved: &ResolvedHlsReference, + request_headers: &HeaderMap, + response_headers: &HeaderMap, +) -> Result<(), ProxyError> { + const ROUTE_PREFIX: &str = "/proxy"; + let (suffix_prefix, target, path) = if let Some(variable_path) = &resolved.variable_path { + ( + "/d=", + variable_path.base_target.as_str(), + Some(variable_path), + ) + } else { + ("/?d=", resolved.target.as_str(), None) + }; + let target_length = percent_encoded_length(target)?; + let mut suffix_length = suffix_prefix .len() - .checked_add(PREFIX.len()) + .checked_add(target_length) .ok_or(ProxyError::Upstream)?; - let remaining = MAX_PLAYLIST_OUTPUT.saturating_sub(output.len()); - if required > remaining { + let mut options = Vec::new(); + if resolved.same_origin { + for (kind, headers) in [('h', request_headers), ('r', response_headers)] { + for (name, value) in headers { + if (kind == 'h' && request_header_forbidden(name)) + || (kind == 'r' && response_header_forbidden(name)) + { + continue; + } + let value = + std::str::from_utf8(value.as_bytes()).map_err(|_| ProxyError::Upstream)?; + let pair_length = name + .as_str() + .len() + .checked_add(value.len()) + .and_then(|length| length.checked_add(1)) + .ok_or(ProxyError::Upstream)?; + if pair_length > MAX_HEADER_PAIR { + return Err(ProxyError::Upstream); + } + let name_length = percent_encoded_length(name.as_str())?; + let value_length = percent_encoded_length(value)?; + suffix_length = suffix_length + .checked_add(3) + .and_then(|length| length.checked_add(name_length)) + .and_then(|length| length.checked_add(3)) + .and_then(|length| length.checked_add(value_length)) + .ok_or(ProxyError::Upstream)?; + options.push((kind, name.as_str(), value)); + } + } + if options.len() > MAX_CUSTOM_OPTIONS { + return Err(ProxyError::Upstream); + } + options.sort_unstable_by(|left, right| { + (left.0, left.1, left.2.as_bytes()).cmp(&(right.0, right.1, right.2.as_bytes())) + }); + } + if let Some(variable_path) = path { + validate_percent_encoding(&variable_path.path).map_err(|_| ProxyError::Upstream)?; + if let Some(query) = &variable_path.query { + validate_percent_encoding(query).map_err(|_| ProxyError::Upstream)?; + } + suffix_length = suffix_length + .checked_add(RAW_CANONICAL_PATH_OPTION.len()) + .and_then(|length| length.checked_add(1)) + .and_then(|length| length.checked_add(variable_path.path.len())) + .and_then(|length| { + variable_path.query.as_ref().map_or(Some(length), |query| { + length + .checked_add(1) + .and_then(|length| length.checked_add(query.len())) + }) + }) + .ok_or(ProxyError::Upstream)?; + } + if suffix_length > MAX_PROXY_INPUT { return Err(ProxyError::Upstream); } - push_playlist(output, PREFIX)?; - push_playlist(output, &encoded) + let required = ROUTE_PREFIX + .len() + .checked_add(suffix_length) + .ok_or(ProxyError::Upstream)?; + reserve_playlist(output, required)?; + output.push_str(ROUTE_PREFIX); + output.push_str(suffix_prefix); + append_percent_encoded(output, target); + for (kind, name, value) in options { + output.push('&'); + output.push(kind); + output.push('='); + append_percent_encoded(output, name); + output.push_str("%3A"); + append_percent_encoded(output, value); + } + if let Some(variable_path) = path { + output.push_str(RAW_CANONICAL_PATH_OPTION); + output.push('/'); + output.push_str(&variable_path.path); + if let Some(query) = &variable_path.query { + output.push('?'); + output.push_str(query); + } + } + Ok(()) +} + +fn percent_encoded_length(value: &str) -> Result { + let mut length = 0usize; + for byte in value.bytes() { + length = length + .checked_add(if percent_encoding_safe(byte) { 1 } else { 3 }) + .ok_or(ProxyError::Upstream)?; + } + Ok(length) +} + +fn append_percent_encoded(output: &mut String, value: &str) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in value.bytes() { + if percent_encoding_safe(byte) { + output.push(char::from(byte)); + } else { + let encoded = [b'%', HEX[(byte >> 4) as usize], HEX[(byte & 0x0f) as usize]]; + output.push_str(std::str::from_utf8(&encoded).unwrap()); + } + } +} + +fn percent_encoding_safe(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') } fn push_playlist(output: &mut String, value: &str) -> Result<(), ProxyError> { + reserve_bounded(output, value.len(), MAX_PLAYLIST_OUTPUT)?; + output.push_str(value); + Ok(()) +} + +fn reserve_playlist(output: &mut String, additional: usize) -> Result<(), ProxyError> { + reserve_bounded(output, additional, MAX_PLAYLIST_OUTPUT) +} + +fn reserve_bounded( + output: &mut String, + additional: usize, + maximum: usize, +) -> Result<(), ProxyError> { let next_length = output .len() - .checked_add(value.len()) + .checked_add(additional) .ok_or(ProxyError::Upstream)?; - if next_length > MAX_PLAYLIST_OUTPUT { + if next_length > maximum { return Err(ProxyError::Upstream); } - output.push_str(value); + if next_length > output.capacity() { + let desired_capacity = output + .capacity() + .max(1) + .saturating_mul(2) + .max(next_length) + .min(maximum); + output + .try_reserve_exact(desired_capacity - output.len()) + .map_err(|_| ProxyError::Upstream)?; + } Ok(()) } #[cfg(test)] mod tests { use super::{ - MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, ProxyError, + HLS_SCHEME_PRESCAN_BYTES, HLS_VARIABLE_RANGE_SCANS, MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, + MAX_PLAYLIST_OUTPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, ProxyError, apply_redirect_origin_policy, buffered_proxy_body, collect_playlist, fetch_with_redirects, handle_proxy, handle_proxy_suffix, parse_proxy_request, parse_proxy_suffix, - proxy_error_response, rewrite_playlist_bounded, same_origin, streaming_proxy_body, + proxy_error_response, resolve_hls_reference, rewrite_playlist_bounded, + rewrite_playlist_with_options, same_origin, streaming_proxy_body, }; use crate::network_security::{ Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, @@ -1202,6 +1808,84 @@ mod tests { ); } + #[test] + fn parse_raw_canonical_path_mode_preserves_escapes_and_rejects_invalid_uses() { + let parsed = parse_proxy_request( + "d=https%3A%2F%2Fexample.com&x-stream-path=raw//media/a%2Fb%25%41%5C%FF", + Some("token=%FF"), + ) + .unwrap(); + assert_eq!( + parsed.target.as_str(), + "https://example.com/media/a%2Fb%25%41%5C%FF?token=%FF" + ); + + for (rest, query) in [ + ( + "d=https%3A%2F%2Fexample.com&x-stream-path=raw&x-stream-path=raw//media", + None, + ), + ( + "d=https%3A%2F%2Fexample.com&x-stream-path=raw&x-stream-%70ath=raw//media", + None, + ), + ( + "d=https%3A%2F%2Fexample.com&x-stream-path=legacy//media", + None, + ), + ("d=https%3A%2F%2Fexample.com&x-stream-path=raw", None), + ("d=https%3A%2F%2Fexample.com&x-stream-path=raw/", None), + ( + "d=https%3A%2F%2Fexample.com&x-stream-path=raw/", + Some("token=value"), + ), + ("d=https%3A%2F%2Fexample.com&x-stream-path=raw//bad%", None), + ] { + assert!( + matches!( + parse_proxy_request(rest, query), + Err(ProxyError::InvalidRequest) + ), + "rest={rest:?}, query={query:?}" + ); + } + assert!(matches!( + parse_proxy_request("", Some("d=https%3A%2F%2Fexample.com&x-stream-path=raw")), + Err(ProxyError::InvalidRequest) + )); + + let unknown = parse_proxy_request( + "d=https%3A%2F%2Fexample.com&x-unknown=raw/media%2Fpart", + None, + ) + .unwrap(); + assert_eq!(unknown.target.as_str(), "https://example.com/media/part"); + } + + #[test] + fn legacy_path_query_preserves_malformed_percent_text_but_raw_mode_rejects_it() { + for (query, expected) in [ + ("token=%", "https://example.com/media?token=%"), + ("token=%0", "https://example.com/media?token=%0"), + ("token=%GG", "https://example.com/media?token=%GG"), + ] { + let legacy = parse_proxy_request("d=https%3A%2F%2Fexample.com/media", Some(query)) + .unwrap_or_else(|error| panic!("legacy query {query:?} failed: {error:?}")); + assert_eq!(legacy.target.as_str(), expected, "query={query:?}"); + + assert!( + matches!( + parse_proxy_request( + "d=https%3A%2F%2Fexample.com&x-stream-path=raw//media", + Some(query), + ), + Err(ProxyError::InvalidRequest) + ), + "raw query {query:?}" + ); + } + } + #[test] fn parse_rejects_malformed_percent_encoding_and_utf8() { for invalid in ["%", "%0", "%GG", "%FF"] { @@ -2961,18 +3645,892 @@ mod tests { } #[test] - fn playlist_rewriter_handles_plain_and_every_quoted_uri() { - let base = url::Url::parse("https://media.example/path/master.m3u8").unwrap(); + fn playlist_rewriter_preserves_non_http_and_malformed_references() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); let body = concat!( "#EXTM3U\r\n", - "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio.m3u8\",X=1,URI=\"backup.m3u8\"\r\n", - "segment.ts?token=1\r\n" + "\r\n", + "# an unrelated comment\n", + "data:text/plain,segment\n", + "skd://license.example/key\r\n", + "urn:example:asset\n", + "http://[invalid\n", + "//[invalid\r\n", + "#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"data:text/plain,key\"\r\n", + "#EXT-X-MAP:URI=\"init.mp4\"\n", + "segment.ts\n", ); + let rewritten = rewrite_playlist_bounded(body, &base).unwrap(); - assert!(rewritten.starts_with("#EXTM3U\r\n")); - assert_eq!(rewritten.matches("/proxy/?d=").count(), 3); - assert!(rewritten.contains("https%3A%2F%2Fmedia.example%2Fpath%2Faudio.m3u8")); - assert!(rewritten.contains("https%3A%2F%2Fmedia.example%2Fpath%2Fsegment.ts%3Ftoken%3D1")); + + assert!(rewritten.starts_with(concat!( + "#EXTM3U\r\n", + "\r\n", + "# an unrelated comment\n", + "data:text/plain,segment\n", + "skd://license.example/key\r\n", + "urn:example:asset\n", + "http://[invalid\n", + "//[invalid\r\n", + "#EXT-X-KEY:METHOD=SAMPLE-AES,URI=\"data:text/plain,key\"\r\n", + ))); + assert_eq!(rewritten.matches("/proxy/?d=").count(), 2); + assert!(rewritten.ends_with('\n')); + } + + #[test] + fn playlist_rewriter_preserves_space_and_tab_only_lines_byte_for_byte() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + let body = " \r\n\t\n \t\r\n\t "; + + let rewritten = rewrite_playlist_bounded(body, &base).unwrap(); + + assert_eq!(rewritten, body); + } + + #[test] + fn playlist_rewriter_keeps_valid_variables_visible_in_lines_and_attributes() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + let body = concat!( + "segments/{$segment}/part.ts?token={$token}\r\n", + "#EXT-X-KEY:METHOD=AES-128,URI=\"keys/{$key}.bin?sig={$signature}\"\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"//{$host}/audio.m3u8\"\r\n", + ); + + let rewritten = rewrite_playlist_bounded(body, &base).unwrap(); + + assert_eq!(rewritten.matches("/proxy/d=").count(), 2); + for variable in ["{$segment}", "{$token}", "{$key}", "{$signature}"] { + assert!(rewritten.contains(variable), "missing {variable:?}"); + } + assert!( + rewritten.contains( + "&x-stream-path=raw//path/segments/{$segment}/part.ts?token={$token}\r\n" + ) + ); + assert!( + rewritten.contains("&x-stream-path=raw//path/keys/{$key}.bin?sig={$signature}\"\n") + ); + assert!(rewritten.ends_with("//{$host}/audio.m3u8\"\r\n")); + } + + #[test] + fn playlist_children_inherit_options_only_for_the_same_complete_origin() { + let base = Url::parse("https://MEDIA.example:443/path/master.m3u8").unwrap(); + let request_headers = HeaderMap::from_iter([ + ("x-z-last".parse().unwrap(), "z".parse().unwrap()), + ("x-a-first".parse().unwrap(), "a".parse().unwrap()), + ]); + let response_headers = HeaderMap::from_iter([( + header::CONTENT_TYPE, + "application/vnd.apple.mpegurl".parse().unwrap(), + )]); + let body = concat!( + "relative.ts\n", + "/root.ts\n", + "//media.example:443/protocol.ts\n", + "https://media.example/absolute.ts\n", + "http://media.example:443/different-scheme.ts\n", + "//other.example/cross-protocol.ts\n", + "https://other.example/cross-absolute.ts\n", + ); + + let rewritten = + rewrite_playlist_with_options(body, &base, &request_headers, &response_headers) + .unwrap(); + let links = rewritten.lines().collect::>(); + assert_eq!(links.len(), 7); + for (index, link) in links.iter().enumerate() { + let parsed = parse_proxy_suffix(link.strip_prefix("/proxy").unwrap()).unwrap(); + if index < 4 { + assert_eq!(parsed.request_headers["x-a-first"], "a", "{link}"); + assert_eq!(parsed.request_headers["x-z-last"], "z", "{link}"); + assert_eq!( + parsed.response_headers[header::CONTENT_TYPE], + "application/vnd.apple.mpegurl", + "{link}" + ); + assert!( + link.contains("&h=x-a-first%3Aa&h=x-z-last%3Az&r=content-type%3Aapplication%2Fvnd.apple.mpegurl"), + "non-deterministic options: {link}" + ); + } else { + assert!(parsed.request_headers.is_empty(), "{link}"); + assert!(parsed.response_headers.is_empty(), "{link}"); + } + } + } + + #[test] + fn playlist_variables_keep_path_options_but_make_origin_variables_indeterminate() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + let request_headers = + HeaderMap::from_iter([("x-token".parse().unwrap(), "secret".parse().unwrap())]); + let response_headers = HeaderMap::from_iter([( + header::CONTENT_TYPE, + "application/vnd.apple.mpegurl".parse().unwrap(), + )]); + let body = concat!( + "segments/{$segment}.ts?token={$token}\n", + "#EXT-X-KEY:URI=\"keys/{$key}.bin?sig={$signature}\"\n", + "//{$host}/audio.m3u8\n", + "{$scheme}://media.example/video.m3u8\n", + "https://{$user}@media.example/private.m3u8\n", + "https://media.example:{$port}/video.m3u8\n", + ); + + let rewritten = + rewrite_playlist_with_options(body, &base, &request_headers, &response_headers) + .unwrap(); + for unchanged in [ + "//{$host}/audio.m3u8", + "{$scheme}://media.example/video.m3u8", + "https://{$user}@media.example/private.m3u8", + "https://media.example:{$port}/video.m3u8", + ] { + assert!( + rewritten.contains(unchanged), + "rewritten playlist: {rewritten:?}" + ); + } + assert_eq!(rewritten.matches("/proxy/d=").count(), 2); + assert!(!rewritten.contains("/proxy/?d=")); + assert!(rewritten.contains( + "&h=x-token%3Asecret&r=content-type%3Aapplication%2Fvnd.apple.mpegurl&x-stream-path=raw//path/segments/{$segment}.ts?token={$token}" + )); + + let substituted = rewritten + .replace("{$segment}", "one") + .replace("{$token}", "two") + .replace("{$key}", "three") + .replace("{$signature}", "four"); + let links = substituted + .split(['\n', '"']) + .filter(|part| part.starts_with("/proxy")) + .collect::>(); + assert_eq!(links.len(), 2, "rewritten playlist: {rewritten:?}"); + for link in links { + let uri = link.parse::().unwrap(); + let raw = uri.path_and_query().unwrap().as_str(); + let parsed = parse_proxy_suffix(raw.strip_prefix("/proxy").unwrap()).unwrap(); + assert_eq!(parsed.request_headers["x-token"], "secret", "{link}"); + assert_eq!( + parsed.response_headers[header::CONTENT_TYPE], + "application/vnd.apple.mpegurl", + "{link}" + ); + } + } + + #[test] + fn special_url_authority_variables_remain_unchanged_after_canonicalization() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + for reference in [ + "https:///{$host}/video.m3u8", + "HTTPS:////{$host}/video.m3u8", + r"https:\\{$host}\video.m3u8", + r"https:/\{$host}/video.m3u8", + "https:///{$user}@media.example/private.m3u8", + "https:///user:{$password}@media.example/private.m3u8", + "https:///media.example:{$port}/video.m3u8", + ] { + let rewritten = rewrite_playlist_bounded(reference, &base) + .unwrap_or_else(|error| panic!("reference {reference:?} failed: {error:?}")); + assert_eq!(rewritten, reference, "reference={reference:?}"); + } + } + + #[test] + fn variable_placeholders_do_not_collide_with_canonicalized_literals() { + let base = Url::parse("https://media.example/master.m3u8").unwrap(); + for reference in [ + "https://X000.example/{$part}.ts", + "https://%78%30%30%30.example/{$part}.ts", + ] { + let resolved = resolve_hls_reference(reference, &base).unwrap().unwrap(); + + assert_eq!( + Url::parse(&resolved.target).unwrap().host_str(), + Some("x000.example"), + "{reference}" + ); + assert_eq!(resolved.target.matches("{$part}").count(), 1, "{reference}"); + } + } + + #[test] + fn overlapping_variable_placeholder_collision_retries_before_restoration() { + let base = Url::parse("https://media.example/master.m3u8").unwrap(); + let mut reference = String::from("segments/"); + for index in 0..33 { + reference.push_str("{$v"); + reference.push_str(&index.to_string()); + reference.push_str("}/"); + } + reference.push_str("x00{$target}.ts"); + + let resolved = resolve_hls_reference(&reference, &base).unwrap().unwrap(); + + assert!( + resolved.target.ends_with("/x00{$target}.ts"), + "restored target: {:?}", + resolved.target + ); + for index in 0..33 { + assert_eq!( + resolved.target.matches(&format!("{{$v{index}}}")).count(), + 1, + "restored target: {:?}", + resolved.target + ); + } + } + + #[test] + fn variable_substitutions_cannot_escape_path_form_or_change_proxy_options() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + let request_headers = + HeaderMap::from_iter([("x-token".parse().unwrap(), "secret".parse().unwrap())]); + let rewritten = rewrite_playlist_with_options( + "segments/{$path}.ts?token={$query}", + &base, + &request_headers, + &HeaderMap::new(), + ) + .unwrap(); + assert!(rewritten.starts_with("/proxy/d="), "{rewritten}"); + + let parse_substitution = |path: &str, query: &str| { + let link = rewritten + .replace("{$path}", path) + .replace("{$query}", query); + let uri = link + .parse::() + .map_err(|_| ProxyError::InvalidRequest)?; + let raw = uri + .path_and_query() + .ok_or(ProxyError::InvalidRequest)? + .as_str(); + let suffix = raw + .strip_prefix("/proxy") + .ok_or(ProxyError::InvalidRequest)?; + parse_proxy_suffix(suffix) + }; + + let benign = parse_substitution("part", "value").unwrap(); + assert_eq!( + benign.target.as_str(), + "https://media.example/path/segments/part.ts?token=value" + ); + assert_eq!(benign.request_headers, request_headers); + assert!(benign.response_headers.is_empty()); + + for encoded in ["%2F", "%25", "%41", "%5C", "%FF"] { + for (path, query) in [ + (format!("one{encoded}two"), "value".to_owned()), + ("one".to_owned(), format!("value{encoded}tail")), + ] { + let parsed = parse_substitution(&path, &query).unwrap_or_else(|error| { + panic!("valid substitution {path:?}, {query:?} failed: {error:?}") + }); + let direct = base + .join(&format!("segments/{path}.ts?token={query}")) + .unwrap(); + assert_eq!( + parsed.target, direct, + "path={path:?}, query={query:?}, link={rewritten:?}" + ); + assert_eq!(parsed.request_headers, request_headers); + assert!(parsed.response_headers.is_empty()); + } + } + assert!(rewritten.contains("&x-stream-path=raw//path/segments/{$path}.ts")); + + for (path, query) in [ + ("one&h=x-added%3Aattacker", "value"), + ("one&r=content-type%3Atext%2Fplain", "value"), + ("one&d=https%3A%2F%2Fattacker.example%2F", "value"), + ("one&ignored=1", "value"), + ("one?nested=1", "value"), + ("one#fragment", "value"), + ("one%2Ftwo", "value"), + ("one", "value&h=x-added%3Aattacker"), + ("one", "value&r=content-type%3Atext%2Fplain"), + ("one", "value&d=https%3A%2F%2Fattacker.example%2F"), + ("one", "value&ignored=1"), + ("one", "value?nested=1"), + ("one", "value#fragment"), + ("one", "value%2Ftail"), + ] { + let parsed = parse_substitution(path, query).unwrap_or_else(|error| { + panic!("valid substitution {path:?}, {query:?} failed: {error:?}") + }); + assert_eq!( + parsed.request_headers, request_headers, + "path={path:?}, query={query:?}" + ); + assert!( + parsed.response_headers.is_empty(), + "path={path:?}, query={query:?}" + ); + } + + for malformed in ["one%", "one%0", "one%GG"] { + assert!( + parse_substitution(malformed, "value").is_err(), + "path={malformed:?}" + ); + assert!( + parse_substitution("one", malformed).is_err(), + "query={malformed:?}" + ); + } + + let double_slash = rewrite_playlist_with_options( + "https://media.example//segments/{$path}.ts", + &base, + &request_headers, + &HeaderMap::new(), + ) + .unwrap() + .replace("{$path}", "part"); + let uri = double_slash.parse::().unwrap(); + let raw = uri.path_and_query().unwrap().as_str(); + let parsed = parse_proxy_suffix(raw.strip_prefix("/proxy").unwrap()).unwrap(); + assert_eq!( + parsed.target.as_str(), + "https://media.example//segments/part.ts" + ); + assert_eq!(parsed.request_headers, request_headers); + } + + #[test] + fn malformed_percent_variable_references_remain_unchanged_before_emission() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + for reference in [ + "segments/{$part}%.ts", + "segments/{$part}%0.ts", + "segments/{$part}%GG.ts", + "segments/{$part}.ts?token={$query}%", + "segments/{$part}.ts?token={$query}%0", + "segments/{$part}.ts?token={$query}%GG", + ] { + let rewritten = rewrite_playlist_bounded(reference, &base) + .unwrap_or_else(|error| panic!("plain reference {reference:?}: {error:?}")); + assert_eq!(rewritten, reference, "plain reference={reference:?}"); + + let tag = format!("#EXT-X-MAP:URI=\"{reference}\""); + let rewritten = rewrite_playlist_bounded(&tag, &base) + .unwrap_or_else(|error| panic!("quoted reference {reference:?}: {error:?}")); + assert_eq!(rewritten, tag, "quoted reference={reference:?}"); + } + } + + #[test] + fn overlong_variable_candidate_is_rejected_before_variable_collection() { + let base = Url::parse("https://media.example/master.m3u8").unwrap(); + let reference = "{$v}".repeat((MAX_PLAYLIST_INPUT - 1) / 4); + assert!(reference.len() > MAX_TARGET_URL); + assert!(reference.len() < MAX_PLAYLIST_INPUT); + HLS_VARIABLE_RANGE_SCANS.with(|scans| scans.set(0)); + + assert!(matches!( + resolve_hls_reference(&reference, &base), + Err(ProxyError::Upstream) + )); + HLS_VARIABLE_RANGE_SCANS.with(|scans| assert_eq!(scans.get(), 0)); + } + + #[test] + fn overlong_delimiter_free_candidate_bounds_initial_scheme_prescan() { + let base = Url::parse("https://media.example/master.m3u8").unwrap(); + let reference = "a".repeat(MAX_PLAYLIST_INPUT - 1); + HLS_SCHEME_PRESCAN_BYTES.with(|scans| scans.set(0)); + + assert!(matches!( + resolve_hls_reference(&reference, &base), + Err(ProxyError::Upstream) + )); + HLS_SCHEME_PRESCAN_BYTES.with(|scans| { + assert_eq!(scans.get(), MAX_TARGET_URL + 1); + }); + + for reference in [ + "data:text/plain,segment", + "skd://license.example/key", + "urn:example:asset", + ] { + assert!(resolve_hls_reference(reference, &base).unwrap().is_none()); + } + } + + #[test] + fn dense_path_variables_restore_without_placeholder_ambiguity() { + let base = Url::parse("https://media.example/master.m3u8").unwrap(); + let reference = format!("/{}tail.ts", "{$v}/".repeat(2_000)); + assert!(reference.len() < MAX_TARGET_URL); + + let resolved = resolve_hls_reference(&reference, &base).unwrap().unwrap(); + + assert_eq!(resolved.target.matches("{$v}").count(), 2_000); + assert!(resolved.target.ends_with("/tail.ts")); + } + + #[test] + fn emitted_hls_links_round_trip_reserved_target_and_header_semantics() { + let base = Url::parse("https://user:pass@media.example/dir/master.m3u8").unwrap(); + let request_headers = HeaderMap::from_iter([( + "x-token".parse().unwrap(), + "raw+plus&equals=value".parse().unwrap(), + )]); + let response_headers = HeaderMap::from_iter([( + header::CONTENT_TYPE, + "video/mp2t; note=raw+plus&equals=value".parse().unwrap(), + )]); + let body = concat!( + "child%2Fname+raw.ts?one=a+b&two=c%2Bd&equal=x=y#removed\n", + "#EXT-X-MAP:URI=\"/root%2Finit.mp4?x=a+b&y=%2B&z==#removed\"\n", + ); + + let rewritten = + rewrite_playlist_with_options(body, &base, &request_headers, &response_headers) + .unwrap(); + assert!(!rewritten.contains("removed")); + let links = rewritten + .split(['\n', '"']) + .filter(|part| part.starts_with("/proxy")) + .collect::>(); + let expected_targets = [ + "https://user:pass@media.example/dir/child%2Fname+raw.ts?one=a+b&two=c%2Bd&equal=x=y", + "https://user:pass@media.example/root%2Finit.mp4?x=a+b&y=%2B&z==", + ]; + for (link, expected_target) in links.iter().zip(expected_targets) { + let parsed = parse_proxy_suffix(link.strip_prefix("/proxy").unwrap()).unwrap(); + assert_eq!(parsed.target.as_str(), expected_target); + assert_eq!(parsed.request_headers["x-token"], "raw+plus&equals=value"); + assert_eq!( + parsed.response_headers[header::CONTENT_TYPE], + "video/mp2t; note=raw+plus&equals=value" + ); + } + } + + #[test] + fn parsed_utf8_obs_text_headers_round_trip_through_playlist_rewriting() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + let original = parse_proxy_request( + "", + Some(concat!( + "d=https%3A%2F%2Fmedia.example%2Fpath%2Fmaster.m3u8", + "&h=X-Utf8%3Acaf%C3%A9-%C2%80", + "&r=Content-Type%3Aapplication%2Fx.test%3Bnote%3Dcaf%C3%A9-%C2%80", + )), + ) + .unwrap(); + assert_eq!( + original.request_headers["x-utf8"].as_bytes(), + b"caf\xc3\xa9-\xc2\x80" + ); + assert_eq!( + original.response_headers[header::CONTENT_TYPE].as_bytes(), + b"application/x.test;note=caf\xc3\xa9-\xc2\x80" + ); + + let rewritten = rewrite_playlist_with_options( + "segment.ts", + &base, + &original.request_headers, + &original.response_headers, + ) + .unwrap(); + let reparsed = parse_proxy_suffix(rewritten.strip_prefix("/proxy").unwrap()).unwrap(); + + assert_eq!( + reparsed.request_headers["x-utf8"].as_bytes(), + original.request_headers["x-utf8"].as_bytes() + ); + assert_eq!( + reparsed.response_headers[header::CONTENT_TYPE].as_bytes(), + original.response_headers[header::CONTENT_TYPE].as_bytes() + ); + } + + #[test] + fn playlist_child_canonical_target_accepts_exact_limit_and_rejects_overflow() { + let base = Url::parse("https://base.example/master.m3u8").unwrap(); + let prefix = "https://example.com/"; + let exact = format!("{prefix}{}", "a".repeat(MAX_TARGET_URL - prefix.len())); + assert_eq!(exact.len(), MAX_TARGET_URL); + let rewritten = rewrite_playlist_bounded(&exact, &base).unwrap(); + let parsed = parse_proxy_suffix(rewritten.strip_prefix("/proxy").unwrap()).unwrap(); + assert_eq!(parsed.target.as_str().len(), MAX_TARGET_URL); + + let over = format!("{exact}a"); + assert!(matches!( + rewrite_playlist_bounded(&over, &base), + Err(ProxyError::Upstream) + )); + } + + #[test] + fn emitted_proxy_suffix_accepts_exact_limit_and_rejects_overflow() { + const FIXED_SUFFIX_LENGTH: usize = 123; + const FULL_VALUE_LENGTH: usize = MAX_HEADER_PAIR - "x-0:".len(); + let last_value_length = MAX_PROXY_INPUT - FIXED_SUFFIX_LENGTH - (7 * FULL_VALUE_LENGTH); + assert_eq!(last_value_length, 8_097); + let headers = |extra: usize| { + let mut headers = HeaderMap::new(); + for index in 0..8 { + let length = if index == 7 { + last_value_length + extra + } else { + FULL_VALUE_LENGTH + }; + headers.insert( + format!("x-{index}") + .parse::() + .unwrap(), + "a".repeat(length).parse::().unwrap(), + ); + } + headers + }; + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + + let exact = + rewrite_playlist_with_options("segment.ts", &base, &headers(0), &HeaderMap::new()) + .unwrap(); + let suffix = exact.strip_prefix("/proxy").unwrap(); + assert_eq!(suffix.len(), MAX_PROXY_INPUT); + assert!(parse_proxy_suffix(suffix).is_ok()); + + assert!(matches!( + rewrite_playlist_with_options("segment.ts", &base, &headers(1), &HeaderMap::new(),), + Err(ProxyError::Upstream) + )); + } + + #[test] + fn emitted_raw_path_mode_counts_exact_option_and_suffix_bytes() { + const FIXED_SUFFIX_LENGTH: usize = 134; + const FULL_VALUE_LENGTH: usize = MAX_HEADER_PAIR - "x-0:".len(); + const LAST_VALUE_LENGTH: usize = + MAX_PROXY_INPUT - FIXED_SUFFIX_LENGTH - (7 * FULL_VALUE_LENGTH); + assert_eq!(LAST_VALUE_LENGTH, 8_086); + let headers = |extra: usize| { + let mut headers = HeaderMap::new(); + for index in 0..8 { + let length = if index == 7 { + LAST_VALUE_LENGTH + extra + } else { + FULL_VALUE_LENGTH + }; + headers.insert( + format!("x-{index}") + .parse::() + .unwrap(), + "a".repeat(length).parse::().unwrap(), + ); + } + headers + }; + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + + let exact = + rewrite_playlist_with_options("{$v}", &base, &headers(0), &HeaderMap::new()).unwrap(); + let suffix = exact.strip_prefix("/proxy").unwrap(); + assert!(suffix.contains("&x-stream-path=raw//path/{$v}")); + assert_eq!(suffix.len(), MAX_PROXY_INPUT); + assert!(parse_proxy_suffix(suffix).is_ok()); + + assert!(matches!( + rewrite_playlist_with_options("{$v}", &base, &headers(1), &HeaderMap::new(),), + Err(ProxyError::Upstream) + )); + } + + #[test] + fn rewritten_playlist_accepts_exact_output_limit_and_rejects_one_more_byte() { + const REWRITTEN_LINE_LENGTH: usize = 35; + let base = Url::parse("https://a.test/master.m3u8").unwrap(); + let repeats = MAX_PLAYLIST_OUTPUT / REWRITTEN_LINE_LENGTH; + let remainder = MAX_PLAYLIST_OUTPUT % REWRITTEN_LINE_LENGTH; + assert_eq!(remainder, 1); + let mut exact = "x\n".repeat(repeats); + exact.push('#'); + assert!(exact.len() <= MAX_PLAYLIST_INPUT); + + let rewritten = rewrite_playlist_bounded(&exact, &base).unwrap(); + assert_eq!(rewritten.len(), MAX_PLAYLIST_OUTPUT); + + exact.push('a'); + assert!(matches!( + rewrite_playlist_bounded(&exact, &base), + Err(ProxyError::Upstream) + )); + } + + #[tokio::test] + async fn overlong_valid_http_playlist_child_returns_bad_gateway() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let prefix = format!("http://child.test:{}/", address.port()); + let child = format!("{prefix}{}", "a".repeat(MAX_TARGET_URL + 1 - prefix.len())); + let router = Router::new().route( + "/master.m3u8", + get(move || { + let child = child.clone(); + async move { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from(child)) + .unwrap() + } + }), + ); + let fixture = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://origin.test:{}/master.m3u8", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + "Proxy upstream request failed" + ); + fixture.abort(); + } + + #[tokio::test] + async fn emitted_hls_links_scope_options_by_final_child_origin_in_real_handlers() { + let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let cross_uri = format!("http://other.test:{}/cross.ts", address.port()); + let router = Router::new().fallback(any(move |uri: Uri, headers: HeaderMap| { + let seen_tx = seen_tx.clone(); + let cross_uri = cross_uri.clone(); + async move { + match uri.path() { + "/master.m3u8" => Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from(format!("#EXTM3U\nsame.ts\n{cross_uri}\n"))) + .unwrap(), + "/same.ts" | "/cross.ts" => { + seen_tx.send((uri.path().to_owned(), headers)).unwrap(); + Response::new(Body::from("media")) + } + _ => StatusCode::NOT_FOUND.into_response(), + } + } + })); + let fixture = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://media.test:{}/master.m3u8", address.port()); + let uri: Uri = format!( + concat!( + "/proxy/?d={}", + "&h=X-Api-Key%3Asame-secret", + "&r=Content-Type%3Aapplication%2Fvnd.apple.mpegurl" + ), + urlencoding::encode(&target) + ) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body = std::str::from_utf8(&body).unwrap(); + let links = body + .lines() + .filter(|line| line.starts_with("/proxy")) + .collect::>(); + assert_eq!(links.len(), 2, "rewritten playlist: {body:?}"); + + let same = parse_proxy_suffix(links[0].strip_prefix("/proxy").unwrap()).unwrap(); + assert_eq!(same.target.host_str(), Some("media.test")); + assert_eq!(same.request_headers["x-api-key"], "same-secret"); + assert_eq!( + same.response_headers[header::CONTENT_TYPE], + "application/vnd.apple.mpegurl" + ); + let cross = parse_proxy_suffix(links[1].strip_prefix("/proxy").unwrap()).unwrap(); + assert_eq!(cross.target.host_str(), Some("other.test")); + assert!(cross.request_headers.is_empty()); + assert!(cross.response_headers.is_empty()); + + for link in links { + let child = handle_proxy( + &runtime, + link.parse().unwrap(), + HeaderMap::new(), + Method::GET, + ) + .await; + assert_eq!(child.status(), StatusCode::OK); + } + let (same_path, same_headers) = seen_rx.recv().await.unwrap(); + assert_eq!(same_path, "/same.ts"); + assert_eq!(same_headers["x-api-key"], "same-secret"); + let (cross_path, cross_headers) = seen_rx.recv().await.unwrap(); + assert_eq!(cross_path, "/cross.ts"); + assert!(!cross_headers.contains_key("x-api-key")); + fixture.abort(); + } + + #[tokio::test] + async fn redirected_playlist_children_use_cleared_h_and_retained_r() { + let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let redirect_location = format!("http://origin-b.test:{}/final.m3u8", address.port()); + let router = Router::new().fallback(any(move |uri: Uri, headers: HeaderMap| { + let seen_tx = seen_tx.clone(); + let redirect_location = redirect_location.clone(); + async move { + match uri.path() { + "/redirect.m3u8" => ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, redirect_location)], + ) + .into_response(), + "/final.m3u8" => { + seen_tx.send((uri.path().to_owned(), headers)).unwrap(); + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from("#EXTM3U\nredirect-child.ts\n")) + .unwrap() + } + "/redirect-child.ts" => { + seen_tx.send((uri.path().to_owned(), headers)).unwrap(); + Response::new(Body::from("media")) + } + _ => StatusCode::NOT_FOUND.into_response(), + } + } + })); + let fixture = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://origin-a.test:{}/redirect.m3u8", address.port()); + let uri: Uri = format!( + concat!( + "/proxy/?d={}", + "&h=X-Api-Key%3Aredirect-secret", + "&r=Content-Type%3Aapplication%2Fvnd.apple.mpegurl" + ), + urlencoding::encode(&target) + ) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let body = std::str::from_utf8(&body).unwrap(); + let link = body + .lines() + .find(|line| line.starts_with("/proxy")) + .unwrap(); + let parsed = parse_proxy_suffix(link.strip_prefix("/proxy").unwrap()).unwrap(); + assert_eq!(parsed.target.host_str(), Some("origin-b.test")); + assert!(parsed.request_headers.is_empty()); + assert_eq!( + parsed.response_headers[header::CONTENT_TYPE], + "application/vnd.apple.mpegurl" + ); + + let child = handle_proxy( + &runtime, + link.parse().unwrap(), + HeaderMap::new(), + Method::GET, + ) + .await; + assert_eq!(child.status(), StatusCode::OK); + for expected_path in ["/final.m3u8", "/redirect-child.ts"] { + let (path, headers) = seen_rx.recv().await.unwrap(); + assert_eq!(path, expected_path); + assert!(!headers.contains_key("x-api-key")); + } + fixture.abort(); + } + + #[test] + fn playlist_rewriter_handles_plain_and_every_quoted_uri() { + let base = url::Url::parse("https://media.example/path/master.m3u8").unwrap(); + let body = concat!( + "#EXTM3U\r\n", + "#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio.m3u8\",X=1,URI=\"backup.m3u8\"\r\n", + "segment.ts?token=1\r\n" + ); + let rewritten = rewrite_playlist_bounded(body, &base).unwrap(); + assert!(rewritten.starts_with("#EXTM3U\r\n")); + assert_eq!(rewritten.matches("/proxy/?d=").count(), 3); + assert!(rewritten.contains("https%3A%2F%2Fmedia.example%2Fpath%2Faudio.m3u8")); + assert!(rewritten.contains("https%3A%2F%2Fmedia.example%2Fpath%2Fsegment.ts%3Ftoken%3D1")); + assert!(rewritten.ends_with("\r\n")); + } + + #[test] + fn playlist_rewriter_only_rewrites_exact_ext_uri_attributes() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + let preserved_prefix = concat!( + "# an unrelated URI=\"comment.ts\"\r\n", + "#COMMENT:URI=\"comment-tag.ts\"\n", + "#EXT-X-TEST:NOTURI=\"not.ts\",X-URI=\"x.ts\"\r\n", + ); + let body = format!( + concat!( + "{}", + "#EXT-X-TEST:NOTURI=\"not.ts\", X-URI=\"x.ts\",\tURI=\"actual.ts\", FOO=1, URI=\"backup.ts\"\n", + "#EXT-X-MAP: \tURI=\"leading.ts\"\r\n", + ), + preserved_prefix, + ); + + let rewritten = rewrite_playlist_bounded(&body, &base).unwrap(); + + assert!( + rewritten.starts_with(preserved_prefix), + "rewritten playlist: {rewritten:?}" + ); + assert!(rewritten.contains("NOTURI=\"not.ts\", X-URI=\"x.ts\",\tURI=\"/proxy/?d=")); + assert_eq!(rewritten.matches("/proxy/?d=").count(), 3); + assert_eq!(rewritten.matches("\r\n").count(), 3); assert!(rewritten.ends_with("\r\n")); } From 2488eaad32ce1613c30f7c6dcb1e47025d477215 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:33:01 -0400 Subject: [PATCH 17/25] security: enforce per-peer proxy admission --- server/src/network_security/mod.rs | 4 +- server/src/network_security/runtime.rs | 252 ++++++++++++- server/src/routes/proxy.rs | 502 +++++++++++++++++++++++-- 3 files changed, 710 insertions(+), 48 deletions(-) diff --git a/server/src/network_security/mod.rs b/server/src/network_security/mod.rs index 1b51268..657eed6 100644 --- a/server/src/network_security/mod.rs +++ b/server/src/network_security/mod.rs @@ -6,7 +6,9 @@ pub(crate) use resolver::{ DestinationError, DestinationValidator, ListenerBinding, SystemClock, SystemDnsResolver, SystemLocalNetworkProvider, }; -pub(crate) use runtime::{ProxyPolicySettings, ProxyRequestContext, ProxyRuntime}; +pub(crate) use runtime::{ + ProxyCapacityPermit, ProxyPolicySettings, ProxyRequestContext, ProxyRuntime, +}; #[cfg(test)] pub(crate) use ip::LocalNetworks; diff --git a/server/src/network_security/runtime.rs b/server/src/network_security/runtime.rs index 034a704..a7ada66 100644 --- a/server/src/network_security/runtime.rs +++ b/server/src/network_security/runtime.rs @@ -1,12 +1,16 @@ use super::resolver::{ DestinationError, DestinationValidator, OutboundPolicy, ResolvedDestination, }; -use std::sync::{Arc, Mutex}; -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use std::{ + collections::HashMap, + net::IpAddr, + sync::{Arc, Mutex}, +}; use tokio_util::sync::CancellationToken; use url::Url; const MAX_CONCURRENT_PROXY_REQUESTS: usize = 64; +const MAX_CONCURRENT_PROXY_REQUESTS_PER_PEER: usize = 16; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) struct ProxyPolicySettings { @@ -17,7 +21,7 @@ pub(crate) struct ProxyPolicySettings { pub(crate) struct ProxyRequestContext { pub(crate) settings: ProxyPolicySettings, pub(crate) cancellation: CancellationToken, - pub(crate) capacity: OwnedSemaphorePermit, + pub(crate) capacity: ProxyCapacityPermit, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -28,9 +32,95 @@ struct ProxyGeneration { cancellation: CancellationToken, } +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +enum ProxyPeer { + Known(IpAddr), + Unknown, +} + +#[derive(Default)] +struct ProxyCapacityState { + global: usize, + peers: HashMap, +} + +#[derive(Default)] +struct ProxyCapacity { + state: Mutex, +} + +pub(crate) struct ProxyCapacityPermit { + capacity: Arc, + peer: ProxyPeer, +} + +impl ProxyCapacity { + fn try_acquire( + self: &Arc, + peer: Option, + ) -> Result { + let peer = normalize_peer(peer); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let peer_active = state.peers.get(&peer).copied().unwrap_or(0); + if state.global >= MAX_CONCURRENT_PROXY_REQUESTS + || peer_active >= MAX_CONCURRENT_PROXY_REQUESTS_PER_PEER + { + return Err(ProxyCapacityError); + } + state.global += 1; + *state.peers.entry(peer).or_insert(0) += 1; + drop(state); + Ok(ProxyCapacityPermit { + capacity: self.clone(), + peer, + }) + } +} + +impl Drop for ProxyCapacityPermit { + fn drop(&mut self) { + let mut state = self + .capacity + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.global = state + .global + .checked_sub(1) + .expect("proxy global capacity permit underflow"); + let remove_peer = { + let active = state + .peers + .get_mut(&self.peer) + .expect("proxy peer capacity permit without counter"); + *active = active + .checked_sub(1) + .expect("proxy peer capacity permit underflow"); + *active == 0 + }; + if remove_peer { + state.peers.remove(&self.peer); + } + } +} + +fn normalize_peer(peer: Option) -> ProxyPeer { + match peer { + Some(IpAddr::V6(address)) => address + .to_ipv4_mapped() + .map(|address| ProxyPeer::Known(IpAddr::V4(address))) + .unwrap_or(ProxyPeer::Known(IpAddr::V6(address))), + Some(address) => ProxyPeer::Known(address), + None => ProxyPeer::Unknown, + } +} + pub(crate) struct ProxyRuntime { validator: Arc, - capacity: Arc, + capacity: Arc, generation: Mutex, } @@ -38,7 +128,7 @@ impl ProxyRuntime { pub(crate) fn new(settings: ProxyPolicySettings, validator: Arc) -> Self { Self { validator, - capacity: Arc::new(Semaphore::new(MAX_CONCURRENT_PROXY_REQUESTS)), + capacity: Arc::new(ProxyCapacity::default()), generation: Mutex::new(ProxyGeneration { settings, cancellation: CancellationToken::new(), @@ -46,12 +136,16 @@ impl ProxyRuntime { } } + #[cfg(test)] pub(crate) fn try_request(&self) -> Result { - let capacity = self - .capacity - .clone() - .try_acquire_owned() - .map_err(|_| ProxyCapacityError)?; + self.try_request_for_peer(None) + } + + pub(crate) fn try_request_for_peer( + &self, + peer: Option, + ) -> Result { + let capacity = self.capacity.try_acquire(peer)?; let generation = self .generation .lock() @@ -63,6 +157,16 @@ impl ProxyRuntime { }) } + #[cfg(test)] + pub(crate) fn capacity_snapshot(&self) -> (usize, usize) { + let state = self + .capacity + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (state.global, state.peers.len()) + } + pub(crate) async fn validate( &self, context: &ProxyRequestContext, @@ -130,7 +234,12 @@ mod tests { }; use super::{ProxyPolicySettings, ProxyRuntime}; use async_trait::async_trait; - use std::{io, net::SocketAddr, sync::Arc, time::Instant}; + use std::{ + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + sync::{Arc, Barrier}, + time::Instant, + }; struct NoDns; @@ -171,12 +280,131 @@ mod tests { #[test] fn sixty_fifth_request_is_rejected_without_waiting() { let runtime = runtime(ProxyPolicySettings::default()); - let permits: Vec<_> = (0..64).map(|_| runtime.try_request().unwrap()).collect(); + let permits: Vec<_> = (0..64) + .map(|index| { + runtime + .try_request_for_peer(Some(IpAddr::V6(Ipv6Addr::from(index + 1)))) + .unwrap() + }) + .collect(); + assert!( + runtime + .try_request_for_peer(Some(IpAddr::V6(Ipv6Addr::from(65)))) + .is_err() + ); + drop(permits); + assert!( + runtime + .try_request_for_peer(Some(IpAddr::V6(Ipv6Addr::from(65)))) + .is_ok() + ); + } + + #[test] + fn unknown_peer_is_limited_to_sixteen_active_requests() { + let runtime = runtime(ProxyPolicySettings::default()); + let permits: Vec<_> = (0..16).map(|_| runtime.try_request().unwrap()).collect(); + assert!(runtime.try_request().is_err()); + drop(permits); assert!(runtime.try_request().is_ok()); } + #[test] + fn one_peer_is_limited_without_blocking_another_peer() { + let runtime = runtime(ProxyPolicySettings::default()); + let first = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)); + let second = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 2)); + let permits: Vec<_> = (0..16) + .map(|_| runtime.try_request_for_peer(Some(first)).unwrap()) + .collect(); + + assert!(runtime.try_request_for_peer(Some(first)).is_err()); + assert!(runtime.try_request_for_peer(Some(second)).is_ok()); + drop(permits); + } + + #[test] + fn ipv4_mapped_ipv6_shares_the_ipv4_peer_quota() { + let runtime = runtime(ProxyPolicySettings::default()); + let ipv4 = Ipv4Addr::new(192, 0, 2, 10); + let permits: Vec<_> = (0..8) + .map(|_| { + runtime + .try_request_for_peer(Some(IpAddr::V4(ipv4))) + .unwrap() + }) + .chain((0..8).map(|_| { + runtime + .try_request_for_peer(Some(IpAddr::V6(ipv4.to_ipv6_mapped()))) + .unwrap() + })) + .collect(); + + assert!( + runtime + .try_request_for_peer(Some(IpAddr::V4(ipv4))) + .is_err() + ); + assert!( + runtime + .try_request_for_peer(Some(IpAddr::V6(ipv4.to_ipv6_mapped()))) + .is_err() + ); + drop(permits); + } + + #[test] + fn dropping_last_permits_removes_idle_peer_entries() { + let runtime = runtime(ProxyPolicySettings::default()); + + for host in 1..=200 { + let peer = IpAddr::V4(Ipv4Addr::new(198, 51, 100, host)); + drop(runtime.try_request_for_peer(Some(peer)).unwrap()); + } + + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + let peer = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 1)); + assert!(runtime.try_request_for_peer(Some(peer)).is_ok()); + } + + #[test] + fn concurrent_last_drop_and_reacquire_cannot_split_a_peer_quota() { + let runtime = Arc::new(runtime(ProxyPolicySettings::default())); + let peer = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 7)); + + for _ in 0..100 { + let mut permits: Vec<_> = (0..16) + .map(|_| runtime.try_request_for_peer(Some(peer)).unwrap()) + .collect(); + let last = permits.pop().unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let drop_barrier = barrier.clone(); + let dropper = std::thread::spawn(move || { + drop_barrier.wait(); + drop(last); + }); + + barrier.wait(); + let replacement = (0..10_000) + .find_map(|_| { + let acquired = runtime.try_request_for_peer(Some(peer)).ok(); + if acquired.is_none() { + std::thread::yield_now(); + } + acquired + }) + .expect("the released peer slot must remain reacquirable"); + dropper.join().unwrap(); + + assert!(runtime.try_request_for_peer(Some(peer)).is_err()); + drop(replacement); + drop(permits); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + } + } + #[tokio::test] async fn restrictive_reconfiguration_cancels_the_old_generation() { let runtime = runtime(ProxyPolicySettings { diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 41b3e6d..8a618c0 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -1,11 +1,11 @@ use crate::{ - network_security::{DestinationError, ProxyRequestContext, ProxyRuntime}, + network_security::{DestinationError, ProxyCapacityPermit, ProxyRequestContext, ProxyRuntime}, state::AppState, }; use axum::{ Router, body::Body, - extract::{OriginalUri, State}, + extract::{ConnectInfo, Extension, OriginalUri, State}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode, Uri, header}, response::{IntoResponse, Response}, routing::any, @@ -15,10 +15,12 @@ use futures_util::{Stream, StreamExt}; use reqwest::{Client, Method}; use std::{ collections::{HashMap, HashSet}, + future::Future, + net::{IpAddr, SocketAddr}, pin::Pin, + sync::Arc, time::Duration, }; -use tokio::sync::OwnedSemaphorePermit; use tokio_util::sync::CancellationToken; use url::{Position, Url}; @@ -28,6 +30,7 @@ const MAX_CUSTOM_OPTIONS: usize = 64; const MAX_HEADER_PAIR: usize = 8 * 1024; const RAW_CANONICAL_PATH_KEY: &str = "x-stream-path"; const RAW_CANONICAL_PATH_OPTION: &str = "&x-stream-path=raw"; +const RESPONSE_HEADER_DEADLINE: Duration = Duration::from_secs(30); #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ProxyError { @@ -312,6 +315,23 @@ fn response_header_forbidden(name: &HeaderName) -> bool { name != header::CONTENT_TYPE } +async fn await_response_headers( + cancellation: &CancellationToken, + send: F, +) -> Result +where + F: Future>, +{ + tokio::select! { + biased; + _ = cancellation.cancelled() => Err(ProxyError::Cancelled), + result = tokio::time::timeout(RESPONSE_HEADER_DEADLINE, send) => { + result.map_err(|_| ProxyError::Upstream)? + .map_err(|_| ProxyError::Upstream) + } + } +} + async fn fetch_with_redirects( runtime: &ProxyRuntime, context: &ProxyRequestContext, @@ -368,11 +388,7 @@ async fn fetch_with_redirects( .request(method.clone(), destination.url.clone()) .headers(headers) .send(); - let response = tokio::select! { - biased; - _ = context.cancellation.cancelled() => return Err(ProxyError::Cancelled), - result = send => result.map_err(|_| ProxyError::Upstream)?, - }; + let response = await_response_headers(&context.cancellation, send).await?; if response.status() == StatusCode::SWITCHING_PROTOCOLS { return Err(ProxyError::Upstream); @@ -440,23 +456,42 @@ fn same_origin(left: &Url, right: &Url) -> bool { } pub fn service(state: AppState) -> Router { - Router::new().fallback(any(proxy_handler)).with_state(state) + runtime_service(state.proxy_runtime.clone()) +} + +fn runtime_service(runtime: Arc) -> Router { + Router::new() + .fallback(any(proxy_handler)) + .with_state(runtime) } async fn proxy_handler( - State(state): State, + State(runtime): State>, + peer: Option>>, OriginalUri(original_uri): OriginalUri, headers: HeaderMap, method: Method, ) -> Response { - handle_proxy(&state.proxy_runtime, original_uri, headers, method).await + let peer = peer.map(|Extension(ConnectInfo(address))| address.ip()); + handle_proxy_for_peer(&runtime, peer, original_uri, headers, method).await } +#[cfg(test)] async fn handle_proxy( runtime: &ProxyRuntime, original_uri: Uri, headers: HeaderMap, method: Method, +) -> Response { + handle_proxy_for_peer(runtime, None, original_uri, headers, method).await +} + +async fn handle_proxy_for_peer( + runtime: &ProxyRuntime, + peer: Option, + original_uri: Uri, + headers: HeaderMap, + method: Method, ) -> Response { let raw_target = original_uri .path_and_query() @@ -467,14 +502,25 @@ async fn handle_proxy( } _ => return proxy_error_response(ProxyError::InvalidRequest), }; - handle_proxy_suffix(runtime, raw_suffix, headers, method).await + handle_proxy_suffix_for_peer(runtime, peer, raw_suffix, headers, method).await } +#[cfg(test)] async fn handle_proxy_suffix( runtime: &ProxyRuntime, raw_suffix: &str, headers: HeaderMap, method: Method, +) -> Response { + handle_proxy_suffix_for_peer(runtime, None, raw_suffix, headers, method).await +} + +async fn handle_proxy_suffix_for_peer( + runtime: &ProxyRuntime, + peer: Option, + raw_suffix: &str, + headers: HeaderMap, + method: Method, ) -> Response { if raw_suffix.len() > MAX_PROXY_INPUT { return proxy_error_response(ProxyError::InvalidRequest); @@ -482,7 +528,7 @@ async fn handle_proxy_suffix( if method == Method::CONNECT { return proxy_error_response(ProxyError::InvalidRequest); } - let context = match runtime.try_request() { + let context = match runtime.try_request_for_peer(peer) { Ok(context) => context, Err(_) => return proxy_error_response(ProxyError::Capacity), }; @@ -752,7 +798,7 @@ fn trim_ascii_ows(mut value: &[u8]) -> &[u8] { fn streaming_proxy_body( stream: UpstreamByteStream, cancellation: CancellationToken, - capacity: OwnedSemaphorePermit, + capacity: ProxyCapacityPermit, ) -> Body { let stream = ProxyBodyState { stream, @@ -805,7 +851,7 @@ type UpstreamByteStream = struct ProxyBodyState { stream: UpstreamByteStream, cancellation: CancellationToken, - _capacity: OwnedSemaphorePermit, + _capacity: ProxyCapacityPermit, terminal: bool, } @@ -813,14 +859,14 @@ struct BufferedProxyBodyState { bytes: Bytes, offset: usize, cancellation: CancellationToken, - _capacity: OwnedSemaphorePermit, + _capacity: ProxyCapacityPermit, terminal: bool, } fn buffered_proxy_body( bytes: Bytes, cancellation: CancellationToken, - capacity: OwnedSemaphorePermit, + capacity: ProxyCapacityPermit, ) -> Body { const CHUNK_SIZE: usize = 64 * 1024; let stream = futures_util::stream::unfold( @@ -1657,10 +1703,11 @@ mod tests { use super::{ HLS_SCHEME_PRESCAN_BYTES, HLS_VARIABLE_RANGE_SCANS, MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, MAX_PLAYLIST_OUTPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, ProxyError, - apply_redirect_origin_policy, buffered_proxy_body, collect_playlist, fetch_with_redirects, - handle_proxy, handle_proxy_suffix, parse_proxy_request, parse_proxy_suffix, - proxy_error_response, resolve_hls_reference, rewrite_playlist_bounded, - rewrite_playlist_with_options, same_origin, streaming_proxy_body, + apply_redirect_origin_policy, await_response_headers, buffered_proxy_body, + collect_playlist, fetch_with_redirects, handle_proxy, handle_proxy_suffix, + parse_proxy_request, parse_proxy_suffix, proxy_error_response, resolve_hls_reference, + rewrite_playlist_bounded, rewrite_playlist_with_options, runtime_service, same_origin, + streaming_proxy_body, }; use crate::network_security::{ Clock, DestinationValidator, DnsResolver, LocalNetworkProvider, ProxyPolicySettings, @@ -1677,7 +1724,7 @@ mod tests { }; use std::{ io, - net::SocketAddr, + net::{IpAddr, Ipv4Addr, SocketAddr}, sync::{ Arc, atomic::{AtomicUsize, Ordering}, @@ -1988,14 +2035,22 @@ mod tests { ProxyPolicySettings::default(), ); let permits = (0..64) - .map(|_| runtime.try_request().unwrap()) + .map(|index| { + runtime + .try_request_for_peer(Some(std::net::IpAddr::V6(std::net::Ipv6Addr::from( + index + 1, + )))) + .unwrap() + }) .collect::>(); + assert_eq!(runtime.capacity_snapshot(), (64, 64)); let prefix = "?d=http%3A%2F%2Fblocked.example&unknown="; let raw_suffix = format!("{prefix}{}", "a".repeat(MAX_PROXY_INPUT + 1 - prefix.len())); let response = handle_proxy_suffix(&runtime, &raw_suffix, HeaderMap::new(), Method::GET).await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); + assert_eq!(runtime.capacity_snapshot(), (64, 64)); let response = handle_proxy_suffix( &runtime, @@ -2314,6 +2369,374 @@ mod tests { (address, task) } + async fn stalled_upstream_fixture() -> (SocketAddr, tokio::task::JoinHandle<()>) { + fixture(Router::new().fallback(any(|| async { + Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_LENGTH, "1") + .body(Body::from_stream(futures_util::stream::pending::< + Result, + >())) + .unwrap() + }))) + .await + } + + async fn proxy_router_fixture( + runtime: Arc, + with_connect_info: bool, + ) -> (SocketAddr, tokio::task::JoinHandle<()>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let router = runtime_service(runtime); + let task = if with_connect_info { + tokio::spawn(async move { + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }) + } else { + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }) + }; + (address, task) + } + + async fn assert_capacity_response(response: reqwest::Response) { + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(response.headers()[header::RETRY_AFTER], "1"); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert_eq!( + response.headers()["content-security-policy"], + "default-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; sandbox" + ); + assert_eq!( + response.text().await.unwrap(), + "Proxy capacity is exhausted" + ); + } + + #[tokio::test] + async fn router_uses_actual_connect_info_and_ignores_forwarded_peers() { + let (upstream_address, upstream) = stalled_upstream_fixture().await; + let (runtime, _) = test_runtime( + upstream_address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let (proxy_address, proxy) = proxy_router_fixture(Arc::new(runtime), true).await; + let target = format!( + "http://stalled-upstream.test:{}/resource", + upstream_address.port() + ); + let url = format!( + "http://{proxy_address}/proxy/?d={}", + urlencoding::encode(&target) + ); + let first_peer = reqwest::Client::builder() + .local_address(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))) + .build() + .unwrap(); + let second_peer = reqwest::Client::builder() + .local_address(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 3))) + .build() + .unwrap(); + let mut held = Vec::new(); + for index in 0..16 { + let response = first_peer + .get(&url) + .header("forwarded", format!("for=198.51.100.{}", index + 1)) + .header("x-forwarded-for", format!("203.0.113.{}", index + 1)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + held.push(response); + } + + let exhausted = first_peer + .get(&url) + .header("forwarded", "for=127.0.0.3") + .header("x-forwarded-for", "127.0.0.3") + .send() + .await + .unwrap(); + assert_capacity_response(exhausted).await; + + let other = second_peer + .get(&url) + .header("forwarded", "for=127.0.0.2") + .header("x-forwarded-for", "127.0.0.2") + .send() + .await + .unwrap(); + assert_eq!(other.status(), StatusCode::OK); + + drop(other); + drop(held); + proxy.abort(); + upstream.abort(); + } + + #[tokio::test] + async fn router_without_connect_info_uses_one_unknown_peer_bucket() { + let (upstream_address, upstream) = stalled_upstream_fixture().await; + let (runtime, _) = test_runtime( + upstream_address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let (proxy_address, proxy) = proxy_router_fixture(Arc::new(runtime), false).await; + let target = format!( + "http://stalled-upstream.test:{}/resource", + upstream_address.port() + ); + let url = format!( + "http://{proxy_address}/proxy/?d={}", + urlencoding::encode(&target) + ); + let client = reqwest::Client::new(); + let mut held = Vec::new(); + for _ in 0..16 { + let response = client.get(&url).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + held.push(response); + } + + assert_capacity_response(client.get(&url).send().await.unwrap()).await; + + drop(held); + proxy.abort(); + upstream.abort(); + } + + #[tokio::test(start_paused = true)] + async fn response_header_deadline_is_absolute_and_releases_admission() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let fixture = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + assert!(stream.read(&mut request).await.unwrap() > 0); + stream + .write_all(b"HTTP/1.1 200 OK\r\nX-Drip: ") + .await + .unwrap(); + loop { + tokio::time::sleep(Duration::from_secs(5)).await; + if stream.write_all(b"a").await.is_err() { + break; + } + } + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://slow-headers.test:{}/resource", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = tokio::time::timeout( + Duration::from_secs(31), + handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET), + ) + .await + .expect("the absolute header deadline must beat a drip-fed response"); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_response_isolated(&response); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + assert_eq!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + "Proxy upstream request failed" + ); + let permits: Vec<_> = (0..16).map(|_| runtime.try_request().unwrap()).collect(); + assert!(runtime.try_request().is_err()); + drop(permits); + fixture.abort(); + } + + #[tokio::test(start_paused = true)] + async fn response_header_await_helper_has_an_absolute_thirty_second_deadline() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let started = tokio::time::Instant::now(); + + let result = await_response_headers( + &cancellation, + futures_util::future::pending::>(), + ) + .await; + + assert!(matches!(result, Err(ProxyError::Upstream))); + assert_eq!( + tokio::time::Instant::now() - started, + Duration::from_secs(30) + ); + } + + #[tokio::test(start_paused = true)] + async fn response_header_await_helper_accepts_completion_before_deadline() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let started = tokio::time::Instant::now(); + + let result = await_response_headers(&cancellation, async { + tokio::time::sleep(Duration::from_secs(29)).await; + Ok::<_, ()>("headers") + }) + .await; + + assert_eq!(result.unwrap(), "headers"); + assert_eq!( + tokio::time::Instant::now() - started, + Duration::from_secs(29) + ); + } + + #[tokio::test(start_paused = true)] + async fn sequential_response_header_awaits_each_receive_a_fresh_deadline() { + let cancellation = tokio_util::sync::CancellationToken::new(); + let started = tokio::time::Instant::now(); + + for expected in ["first", "second"] { + let result = await_response_headers(&cancellation, async move { + tokio::time::sleep(Duration::from_secs(29)).await; + Ok::<_, ()>(expected) + }) + .await; + assert_eq!(result.unwrap(), expected); + } + + assert_eq!( + tokio::time::Instant::now() - started, + Duration::from_secs(58) + ); + } + + #[tokio::test] + async fn response_headers_completing_before_deadline_proceed() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let fixture = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + assert!(stream.read(&mut request).await.unwrap() > 0); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://just-in-time.test:{}/resource", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + + assert_eq!(response.status(), StatusCode::OK); + fixture.await.unwrap(); + } + + #[tokio::test] + async fn every_redirect_hop_gets_a_fresh_response_header_deadline() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let fixture = tokio::spawn(async move { + for response in [ + b"HTTP/1.1 307 Temporary Redirect\r\nLocation: /final\r\nContent-Length: 0\r\n\r\n" + .as_slice(), + b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".as_slice(), + ] { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + assert!(stream.read(&mut request).await.unwrap() > 0); + stream.write_all(response).await.unwrap(); + } + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://redirect-deadline.test:{}/start", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + + assert_eq!(response.status(), StatusCode::OK); + fixture.await.unwrap(); + } + + #[tokio::test(start_paused = true)] + async fn policy_cancellation_wins_while_response_headers_are_pending() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let fixture = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = [0u8; 4096]; + assert!(stream.read(&mut request).await.unwrap() > 0); + futures_util::future::pending::<()>().await; + }); + let settings = ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }; + let (runtime, _) = test_runtime(address, settings); + let request = parse_proxy_request( + "", + Some(&format!( + "d=http%3A%2F%2Fcancel-headers.test%3A{}%2Fresource", + address.port() + )), + ) + .unwrap(); + let context = runtime.try_request().unwrap(); + let cancel = async { + tokio::time::sleep(Duration::from_secs(5)).await; + runtime.begin_reconfigure(ProxyPolicySettings::default()); + }; + let incoming = HeaderMap::new(); + + let (result, ()) = tokio::join!( + fetch_with_redirects(&runtime, &context, &request, Method::GET, &incoming,), + cancel, + ); + + assert!(matches!(result, Err(ProxyError::Cancelled))); + fixture.abort(); + } + #[tokio::test] async fn active_html_and_svg_responses_receive_fixed_isolation_headers() { async fn active(Path(kind): Path) -> Response { @@ -3528,25 +3951,31 @@ mod tests { #[tokio::test] async fn buffered_playlist_body_retains_capacity_and_observes_cancellation() { - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - let permit = semaphore.clone().try_acquire_owned().unwrap(); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let permit = runtime.try_request().unwrap().capacity; let cancellation = tokio_util::sync::CancellationToken::new(); let body = buffered_proxy_body( bytes::Bytes::from_static(b"#EXTM3U\nsegment.ts\n"), cancellation.clone(), permit, ); - assert!(semaphore.clone().try_acquire_owned().is_err()); + assert_eq!(runtime.capacity_snapshot(), (1, 1)); cancellation.cancel(); assert!(axum::body::to_bytes(body, usize::MAX).await.is_err()); - assert!(semaphore.try_acquire_owned().is_ok()); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); } #[tokio::test(start_paused = true)] async fn streaming_body_times_out_once_and_releases_capacity() { let stream = futures_util::stream::pending::>(); - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - let permit = semaphore.clone().try_acquire_owned().unwrap(); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let permit = runtime.try_request().unwrap().capacity; let body = streaming_proxy_body( Box::pin(stream), tokio_util::sync::CancellationToken::new(), @@ -3554,25 +3983,28 @@ mod tests { ); let collect = tokio::spawn(axum::body::to_bytes(body, usize::MAX)); tokio::task::yield_now().await; - assert!(semaphore.clone().try_acquire_owned().is_err()); + assert_eq!(runtime.capacity_snapshot(), (1, 1)); tokio::time::advance(Duration::from_secs(31)).await; assert!(collect.await.unwrap().is_err()); - assert!(semaphore.try_acquire_owned().is_ok()); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); } #[tokio::test] async fn dropping_streaming_body_releases_capacity() { let stream = futures_util::stream::pending::>(); - let semaphore = Arc::new(tokio::sync::Semaphore::new(1)); - let permit = semaphore.clone().try_acquire_owned().unwrap(); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let permit = runtime.try_request().unwrap().capacity; let body = streaming_proxy_body( Box::pin(stream), tokio_util::sync::CancellationToken::new(), permit, ); - assert!(semaphore.clone().try_acquire_owned().is_err()); + assert_eq!(runtime.capacity_snapshot(), (1, 1)); drop(body); - assert!(semaphore.try_acquire_owned().is_ok()); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); } #[tokio::test] From 325fc7338de56d7a3b83a1689636bbddbc806b56 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:46:31 -0400 Subject: [PATCH 18/25] security: reclaim stalled proxy producers --- server/src/network_security/mod.rs | 4 +- server/src/network_security/runtime.rs | 175 ++- server/src/routes/proxy.rs | 1880 ++++++++++++++++++++++-- 3 files changed, 1907 insertions(+), 152 deletions(-) diff --git a/server/src/network_security/mod.rs b/server/src/network_security/mod.rs index 657eed6..658a932 100644 --- a/server/src/network_security/mod.rs +++ b/server/src/network_security/mod.rs @@ -7,10 +7,12 @@ pub(crate) use resolver::{ SystemLocalNetworkProvider, }; pub(crate) use runtime::{ - ProxyCapacityPermit, ProxyPolicySettings, ProxyRequestContext, ProxyRuntime, + ProxyPolicySettings, ProxyProducerLease, ProxyRequestContext, ProxyRuntime, }; #[cfg(test)] pub(crate) use ip::LocalNetworks; #[cfg(test)] pub(crate) use resolver::{Clock, DnsResolver, LocalNetworkProvider}; +#[cfg(test)] +pub(crate) use runtime::ProxyProducerProbe; diff --git a/server/src/network_security/runtime.rs b/server/src/network_security/runtime.rs index a7ada66..c87dcb8 100644 --- a/server/src/network_security/runtime.rs +++ b/server/src/network_security/runtime.rs @@ -6,6 +6,8 @@ use std::{ net::IpAddr, sync::{Arc, Mutex}, }; +#[cfg(test)] +use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use url::Url; @@ -21,7 +23,149 @@ pub(crate) struct ProxyPolicySettings { pub(crate) struct ProxyRequestContext { pub(crate) settings: ProxyPolicySettings, pub(crate) cancellation: CancellationToken, - pub(crate) capacity: ProxyCapacityPermit, + capacity: ProxyCapacityPermit, + #[cfg(test)] + producer_probe: Option, +} + +pub(crate) struct ProxyProducerLease { + cancellation: CancellationToken, + _capacity: ProxyCapacityPermit, + #[cfg(test)] + producer_probe: Option, +} + +#[cfg(test)] +#[derive(Clone)] +pub(crate) struct ProxyProducerProbe { + state: Arc, +} + +#[cfg(test)] +struct ProxyProducerProbeState { + signals: Mutex, + notify: Notify, +} + +#[cfg(test)] +struct ProxyProducerProbeSignals { + outcome: ProxyProducerProbeOutcome, +} + +#[cfg(test)] +#[derive(Clone, Copy, Eq, PartialEq)] +enum ProxyProducerProbeOutcome { + Pending, + Ready, + Terminated, +} + +#[cfg(test)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct ProxyProducerProbeTerminated; + +#[cfg(test)] +impl ProxyProducerProbe { + fn new() -> Self { + Self { + state: Arc::new(ProxyProducerProbeState { + signals: Mutex::new(ProxyProducerProbeSignals { + outcome: ProxyProducerProbeOutcome::Pending, + }), + notify: Notify::new(), + }), + } + } + + fn lock_signals(&self) -> std::sync::MutexGuard<'_, ProxyProducerProbeSignals> { + self.state + .signals + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn outcome( + signals: &ProxyProducerProbeSignals, + ) -> Option> { + match signals.outcome { + ProxyProducerProbeOutcome::Pending => None, + ProxyProducerProbeOutcome::Ready => Some(Ok(())), + ProxyProducerProbeOutcome::Terminated => Some(Err(ProxyProducerProbeTerminated)), + } + } + + async fn wait_for( + &self, + ready: impl Fn(&ProxyProducerProbeSignals) -> Option>, + ) -> Result<(), ProxyProducerProbeTerminated> { + loop { + let notified = self.state.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + let outcome = ready(&self.lock_signals()); + if let Some(outcome) = outcome { + return outcome; + } + notified.await; + } + } + + fn update(&self, update: impl FnOnce(&mut ProxyProducerProbeSignals)) { + update(&mut self.lock_signals()); + self.state.notify.notify_waiters(); + } + + pub(crate) fn is_full_deadline_armed(&self) -> bool { + self.lock_signals().outcome == ProxyProducerProbeOutcome::Ready + } + + pub(crate) fn is_pending(&self) -> bool { + self.lock_signals().outcome == ProxyProducerProbeOutcome::Pending + } + + pub(crate) async fn wait_for_full_deadline_armed( + &self, + ) -> Result<(), ProxyProducerProbeTerminated> { + self.wait_for(Self::outcome).await + } + + pub(crate) fn mark_full_deadline_armed(&self) { + self.update(|signals| { + if signals.outcome == ProxyProducerProbeOutcome::Pending { + signals.outcome = ProxyProducerProbeOutcome::Ready; + } + }); + } + + pub(crate) fn mark_terminated_before_ready(&self) { + self.update(|signals| { + if signals.outcome == ProxyProducerProbeOutcome::Pending { + signals.outcome = ProxyProducerProbeOutcome::Terminated; + } + }); + } +} + +impl ProxyRequestContext { + pub(crate) fn into_producer_lease(self) -> ProxyProducerLease { + ProxyProducerLease { + cancellation: self.cancellation, + _capacity: self.capacity, + #[cfg(test)] + producer_probe: self.producer_probe, + } + } +} + +impl ProxyProducerLease { + pub(crate) fn cancellation(&self) -> &CancellationToken { + &self.cancellation + } + + #[cfg(test)] + pub(crate) fn producer_probe(&self) -> Option { + self.producer_probe.clone() + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -49,7 +193,7 @@ struct ProxyCapacity { state: Mutex, } -pub(crate) struct ProxyCapacityPermit { +struct ProxyCapacityPermit { capacity: Arc, peer: ProxyPeer, } @@ -122,6 +266,8 @@ pub(crate) struct ProxyRuntime { validator: Arc, capacity: Arc, generation: Mutex, + #[cfg(test)] + next_producer_probe: Mutex>, } impl ProxyRuntime { @@ -133,6 +279,8 @@ impl ProxyRuntime { settings, cancellation: CancellationToken::new(), }), + #[cfg(test)] + next_producer_probe: Mutex::new(None), } } @@ -150,13 +298,36 @@ impl ProxyRuntime { .generation .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + #[cfg(test)] + let producer_probe = self + .next_producer_probe + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); Ok(ProxyRequestContext { settings: generation.settings, cancellation: generation.cancellation.clone(), capacity, + #[cfg(test)] + producer_probe, }) } + #[cfg(test)] + pub(crate) fn probe_next_request_producer(&self) -> ProxyProducerProbe { + let probe = ProxyProducerProbe::new(); + let previous = self + .next_producer_probe + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .replace(probe.clone()); + assert!( + previous.is_none(), + "proxy runtime already has an unclaimed producer probe" + ); + probe + } + #[cfg(test)] pub(crate) fn capacity_snapshot(&self) -> (usize, usize) { let state = self diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 8a618c0..74ac408 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -1,5 +1,7 @@ +#[cfg(test)] +use crate::network_security::ProxyProducerProbe; use crate::{ - network_security::{DestinationError, ProxyCapacityPermit, ProxyRequestContext, ProxyRuntime}, + network_security::{DestinationError, ProxyProducerLease, ProxyRequestContext, ProxyRuntime}, state::AppState, }; use axum::{ @@ -17,10 +19,13 @@ use std::{ collections::{HashMap, HashSet}, future::Future, net::{IpAddr, SocketAddr}, + panic::{AssertUnwindSafe, catch_unwind}, pin::Pin, - sync::Arc, + sync::{Arc, Mutex}, + task::{Context, Poll}, time::Duration, }; +use tokio::{sync::Notify, task::JoinHandle}; use tokio_util::sync::CancellationToken; use url::{Position, Url}; @@ -31,6 +36,9 @@ const MAX_HEADER_PAIR: usize = 8 * 1024; const RAW_CANONICAL_PATH_KEY: &str = "x-stream-path"; const RAW_CANONICAL_PATH_OPTION: &str = "&x-stream-path=raw"; const RESPONSE_HEADER_DEADLINE: Duration = Duration::from_secs(30); +const UPSTREAM_READ_IDLE_DEADLINE: Duration = Duration::from_secs(30); +const DOWNSTREAM_NO_PROGRESS_DEADLINE: Duration = Duration::from_secs(120); +const PROXY_BODY_CHUNK_SIZE: usize = 64 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ProxyError { @@ -585,27 +593,24 @@ async fn handle_proxy_suffix_for_peer( Ok(body) => body, Err(error) => return proxy_error_response(error), }; - let ProxyRequestContext { - cancellation, - capacity, - .. - } = context; return build_proxy_response( status, &upstream_headers, &effective_response_headers, - buffered_proxy_body(Bytes::from(body), cancellation, capacity), + buffered_proxy_body(Bytes::from(body), context.into_producer_lease()), true, credential_bearing, ); } - let ProxyRequestContext { - cancellation, - capacity, - .. - } = context; - let body = streaming_proxy_body(Box::pin(upstream.bytes_stream()), cancellation, capacity); + let body = streaming_proxy_body( + Box::pin( + upstream + .bytes_stream() + .map(|item| item.map_err(|_| ProxySourceError)), + ), + context.into_producer_lease(), + ); build_proxy_response( status, &upstream_headers, @@ -795,112 +800,607 @@ fn trim_ascii_ows(mut value: &[u8]) -> &[u8] { value } -fn streaming_proxy_body( - stream: UpstreamByteStream, +fn streaming_proxy_body(stream: UpstreamByteStream, lease: ProxyProducerLease) -> Body { + spawn_proxy_body(ProxyBodySource::Streaming(stream), lease).0 +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ProxySourceError; + +type UpstreamByteStream = + Pin> + Send + 'static>>; + +enum ProxyBodySource { + Streaming(UpstreamByteStream), + Buffered(Option), +} + +enum ProxySourceItem { + Chunk(Bytes), + Eof, + Failed, +} + +impl ProxyBodySource { + async fn next(&mut self) -> ProxySourceItem { + match self { + Self::Streaming(stream) => match stream.next().await { + Some(Ok(bytes)) => ProxySourceItem::Chunk(bytes), + Some(Err(_)) => ProxySourceItem::Failed, + None => ProxySourceItem::Eof, + }, + Self::Buffered(bytes) => bytes + .take() + .map_or(ProxySourceItem::Eof, ProxySourceItem::Chunk), + } + } +} + +enum ProxyHandoffSlot { + Empty, + Reserved, + Full { + bytes: Bytes, + deadline: tokio::time::Instant, + }, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum ProxyHandoffTerminal { + Running, + Clean, + FailedPending, + FailedDelivered, +} + +struct ProxyHandoffState { + slot: ProxyHandoffSlot, + terminal: ProxyHandoffTerminal, + consumer_closed: bool, +} + +struct ProxyHandoff { + state: Mutex, + producer_notify: Notify, + consumer_notify: Notify, cancellation: CancellationToken, - capacity: ProxyCapacityPermit, -) -> Body { - let stream = ProxyBodyState { - stream, - cancellation, - _capacity: capacity, - terminal: false, - }; - let stream = futures_util::stream::unfold(stream, |mut state| async move { - if state.terminal { - return None; + #[cfg(test)] + producer_probe: Option, +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum ProxyProducerStop { + ConsumerClosed, + Failed, +} + +enum ProxyConsumerItem { + Chunk(Bytes), + Failed, + Eof, +} + +impl ProxyHandoff { + fn new(cancellation: CancellationToken) -> Self { + Self { + state: Mutex::new(ProxyHandoffState { + slot: ProxyHandoffSlot::Empty, + terminal: ProxyHandoffTerminal::Running, + consumer_closed: false, + }), + producer_notify: Notify::new(), + consumer_notify: Notify::new(), + cancellation, + #[cfg(test)] + producer_probe: None, } - let next = tokio::select! { - biased; - _ = state.cancellation.cancelled() => { - state.terminal = true; - Some(Err(std::io::Error::new( - std::io::ErrorKind::ConnectionAborted, - "proxy policy changed", - ))) + } + + #[cfg(test)] + fn with_producer_probe(mut self, producer_probe: Option) -> Self { + self.producer_probe = producer_probe; + self + } + + fn lock_state(&self) -> std::sync::MutexGuard<'_, ProxyHandoffState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn fail_locked(state: &mut ProxyHandoffState) { + if !state.consumer_closed && state.terminal == ProxyHandoffTerminal::Running { + state.slot = ProxyHandoffSlot::Empty; + state.terminal = ProxyHandoffTerminal::FailedPending; + } + } + + fn fail(&self) { + let mut state = self.lock_state(); + Self::fail_locked(&mut state); + drop(state); + #[cfg(test)] + if let Some(producer_probe) = &self.producer_probe { + producer_probe.mark_terminated_before_ready(); + } + self.producer_notify.notify_waiters(); + self.consumer_notify.notify_waiters(); + } + + fn close_consumer(&self) { + let mut state = self.lock_state(); + state.consumer_closed = true; + state.slot = ProxyHandoffSlot::Empty; + drop(state); + #[cfg(test)] + if let Some(producer_probe) = &self.producer_probe { + producer_probe.mark_terminated_before_ready(); + } + self.producer_notify.notify_waiters(); + self.consumer_notify.notify_waiters(); + } + + async fn reserve(&self) -> Result<(), ProxyProducerStop> { + loop { + let notified = self.producer_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let mut state = self.lock_state(); + if state.consumer_closed { + return Err(ProxyProducerStop::ConsumerClosed); + } + if state.terminal != ProxyHandoffTerminal::Running { + return Err(ProxyProducerStop::Failed); + } + if matches!(state.slot, ProxyHandoffSlot::Empty) { + state.slot = ProxyHandoffSlot::Reserved; + return Ok(()); + } + } + tokio::select! { + biased; + _ = self.cancellation.cancelled() => { + self.fail(); + return Err(ProxyProducerStop::Failed); + } + _ = &mut notified => {} + } + } + } + + fn publish( + &self, + bytes: Bytes, + deadline: tokio::time::Instant, + ) -> Result<(), ProxyProducerStop> { + let mut state = self.lock_state(); + if state.consumer_closed { + state.slot = ProxyHandoffSlot::Empty; + return Err(ProxyProducerStop::ConsumerClosed); + } + if self.cancellation.is_cancelled() || state.terminal != ProxyHandoffTerminal::Running { + Self::fail_locked(&mut state); + drop(state); + #[cfg(test)] + if let Some(producer_probe) = &self.producer_probe { + producer_probe.mark_terminated_before_ready(); + } + self.consumer_notify.notify_waiters(); + return Err(ProxyProducerStop::Failed); + } + debug_assert!(matches!(state.slot, ProxyHandoffSlot::Reserved)); + state.slot = ProxyHandoffSlot::Full { bytes, deadline }; + #[cfg(test)] + let consumer_notification_deferred = self.producer_probe.is_some(); + drop(state); + #[cfg(test)] + if consumer_notification_deferred { + return Ok(()); + } + self.consumer_notify.notify_waiters(); + Ok(()) + } + + async fn wait_until_consumed( + &self, + deadline: tokio::time::Instant, + ) -> Result<(), ProxyProducerStop> { + let sleep = tokio::time::sleep_until(deadline); + tokio::pin!(sleep); + #[cfg(test)] + std::future::poll_fn(|context| { + let _ = sleep.as_mut().poll(context); + Poll::Ready(()) + }) + .await; + loop { + let notified = self.producer_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let state = self.lock_state(); + if state.consumer_closed { + return Err(ProxyProducerStop::ConsumerClosed); + } + if state.terminal != ProxyHandoffTerminal::Running { + return Err(ProxyProducerStop::Failed); + } + if matches!(state.slot, ProxyHandoffSlot::Empty) { + return Ok(()); + } + #[cfg(test)] + if matches!(state.slot, ProxyHandoffSlot::Full { .. }) + && let Some(producer_probe) = &self.producer_probe + { + producer_probe.mark_full_deadline_armed(); + } + } + #[cfg(test)] + if self.producer_probe.is_some() { + self.consumer_notify.notify_waiters(); + } + tokio::select! { + biased; + _ = self.cancellation.cancelled() => { + self.fail(); + return Err(ProxyProducerStop::Failed); + } + _ = &mut sleep => { + let mut state = self.lock_state(); + if state.consumer_closed { + return Err(ProxyProducerStop::ConsumerClosed); + } + if state.terminal != ProxyHandoffTerminal::Running { + return Err(ProxyProducerStop::Failed); + } + if matches!(state.slot, ProxyHandoffSlot::Empty) { + return Ok(()); + } + if let ProxyHandoffSlot::Full { + deadline: published_deadline, + .. + } = &state.slot + { + debug_assert_eq!(*published_deadline, deadline); + } + Self::fail_locked(&mut state); + drop(state); + #[cfg(test)] + if let Some(producer_probe) = &self.producer_probe { + producer_probe.mark_terminated_before_ready(); + } + self.producer_notify.notify_waiters(); + self.consumer_notify.notify_waiters(); + return Err(ProxyProducerStop::Failed); + } + _ = &mut notified => {} + } + } + } + + async fn consumer_closed(&self) { + loop { + let notified = self.producer_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.lock_state().consumer_closed { + return; } - result = tokio::time::timeout(Duration::from_secs(30), state.stream.next()) => { - match result { - Err(_) => { - state.terminal = true; - Some(Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "proxy upstream body timed out", - ))) + notified.await; + } + } + + fn clean(&self) { + let mut state = self.lock_state(); + if self.cancellation.is_cancelled() { + Self::fail_locked(&mut state); + } else if !state.consumer_closed && state.terminal == ProxyHandoffTerminal::Running { + debug_assert!(matches!(state.slot, ProxyHandoffSlot::Reserved)); + state.slot = ProxyHandoffSlot::Empty; + state.terminal = ProxyHandoffTerminal::Clean; + } + drop(state); + #[cfg(test)] + if let Some(producer_probe) = &self.producer_probe { + producer_probe.mark_terminated_before_ready(); + } + self.producer_notify.notify_waiters(); + self.consumer_notify.notify_waiters(); + } + + async fn take(&self) -> ProxyConsumerItem { + loop { + let notified = self.consumer_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let mut state = self.lock_state(); + if self.cancellation.is_cancelled() { + Self::fail_locked(&mut state); + } + if state.terminal == ProxyHandoffTerminal::Running + && let ProxyHandoffSlot::Full { deadline, .. } = &state.slot + && tokio::time::Instant::now() >= *deadline + { + Self::fail_locked(&mut state); + } + #[cfg(test)] + if state.terminal != ProxyHandoffTerminal::Running + && let Some(producer_probe) = &self.producer_probe + { + producer_probe.mark_terminated_before_ready(); + } + match state.terminal { + ProxyHandoffTerminal::FailedPending => { + state.slot = ProxyHandoffSlot::Empty; + state.terminal = ProxyHandoffTerminal::FailedDelivered; + drop(state); + self.producer_notify.notify_waiters(); + return ProxyConsumerItem::Failed; } - Ok(Some(Ok(bytes))) => Some(Ok(bytes)), - Ok(Some(Err(_))) => { - state.terminal = true; - Some(Err(std::io::Error::new( - std::io::ErrorKind::ConnectionAborted, - "proxy upstream body failed", - ))) + ProxyHandoffTerminal::FailedDelivered => return ProxyConsumerItem::Eof, + ProxyHandoffTerminal::Clean => { + debug_assert!(matches!(state.slot, ProxyHandoffSlot::Empty)); + return ProxyConsumerItem::Eof; + } + ProxyHandoffTerminal::Running => { + #[cfg(test)] + let producer_probe_pending = self + .producer_probe + .as_ref() + .is_some_and(ProxyProducerProbe::is_pending); + #[cfg(not(test))] + let producer_probe_pending = false; + if matches!(state.slot, ProxyHandoffSlot::Full { .. }) + && !producer_probe_pending + { + let ProxyHandoffSlot::Full { bytes, .. } = + std::mem::replace(&mut state.slot, ProxyHandoffSlot::Empty) + else { + unreachable!() + }; + drop(state); + self.producer_notify.notify_waiters(); + return ProxyConsumerItem::Chunk(bytes); + } } - Ok(None) => return None, } } + tokio::select! { + biased; + _ = self.cancellation.cancelled() => self.fail(), + _ = &mut notified => {} + } + } + } +} + +struct ProxyProducerGuard { + handoff: Arc, + armed: bool, +} + +struct ProxyProducerTask { + future: Option>>, +} + +impl ProxyProducerTask { + fn new(future: F) -> Self { + Self { + future: Some(Box::pin(future)), + } + } + + fn drop_future(&mut self) { + let Some(future) = self.future.take() else { + return; }; - next.map(|item| (item, state)) - }); - Body::from_stream(stream) + if let Err(payload) = catch_unwind(AssertUnwindSafe(|| drop(future))) { + drop_panic_payload(payload); + } + } } -type UpstreamByteStream = - Pin> + Send + 'static>>; +impl Future for ProxyProducerTask +where + F: Future, +{ + type Output = (); + + fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll { + let outcome = catch_unwind(AssertUnwindSafe(|| { + self.future + .as_mut() + .expect("proxy producer task polled after completion") + .as_mut() + .poll(context) + })); + match outcome { + Ok(Poll::Pending) => Poll::Pending, + Ok(Poll::Ready(())) => { + self.drop_future(); + Poll::Ready(()) + } + Err(payload) => { + drop_panic_payload(payload); + self.drop_future(); + Poll::Ready(()) + } + } + } +} -struct ProxyBodyState { - stream: UpstreamByteStream, - cancellation: CancellationToken, - _capacity: ProxyCapacityPermit, - terminal: bool, +impl Drop for ProxyProducerTask { + fn drop(&mut self) { + self.drop_future(); + } } -struct BufferedProxyBodyState { - bytes: Bytes, - offset: usize, - cancellation: CancellationToken, - _capacity: ProxyCapacityPermit, - terminal: bool, +fn drop_panic_payload(payload: Box) { + let _ = catch_unwind(AssertUnwindSafe(|| drop(payload))); } -fn buffered_proxy_body( - bytes: Bytes, - cancellation: CancellationToken, - capacity: ProxyCapacityPermit, -) -> Body { - const CHUNK_SIZE: usize = 64 * 1024; - let stream = futures_util::stream::unfold( - BufferedProxyBodyState { - bytes, - offset: 0, - cancellation, - _capacity: capacity, - terminal: false, - }, - |mut state| async move { - if state.terminal || state.offset == state.bytes.len() { - return None; +impl ProxyProducerGuard { + fn new(handoff: Arc) -> Self { + Self { + handoff, + armed: true, + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for ProxyProducerGuard { + fn drop(&mut self) { + if self.armed { + self.handoff.fail(); + } + } +} + +struct ProxyBodyConsumer { + handoff: Arc, + producer: JoinHandle<()>, +} + +struct ProxyProducerResources { + source: ProxyBodySource, + _lease: ProxyProducerLease, +} + +impl Drop for ProxyBodyConsumer { + fn drop(&mut self) { + self.handoff.close_consumer(); + self.producer.abort(); + } +} + +async fn read_source_chunk( + source: &mut ProxyBodySource, + handoff: &ProxyHandoff, +) -> Result, ProxyProducerStop> { + let deadline = tokio::time::sleep(UPSTREAM_READ_IDLE_DEADLINE); + tokio::pin!(deadline); + loop { + tokio::select! { + biased; + _ = handoff.cancellation.cancelled() => { + handoff.fail(); + return Err(ProxyProducerStop::Failed); + } + _ = handoff.consumer_closed() => { + return Err(ProxyProducerStop::ConsumerClosed); + } + _ = &mut deadline => { + handoff.fail(); + return Err(ProxyProducerStop::Failed); + } + item = source.next() => match item { + ProxySourceItem::Chunk(bytes) if bytes.is_empty() => { + tokio::task::yield_now().await; + } + ProxySourceItem::Chunk(bytes) => return Ok(Some(bytes)), + ProxySourceItem::Eof => return Ok(None), + ProxySourceItem::Failed => { + handoff.fail(); + return Err(ProxyProducerStop::Failed); + } + } + } + } +} + +async fn run_proxy_body_producer( + mut resources: ProxyProducerResources, + handoff: Arc, + mut guard: ProxyProducerGuard, +) { + loop { + if handoff.reserve().await.is_err() { + guard.disarm(); + return; + } + let bytes = match read_source_chunk(&mut resources.source, &handoff).await { + Ok(Some(bytes)) => bytes, + Ok(None) => { + drop(resources); + handoff.clean(); + guard.disarm(); + return; + } + Err(_) => { + guard.disarm(); + return; + } + }; + + let mut offset = 0usize; + while offset < bytes.len() { + if offset != 0 && handoff.reserve().await.is_err() { + guard.disarm(); + return; + } + let end = offset + .saturating_add(PROXY_BODY_CHUNK_SIZE) + .min(bytes.len()); + let chunk = Bytes::copy_from_slice(&bytes[offset..end]); + let deadline = tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE; + if handoff.publish(chunk, deadline).is_err() { + guard.disarm(); + return; } - if state.cancellation.is_cancelled() { - state.terminal = true; - return Some(( - Err(std::io::Error::new( - std::io::ErrorKind::ConnectionAborted, - "proxy policy changed", - )), - state, - )); + if handoff.wait_until_consumed(deadline).await.is_err() { + guard.disarm(); + return; } - let end = state - .offset - .saturating_add(CHUNK_SIZE) - .min(state.bytes.len()); - let chunk = state.bytes.slice(state.offset..end); - state.offset = end; - Some((Ok::<_, std::io::Error>(chunk), state)) + offset = end; + } + } +} + +fn spawn_proxy_body( + source: ProxyBodySource, + lease: ProxyProducerLease, +) -> (Body, tokio::task::AbortHandle) { + let handoff = ProxyHandoff::new(lease.cancellation().clone()); + #[cfg(test)] + let handoff = handoff.with_producer_probe(lease.producer_probe()); + let handoff = Arc::new(handoff); + let guard = ProxyProducerGuard::new(handoff.clone()); + let producer_handoff = handoff.clone(); + let producer = tokio::spawn(ProxyProducerTask::new(run_proxy_body_producer( + ProxyProducerResources { + source, + _lease: lease, }, - ); - Body::from_stream(stream) + producer_handoff, + guard, + ))); + let abort = producer.abort_handle(); + let consumer = ProxyBodyConsumer { handoff, producer }; + let stream = futures_util::stream::unfold(consumer, |consumer| async move { + let item = consumer.handoff.take().await; + match item { + ProxyConsumerItem::Chunk(bytes) => Some((Ok(bytes), consumer)), + ProxyConsumerItem::Failed => Some(( + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionAborted, + "proxy response body failed", + )), + consumer, + )), + ProxyConsumerItem::Eof => None, + } + }); + (Body::from_stream(stream), abort) +} + +fn buffered_proxy_body(bytes: Bytes, lease: ProxyProducerLease) -> Body { + spawn_proxy_body(ProxyBodySource::Buffered(Some(bytes)), lease).0 } const MAX_PLAYLIST_INPUT: usize = 8 * 1024 * 1024; @@ -1701,12 +2201,14 @@ fn reserve_bounded( #[cfg(test)] mod tests { use super::{ - HLS_SCHEME_PRESCAN_BYTES, HLS_VARIABLE_RANGE_SCANS, MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, - MAX_PLAYLIST_OUTPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, ProxyError, - apply_redirect_origin_policy, await_response_headers, buffered_proxy_body, - collect_playlist, fetch_with_redirects, handle_proxy, handle_proxy_suffix, - parse_proxy_request, parse_proxy_suffix, proxy_error_response, resolve_hls_reference, - rewrite_playlist_bounded, rewrite_playlist_with_options, runtime_service, same_origin, + DOWNSTREAM_NO_PROGRESS_DEADLINE, HLS_SCHEME_PRESCAN_BYTES, HLS_VARIABLE_RANGE_SCANS, + MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, MAX_PLAYLIST_OUTPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, + PROXY_BODY_CHUNK_SIZE, ProxyBodySource, ProxyConsumerItem, ProxyError, ProxyHandoff, + ProxyHandoffSlot, ProxyProducerStop, ProxySourceError, apply_redirect_origin_policy, + await_response_headers, buffered_proxy_body, collect_playlist, fetch_with_redirects, + handle_proxy, handle_proxy_suffix, parse_proxy_request, parse_proxy_suffix, + proxy_error_response, resolve_hls_reference, rewrite_playlist_bounded, + rewrite_playlist_with_options, runtime_service, same_origin, spawn_proxy_body, streaming_proxy_body, }; use crate::network_security::{ @@ -1722,16 +2224,22 @@ mod tests { response::{IntoResponse, Response}, routing::{any, get}, }; + use futures_util::StreamExt; use std::{ + collections::VecDeque, + future::Future, io, - net::{IpAddr, Ipv4Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + pin::Pin, sync::{ Arc, atomic::{AtomicUsize, Ordering}, }, + task::{Context, Poll}, time::{Duration, Instant}, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_util::sync::CancellationToken; use url::Url; fn assert_response_isolated(response: &Response) { @@ -2382,6 +2890,20 @@ mod tests { .await } + async fn chunk_then_stalled_upstream_fixture() -> (SocketAddr, tokio::task::JoinHandle<()>) { + fixture(Router::new().fallback(any(|| async { + let stream = futures_util::stream::iter([Ok::<_, std::io::Error>( + bytes::Bytes::from_static(b"network-chunk"), + )]) + .chain(futures_util::stream::pending()); + Response::builder() + .status(StatusCode::OK) + .body(Body::from_stream(stream)) + .unwrap() + }))) + .await + } + async fn proxy_router_fixture( runtime: Arc, with_connect_info: bool, @@ -3955,55 +4477,1115 @@ mod tests { "127.0.0.1:1".parse().unwrap(), ProxyPolicySettings::default(), ); - let permit = runtime.try_request().unwrap().capacity; - let cancellation = tokio_util::sync::CancellationToken::new(); - let body = buffered_proxy_body( - bytes::Bytes::from_static(b"#EXTM3U\nsegment.ts\n"), - cancellation.clone(), - permit, - ); + let context = runtime.try_request().unwrap(); + let cancellation = context.cancellation.clone(); + let lease = context.into_producer_lease(); + let body = buffered_proxy_body(bytes::Bytes::from_static(b"#EXTM3U\nsegment.ts\n"), lease); assert_eq!(runtime.capacity_snapshot(), (1, 1)); cancellation.cancel(); assert!(axum::body::to_bytes(body, usize::MAX).await.is_err()); + wait_for_capacity(&runtime, (0, 0)).await; assert_eq!(runtime.capacity_snapshot(), (0, 0)); } - #[tokio::test(start_paused = true)] - async fn streaming_body_times_out_once_and_releases_capacity() { - let stream = futures_util::stream::pending::>(); - let (runtime, _) = test_runtime( - "127.0.0.1:1".parse().unwrap(), - ProxyPolicySettings::default(), - ); - let permit = runtime.try_request().unwrap().capacity; - let body = streaming_proxy_body( - Box::pin(stream), - tokio_util::sync::CancellationToken::new(), - permit, + async fn wait_until(mut ready: impl FnMut() -> bool) { + for _ in 0..64 { + if ready() { + return; + } + tokio::task::yield_now().await; + } + assert!( + ready(), + "condition did not become ready after bounded yields" ); - let collect = tokio::spawn(axum::body::to_bytes(body, usize::MAX)); - tokio::task::yield_now().await; - assert_eq!(runtime.capacity_snapshot(), (1, 1)); - tokio::time::advance(Duration::from_secs(31)).await; - assert!(collect.await.unwrap().is_err()); - assert_eq!(runtime.capacity_snapshot(), (0, 0)); } - #[tokio::test] - async fn dropping_streaming_body_releases_capacity() { - let stream = futures_util::stream::pending::>(); - let (runtime, _) = test_runtime( - "127.0.0.1:1".parse().unwrap(), - ProxyPolicySettings::default(), - ); - let permit = runtime.try_request().unwrap().capacity; - let body = streaming_proxy_body( - Box::pin(stream), - tokio_util::sync::CancellationToken::new(), - permit, - ); - assert_eq!(runtime.capacity_snapshot(), (1, 1)); - drop(body); + async fn wait_for_capacity(runtime: &ProxyRuntime, expected: (usize, usize)) { + wait_until(|| runtime.capacity_snapshot() == expected).await; + } + + fn poll_once(mut future: Pin<&mut F>) -> Poll { + let mut context = Context::from_waker(futures_util::task::noop_waker_ref()); + future.as_mut().poll(&mut context) + } + + async fn assert_body_error_then_eof(body: Body) { + let mut stream = body.into_data_stream(); + assert!(stream.next().await.unwrap().is_err()); + assert!(stream.next().await.is_none()); + } + + enum TestStreamStep { + Chunk(bytes::Bytes), + Error, + Pending, + Panic, + PanicWithPayload(Arc), + } + + struct TestByteStream { + steps: VecDeque, + polls: Arc, + drops: Arc, + } + + impl TestByteStream { + fn new( + steps: impl IntoIterator, + ) -> (Self, Arc, Arc) { + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + ( + Self { + steps: steps.into_iter().collect(), + polls: polls.clone(), + drops: drops.clone(), + }, + polls, + drops, + ) + } + } + + impl futures_util::Stream for TestByteStream { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + _context: &mut Context<'_>, + ) -> Poll> { + self.polls.fetch_add(1, Ordering::SeqCst); + match self.steps.front() { + Some(TestStreamStep::Pending) => Poll::Pending, + Some(TestStreamStep::Error) => { + self.steps.pop_front(); + Poll::Ready(Some(Err(ProxySourceError))) + } + Some(TestStreamStep::Panic) => { + self.steps.pop_front(); + panic!("controlled proxy producer panic"); + } + Some(TestStreamStep::PanicWithPayload(_)) => { + let Some(TestStreamStep::PanicWithPayload(drops)) = self.steps.pop_front() + else { + unreachable!() + }; + std::panic::panic_any(TrackedPanicPayload { drops }); + } + Some(TestStreamStep::Chunk(_)) => { + let Some(TestStreamStep::Chunk(bytes)) = self.steps.pop_front() else { + unreachable!() + }; + Poll::Ready(Some(Ok(bytes))) + } + None => Poll::Ready(None), + } + } + } + + impl Drop for TestByteStream { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + struct TrackedBytesOwner { + bytes: Vec, + drops: Arc, + } + + impl AsRef<[u8]> for TrackedBytesOwner { + fn as_ref(&self) -> &[u8] { + &self.bytes + } + } + + impl Drop for TrackedBytesOwner { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + struct TrackedPanicPayload { + drops: Arc, + } + + impl Drop for TrackedPanicPayload { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + struct PanicOnDropAfterEofStream { + returned_eof: bool, + payload_drops: Arc, + } + + impl futures_util::Stream for PanicOnDropAfterEofStream { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + _context: &mut Context<'_>, + ) -> Poll> { + self.returned_eof = true; + Poll::Ready(None) + } + } + + impl Drop for PanicOnDropAfterEofStream { + fn drop(&mut self) { + if self.returned_eof { + std::panic::panic_any(TrackedPanicPayload { + drops: self.payload_drops.clone(), + }); + } + } + } + + struct PanicOnDropPendingStream { + polled: Arc, + payload_drops: Arc, + } + + impl futures_util::Stream for PanicOnDropPendingStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _context: &mut Context<'_>, + ) -> Poll> { + self.polled.fetch_add(1, Ordering::SeqCst); + Poll::Pending + } + } + + impl Drop for PanicOnDropPendingStream { + fn drop(&mut self) { + std::panic::panic_any(TrackedPanicPayload { + drops: self.payload_drops.clone(), + }); + } + } + + struct ReadyEmptyStream { + polls: Arc, + drops: Arc, + } + + impl futures_util::Stream for ReadyEmptyStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _context: &mut Context<'_>, + ) -> Poll> { + self.polls.fetch_add(1, Ordering::SeqCst); + Poll::Ready(Some(Ok(bytes::Bytes::new()))) + } + } + + impl Drop for ReadyEmptyStream { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + struct CancelThenEofStream { + cancellation: tokio_util::sync::CancellationToken, + } + + impl futures_util::Stream for CancelThenEofStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _context: &mut Context<'_>, + ) -> Poll> { + self.cancellation.cancel(); + Poll::Ready(None) + } + } + + struct OneThenPendingStream { + chunk: Option, + polls: Arc, + drops: Arc, + } + + impl futures_util::Stream for OneThenPendingStream { + type Item = Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + _context: &mut Context<'_>, + ) -> Poll> { + self.polls.fetch_add(1, Ordering::SeqCst); + self.chunk + .take() + .map_or(Poll::Pending, |chunk| Poll::Ready(Some(Ok(chunk)))) + } + } + + impl Drop for OneThenPendingStream { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + #[tokio::test] + async fn request_probes_are_runtime_isolated_and_mark_only_a_full_handoff() { + let (pending_runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let (full_runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let pending_probe = pending_runtime.probe_next_request_producer(); + let full_probe = full_runtime.probe_next_request_producer(); + let (pending_stream, pending_polls, _) = TestByteStream::new([TestStreamStep::Pending]); + let pending_body = streaming_proxy_body( + Box::pin(pending_stream), + pending_runtime.try_request().unwrap().into_producer_lease(), + ); + let (full_stream, _, _) = TestByteStream::new([ + TestStreamStep::Chunk(bytes::Bytes::from_static(b"full")), + TestStreamStep::Pending, + ]); + let full_body = streaming_proxy_body( + Box::pin(full_stream), + full_runtime.try_request().unwrap().into_producer_lease(), + ); + + full_probe.wait_for_full_deadline_armed().await.unwrap(); + wait_until(|| pending_polls.load(Ordering::SeqCst) == 1).await; + assert!(!pending_probe.is_full_deadline_armed()); + + drop(pending_body); + drop(full_body); + assert!(pending_probe.wait_for_full_deadline_armed().await.is_err()); + wait_for_capacity(&pending_runtime, (0, 0)).await; + wait_for_capacity(&full_runtime, (0, 0)).await; + } + + #[tokio::test] + async fn pending_probe_reports_cancellation_instead_of_stranding_its_waiter() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let producer_probe = runtime.probe_next_request_producer(); + let context = runtime.try_request().unwrap(); + let cancellation = context.cancellation.clone(); + let (stream, polls, _) = TestByteStream::new([TestStreamStep::Pending]); + let body = streaming_proxy_body(Box::pin(stream), context.into_producer_lease()); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + cancellation.cancel(); + + assert!(producer_probe.wait_for_full_deadline_armed().await.is_err()); + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + } + + #[tokio::test] + async fn waiting_consumer_stays_pending_until_the_full_deadline_is_armed() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let producer_probe = runtime.probe_next_request_producer(); + let lease = runtime.try_request().unwrap().into_producer_lease(); + let handoff = Arc::new( + ProxyHandoff::new(lease.cancellation().clone()) + .with_producer_probe(lease.producer_probe()), + ); + assert!(handoff.reserve().await.is_ok()); + let deadline = tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE; + let mut consumer = Box::pin(handoff.take()); + assert!(poll_once(consumer.as_mut()).is_pending()); + assert!( + handoff + .publish(bytes::Bytes::from_static(b"published"), deadline) + .is_ok() + ); + assert!(poll_once(consumer.as_mut()).is_pending()); + + let mut producer = Box::pin(handoff.wait_until_consumed(deadline)); + assert!(poll_once(producer.as_mut()).is_pending()); + assert!(producer_probe.is_full_deadline_armed()); + match poll_once(consumer.as_mut()) { + Poll::Ready(ProxyConsumerItem::Chunk(bytes)) => assert_eq!(bytes, "published"), + Poll::Pending => panic!("consumer stayed pending after deadline readiness"), + Poll::Ready(ProxyConsumerItem::Failed | ProxyConsumerItem::Eof) => { + panic!("consumer did not receive the published chunk") + } + } + assert!(matches!(poll_once(producer.as_mut()), Poll::Ready(Ok(())))); + + drop(lease); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + } + + #[tokio::test] + async fn first_consumer_poll_after_full_waits_for_armed_deadline() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let producer_probe = runtime.probe_next_request_producer(); + let lease = runtime.try_request().unwrap().into_producer_lease(); + let handoff = Arc::new( + ProxyHandoff::new(lease.cancellation().clone()) + .with_producer_probe(lease.producer_probe()), + ); + assert!(handoff.reserve().await.is_ok()); + let deadline = tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE; + assert!( + handoff + .publish(bytes::Bytes::from_static(b"published"), deadline) + .is_ok() + ); + + let mut consumer = Box::pin(handoff.take()); + assert!(poll_once(consumer.as_mut()).is_pending()); + assert!(matches!( + handoff.lock_state().slot, + ProxyHandoffSlot::Full { .. } + )); + + let mut producer = Box::pin(handoff.wait_until_consumed(deadline)); + assert!(poll_once(producer.as_mut()).is_pending()); + assert!(producer_probe.is_full_deadline_armed()); + match poll_once(consumer.as_mut()) { + Poll::Ready(ProxyConsumerItem::Chunk(bytes)) => assert_eq!(bytes, "published"), + Poll::Pending => panic!("consumer stayed pending after deadline readiness"), + Poll::Ready(ProxyConsumerItem::Failed | ProxyConsumerItem::Eof) => { + panic!("consumer did not receive the published chunk") + } + } + assert!(matches!(poll_once(producer.as_mut()), Poll::Ready(Ok(())))); + + drop(lease); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + } + + #[tokio::test(start_paused = true)] + async fn timely_take_wins_when_producer_observes_notify_after_deadline() { + let handoff = ProxyHandoff::new(CancellationToken::new()); + assert!(handoff.reserve().await.is_ok()); + let deadline = tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE; + assert!( + handoff + .publish(bytes::Bytes::from_static(b"timely"), deadline) + .is_ok() + ); + let mut producer = Box::pin(handoff.wait_until_consumed(deadline)); + assert!(poll_once(producer.as_mut()).is_pending()); + + tokio::time::advance(Duration::from_secs(119)).await; + match handoff.take().await { + ProxyConsumerItem::Chunk(bytes) => assert_eq!(bytes, "timely"), + ProxyConsumerItem::Failed | ProxyConsumerItem::Eof => { + panic!("timely consumer did not receive the published chunk") + } + } + tokio::time::advance(Duration::from_secs(2)).await; + + assert!(matches!(poll_once(producer.as_mut()), Poll::Ready(Ok(())))); + } + + #[tokio::test(start_paused = true)] + async fn late_take_fails_without_delivering_expired_chunk() { + let handoff = ProxyHandoff::new(CancellationToken::new()); + assert!(handoff.reserve().await.is_ok()); + let deadline = tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE; + assert!( + handoff + .publish(bytes::Bytes::from_static(b"expired"), deadline) + .is_ok() + ); + let mut producer = Box::pin(handoff.wait_until_consumed(deadline)); + assert!(poll_once(producer.as_mut()).is_pending()); + + tokio::time::advance(Duration::from_secs(121)).await; + + assert!(matches!(handoff.take().await, ProxyConsumerItem::Failed)); + assert!(matches!(handoff.take().await, ProxyConsumerItem::Eof)); + assert!(matches!( + poll_once(producer.as_mut()), + Poll::Ready(Err(ProxyProducerStop::Failed)) + )); + } + + #[tokio::test(start_paused = true)] + async fn unpolled_body_reads_one_chunk_then_stall_reclaims_capacity() { + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let payload_drops = Arc::new(AtomicUsize::new(0)); + let stream = OneThenPendingStream { + chunk: Some(bytes::Bytes::from_owner(TrackedBytesOwner { + bytes: b"queued".to_vec(), + drops: payload_drops.clone(), + })), + polls: polls.clone(), + drops: drops.clone(), + }; + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let (body, producer) = spawn_proxy_body( + ProxyBodySource::Streaming(Box::pin(stream)), + context.into_producer_lease(), + ); + + for _ in 0..16 { + if polls.load(Ordering::SeqCst) == 1 { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + + tokio::time::advance(Duration::from_secs(121)).await; + for _ in 0..16 { + if runtime.capacity_snapshot() == (0, 0) { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(polls.load(Ordering::SeqCst), 1); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!(payload_drops.load(Ordering::SeqCst), 1); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + wait_until(|| producer.is_finished()).await; + assert!(producer.is_finished()); + + let mut body = body.into_data_stream(); + assert!(body.next().await.unwrap().is_err()); + assert!(body.next().await.is_none()); + } + + #[tokio::test] + async fn dropping_full_handoff_clears_payload_and_stops_before_a_second_poll() { + let (stream, polls, drops) = TestByteStream::new([ + TestStreamStep::Chunk(bytes::Bytes::from_static(b"queued")), + TestStreamStep::Pending, + ]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + drop(body); + + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(polls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn dropping_body_while_upstream_read_is_pending_reclaims_without_a_timer() { + let (stream, polls, drops) = TestByteStream::new([TestStreamStep::Pending]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + drop(body); + + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(polls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn cancellation_while_handoff_is_full_discards_data_then_errors_once() { + let (stream, polls, drops) = TestByteStream::new([ + TestStreamStep::Chunk(bytes::Bytes::from_static(b"must-not-escape")), + TestStreamStep::Pending, + ]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let cancellation = context.cancellation.clone(); + let body = streaming_proxy_body(Box::pin(stream), context.into_producer_lease()); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + cancellation.cancel(); + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(polls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn cancellation_while_upstream_read_is_pending_errors_and_reclaims_immediately() { + let (stream, polls, drops) = TestByteStream::new([TestStreamStep::Pending]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let cancellation = context.cancellation.clone(); + let body = streaming_proxy_body(Box::pin(stream), context.into_producer_lease()); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + cancellation.cancel(); + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(polls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn cancellation_triggered_by_the_eof_poll_wins_over_clean_eof() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let stream = CancelThenEofStream { + cancellation: context.cancellation.clone(), + }; + let body = streaming_proxy_body(Box::pin(stream), context.into_producer_lease()); + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + } + + #[tokio::test(start_paused = true)] + async fn pending_upstream_read_times_out_with_one_error_then_eof() { + let (stream, polls, drops) = TestByteStream::new([TestStreamStep::Pending]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + tokio::time::advance(Duration::from_secs(31)).await; + assert_body_error_then_eof(body).await; + + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + } + + #[tokio::test] + async fn upstream_source_error_yields_one_generic_error_then_eof() { + let (stream, polls, drops) = TestByteStream::new([TestStreamStep::Error]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + + assert_body_error_then_eof(body).await; + + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(polls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn clean_finite_stream_preserves_content_order_and_reclaims_everything() { + let (stream, polls, drops) = TestByteStream::new([ + TestStreamStep::Chunk(bytes::Bytes::from_static(b"first-")), + TestStreamStep::Chunk(bytes::Bytes::from_static(b"second")), + ]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + + assert_eq!( + axum::body::to_bytes(body, usize::MAX).await.unwrap(), + "first-second" + ); + + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(polls.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn producer_panic_wakes_retained_body_and_fails_closed() { + let (stream, polls, drops) = TestByteStream::new([TestStreamStep::Panic]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let (body, producer) = spawn_proxy_body( + ProxyBodySource::Streaming(Box::pin(stream)), + runtime.try_request().unwrap().into_producer_lease(), + ); + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + wait_until(|| producer.is_finished()).await; + + assert_body_error_then_eof(body).await; + + wait_for_capacity(&runtime, (0, 0)).await; + assert_eq!(polls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn producer_panic_payload_is_dropped_while_body_remains_retained() { + let payload_drops = Arc::new(AtomicUsize::new(0)); + let (stream, _, source_drops) = + TestByteStream::new([TestStreamStep::PanicWithPayload(payload_drops.clone())]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + + wait_until(|| source_drops.load(Ordering::SeqCst) == 1).await; + wait_for_capacity(&runtime, (0, 0)).await; + assert_eq!(payload_drops.load(Ordering::SeqCst), 1); + assert_body_error_then_eof(body).await; + } + + #[tokio::test] + async fn source_drop_panic_after_eof_overrides_clean_terminal_state() { + let payload_drops = Arc::new(AtomicUsize::new(0)); + let stream = PanicOnDropAfterEofStream { + returned_eof: false, + payload_drops: payload_drops.clone(), + }; + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + assert_eq!(payload_drops.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn explicit_producer_abort_wakes_retained_body_and_fails_closed() { + let (stream, polls, drops) = TestByteStream::new([TestStreamStep::Pending]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let (body, abort) = spawn_proxy_body( + ProxyBodySource::Streaming(Box::pin(stream)), + context.into_producer_lease(), + ); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + abort.abort(); + wait_until(|| abort.is_finished()).await; + assert!(abort.is_finished()); + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + } + + #[tokio::test] + async fn explicit_abort_drops_a_panicking_source_without_retaining_its_payload() { + let polls = Arc::new(AtomicUsize::new(0)); + let payload_drops = Arc::new(AtomicUsize::new(0)); + let stream = PanicOnDropPendingStream { + polled: polls.clone(), + payload_drops: payload_drops.clone(), + }; + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let (body, abort) = spawn_proxy_body( + ProxyBodySource::Streaming(Box::pin(stream)), + runtime.try_request().unwrap().into_producer_lease(), + ); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + abort.abort(); + + wait_for_capacity(&runtime, (0, 0)).await; + assert_eq!(payload_drops.load(Ordering::SeqCst), 1); + assert_body_error_then_eof(body).await; + } + + #[tokio::test] + async fn abort_before_first_producer_poll_still_fails_closed_and_reclaims() { + let (stream, polls, drops) = TestByteStream::new([TestStreamStep::Pending]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let (body, abort) = spawn_proxy_body( + ProxyBodySource::Streaming(Box::pin(stream)), + context.into_producer_lease(), + ); + + abort.abort(); + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(polls.load(Ordering::SeqCst), 0); + } + + #[tokio::test(start_paused = true)] + async fn slow_progress_resets_each_downstream_deadline_without_a_total_limit() { + let (stream, polls, drops) = TestByteStream::new([ + TestStreamStep::Chunk(bytes::Bytes::from_static(b"one")), + TestStreamStep::Chunk(bytes::Bytes::from_static(b"two")), + TestStreamStep::Chunk(bytes::Bytes::from_static(b"three")), + ]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + let mut body = body.into_data_stream(); + let started = tokio::time::Instant::now(); + + for (index, expected) in [b"one".as_slice(), b"two", b"three"] + .into_iter() + .enumerate() + { + wait_until(|| polls.load(Ordering::SeqCst) > index).await; + tokio::time::advance(Duration::from_secs(119)).await; + assert_eq!(body.next().await.unwrap().unwrap(), expected); + } + assert!(body.next().await.is_none()); + + assert!(tokio::time::Instant::now().duration_since(started) > Duration::from_secs(350)); + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(polls.load(Ordering::SeqCst), 4); + } + + #[tokio::test] + async fn buffered_large_source_crosses_as_independent_bounded_chunks() { + let owner_drops = Arc::new(AtomicUsize::new(0)); + let source = bytes::Bytes::from_owner(TrackedBytesOwner { + bytes: (0..PROXY_BODY_CHUNK_SIZE + 17) + .map(|index| (index % 251) as u8) + .collect(), + drops: owner_drops.clone(), + }); + let expected = source.to_vec(); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = + buffered_proxy_body(source, runtime.try_request().unwrap().into_producer_lease()); + let mut stream = body.into_data_stream(); + let mut retained_chunks = Vec::new(); + let mut actual = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.unwrap(); + assert!(!chunk.is_empty()); + assert!(chunk.len() <= PROXY_BODY_CHUNK_SIZE); + actual.extend_from_slice(&chunk); + retained_chunks.push(chunk); + } + + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| owner_drops.load(Ordering::SeqCst) == 1).await; + assert_eq!(retained_chunks.len(), 2); + assert_eq!(actual, expected); + assert_eq!(owner_drops.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn dropping_unpolled_buffered_body_reclaims_its_source_and_permit() { + let owner_drops = Arc::new(AtomicUsize::new(0)); + let source = bytes::Bytes::from_owner(TrackedBytesOwner { + bytes: vec![b'x'; PROXY_BODY_CHUNK_SIZE + 1], + drops: owner_drops.clone(), + }); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = + buffered_proxy_body(source, runtime.try_request().unwrap().into_producer_lease()); + tokio::task::yield_now().await; + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + assert_eq!(owner_drops.load(Ordering::SeqCst), 0); + + drop(body); + + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| owner_drops.load(Ordering::SeqCst) == 1).await; + } + + #[tokio::test(start_paused = true)] + async fn empty_chunks_do_not_reset_the_persistent_upstream_idle_deadline() { + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel::(); + let polls = Arc::new(AtomicUsize::new(0)); + let stream_polls = polls.clone(); + let stream = futures_util::stream::poll_fn(move |context| { + stream_polls.fetch_add(1, Ordering::SeqCst); + receiver + .poll_recv(context) + .map(|item| item.map(Ok::<_, ProxySourceError>)) + }); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + wait_until(|| polls.load(Ordering::SeqCst) >= 1).await; + + tokio::time::advance(Duration::from_secs(29)).await; + sender.send(bytes::Bytes::new()).unwrap(); + wait_until(|| polls.load(Ordering::SeqCst) >= 2).await; + tokio::time::advance(Duration::from_secs(2)).await; + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + } + + #[tokio::test(start_paused = true)] + async fn always_ready_empty_chunks_cannot_starve_the_read_idle_deadline() { + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let stream = ReadyEmptyStream { + polls: polls.clone(), + drops: drops.clone(), + }; + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + wait_until(|| polls.load(Ordering::SeqCst) >= 2).await; + + tokio::time::advance(Duration::from_secs(31)).await; + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + } + + #[tokio::test(start_paused = true)] + async fn retained_stalled_body_releases_same_peer_capacity_for_re_admission() { + let (stream, polls, drops) = TestByteStream::new([ + TestStreamStep::Chunk(bytes::Bytes::from_static(b"held")), + TestStreamStep::Pending, + ]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime.try_request().unwrap().into_producer_lease(), + ); + let blockers: Vec<_> = (0..15).map(|_| runtime.try_request().unwrap()).collect(); + assert!(runtime.try_request().is_err()); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + tokio::time::advance(Duration::from_secs(121)).await; + wait_for_capacity(&runtime, (15, 1)).await; + let replacement = runtime.try_request().unwrap(); + + assert_body_error_then_eof(body).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + drop(replacement); + drop(blockers); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + } + + #[tokio::test(start_paused = true)] + async fn retained_stalled_body_releases_global_capacity_for_re_admission() { + let (stream, polls, drops) = TestByteStream::new([ + TestStreamStep::Chunk(bytes::Bytes::from_static(b"held")), + TestStreamStep::Pending, + ]); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let body = streaming_proxy_body( + Box::pin(stream), + runtime + .try_request_for_peer(Some(IpAddr::V4(Ipv4Addr::new(192, 0, 2, 1)))) + .unwrap() + .into_producer_lease(), + ); + let blockers: Vec<_> = (2..=64u16) + .map(|index| { + runtime + .try_request_for_peer(Some(IpAddr::V6(Ipv6Addr::from(u128::from(index))))) + .unwrap() + }) + .collect(); + assert!( + runtime + .try_request_for_peer(Some(IpAddr::V6(Ipv6Addr::from(65u128)))) + .is_err() + ); + wait_until(|| polls.load(Ordering::SeqCst) == 1).await; + + tokio::time::advance(Duration::from_secs(121)).await; + wait_for_capacity(&runtime, (63, 63)).await; + let replacement = runtime + .try_request_for_peer(Some(IpAddr::V6(Ipv6Addr::from(65u128)))) + .unwrap(); + + assert_body_error_then_eof(body).await; + wait_until(|| drops.load(Ordering::SeqCst) == 1).await; + drop(replacement); + drop(blockers); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + } + + #[tokio::test] + async fn retained_real_axum_handler_response_reclaims_its_producer() { + let (upstream_address, upstream) = chunk_then_stalled_upstream_fixture().await; + let (runtime, _) = test_runtime( + upstream_address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://fixture.test:{}/body", upstream_address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + let producer_probe = runtime.probe_next_request_producer(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + producer_probe.wait_for_full_deadline_armed().await.unwrap(); + tokio::time::pause(); + + tokio::time::advance(Duration::from_secs(31)).await; + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + tokio::time::advance(Duration::from_secs(90)).await; + wait_for_capacity(&runtime, (0, 0)).await; + assert_body_error_then_eof(response.into_body()).await; + upstream.abort(); + } + + #[tokio::test] + async fn retained_real_axum_service_response_reclaims_its_producer() { + let (upstream_address, upstream) = chunk_then_stalled_upstream_fixture().await; + let (runtime, _) = test_runtime( + upstream_address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let runtime = Arc::new(runtime); + let (proxy_address, proxy) = proxy_router_fixture(runtime.clone(), true).await; + let target = format!( + "http://stalled-upstream.test:{}/resource", + upstream_address.port() + ); + let url = format!( + "http://{proxy_address}/proxy/?d={}", + urlencoding::encode(&target) + ); + let producer_probe = runtime.probe_next_request_producer(); + + let response = reqwest::Client::new().get(url).send().await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + producer_probe.wait_for_full_deadline_armed().await.unwrap(); + tokio::time::pause(); + + tokio::time::advance(Duration::from_secs(121)).await; + wait_for_capacity(&runtime, (0, 0)).await; + + drop(response); + proxy.abort(); + upstream.abort(); + } + + #[tokio::test(start_paused = true)] + async fn streaming_body_times_out_once_and_releases_capacity() { + let stream = futures_util::stream::pending::>(); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let body = streaming_proxy_body(Box::pin(stream), context.into_producer_lease()); + let collect = tokio::spawn(axum::body::to_bytes(body, usize::MAX)); + tokio::task::yield_now().await; + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + tokio::time::advance(Duration::from_secs(31)).await; + assert!(collect.await.unwrap().is_err()); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + } + + #[tokio::test] + async fn dropping_streaming_body_releases_capacity() { + let stream = futures_util::stream::pending::>(); + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let body = streaming_proxy_body(Box::pin(stream), context.into_producer_lease()); + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + drop(body); + for _ in 0..16 { + if runtime.capacity_snapshot() == (0, 0) { + break; + } + tokio::task::yield_now().await; + } assert_eq!(runtime.capacity_snapshot(), (0, 0)); } From a8a9c7efdf9bba6a0be38a61298f9d4409c542c4 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 21 Aug 2026 09:53:39 -0400 Subject: [PATCH 19/25] security: close proxy audit gaps --- server/src/routes/proxy.rs | 169 ++++++++++++++++++++++++++++++++++--- 1 file changed, 158 insertions(+), 11 deletions(-) diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 74ac408..532607b 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -74,6 +74,7 @@ struct FetchedProxyResponse { final_url: Url, effective_custom_request_headers: HeaderMap, effective_response_headers: HeaderMap, + credential_bearing: bool, } #[cfg(test)] @@ -271,8 +272,8 @@ fn validate_proxy_target(mut target: Url) -> Result { fn parse_custom_header(value: &str) -> Result<(HeaderName, HeaderValue), ProxyError> { let (name, value) = value.split_once(':').ok_or(ProxyError::InvalidRequest)?; - let name = name.trim(); - let value = value.trim(); + let name = name.trim_matches(|character| matches!(character, ' ' | '\t')); + let value = value.trim_matches(|character| matches!(character, ' ' | '\t')); if name.is_empty() || !name .bytes() @@ -371,8 +372,12 @@ async fn fetch_with_redirects( } } let mut redirects = 0usize; + let mut credential_bearing = false; loop { let destination = runtime.validate(context, &target).await?; + credential_bearing |= !destination.url.username().is_empty() + || destination.url.password().is_some() + || !custom_headers.is_empty(); let mut builder = Client::builder() .redirect(reqwest::redirect::Policy::none()) .no_proxy() @@ -408,6 +413,7 @@ async fn fetch_with_redirects( final_url: destination.url, effective_custom_request_headers: custom_headers, effective_response_headers: request.response_headers.clone(), + credential_bearing, }); } if redirects >= 5 { @@ -544,9 +550,6 @@ async fn handle_proxy_suffix_for_peer( Ok(request) => request, Err(error) => return proxy_error_response(error), }; - let credential_bearing = !request.target.username().is_empty() - || request.target.password().is_some() - || !request.request_headers.is_empty(); let request_method = method.clone(); let fetched = match fetch_with_redirects(runtime, &context, &request, method, &headers).await { Ok(response) => response, @@ -557,6 +560,7 @@ async fn handle_proxy_suffix_for_peer( final_url, effective_custom_request_headers, effective_response_headers, + credential_bearing, } = fetched; let status = upstream.status(); let upstream_headers = upstream.headers().clone(); @@ -1584,14 +1588,25 @@ fn rewrite_playlist_with_options( output .try_reserve_exact(body.len().min(MAX_PLAYLIST_OUTPUT)) .map_err(|_| ProxyError::Upstream)?; - for line_with_ending in body.split_inclusive('\n') { - let (line, ending) = if let Some(line) = line_with_ending.strip_suffix("\r\n") { - (line, "\r\n") - } else if let Some(line) = line_with_ending.strip_suffix('\n') { - (line, "\n") + let bytes = body.as_bytes(); + let mut position = 0usize; + while position < body.len() { + let ending_start = bytes[position..] + .iter() + .position(|byte| matches!(byte, b'\r' | b'\n')) + .map_or(body.len(), |offset| position + offset); + let ending_end = if ending_start == body.len() { + ending_start + } else if bytes + .get(ending_start + 1) + .is_some_and(|next| *next != bytes[ending_start] && matches!(next, b'\r' | b'\n')) + { + ending_start + 2 } else { - (line_with_ending, "") + ending_start + 1 }; + let line = &body[position..ending_start]; + let ending = &body[ending_start..ending_end]; if line.starts_with("#EXT") { rewrite_playlist_tag( @@ -1613,6 +1628,7 @@ fn rewrite_playlist_with_options( )?; } push_playlist(&mut output, ending)?; + position = ending_end; } if body.is_empty() { return Ok(String::new()); @@ -2336,6 +2352,39 @@ mod tests { assert_eq!(parsed.request_headers["x-test"], "a&b=c+d e"); } + #[test] + fn parse_custom_headers_reject_boundary_line_and_control_bytes() { + for encoded_control in ["%0D", "%0A", "%0B", "%0C"] { + for encoded_header in [ + format!("{encoded_control}X-Test%3Avalue"), + format!("X-Test%3Avalue{encoded_control}"), + ] { + let query = format!("d=https%3A%2F%2Fexample.com&h={encoded_header}"); + assert!( + matches!( + parse_proxy_request("", Some(&query)), + Err(ProxyError::InvalidRequest) + ), + "accepted boundary control in {encoded_header:?}" + ); + } + } + } + + #[test] + fn parse_custom_headers_trim_sp_and_htab_only() { + let parsed = parse_proxy_request( + "", + Some(concat!( + "d=https%3A%2F%2Fexample.com", + "&h=%20%09X-Test%09%20%3A%09%20value%20%09", + )), + ) + .unwrap(); + + assert_eq!(parsed.request_headers["x-test"], "value"); + } + #[test] fn parse_path_decodes_once_with_path_semantics_and_replaces_base_components() { let parsed = parse_proxy_request( @@ -4222,6 +4271,87 @@ mod tests { fixture.abort(); } + #[tokio::test] + async fn redirect_introduced_url_credentials_force_no_store_after_later_redirect_removes_them() + { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let credential_location = format!( + "http://user:secret@cache.test:{}/credential-hop", + address.port() + ); + let final_location = format!("http://cache.test:{}/final", address.port()); + let (authorization_tx, authorization_rx) = tokio::sync::oneshot::channel(); + let authorization_tx = Arc::new(std::sync::Mutex::new(Some(authorization_tx))); + let router = Router::new() + .route( + "/start", + get(move || { + let credential_location = credential_location.clone(); + async move { + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, credential_location)], + ) + } + }), + ) + .route( + "/credential-hop", + get(move |headers: HeaderMap| { + let final_location = final_location.clone(); + let authorization_tx = authorization_tx.clone(); + async move { + if let Some(sender) = authorization_tx.lock().unwrap().take() { + let authorization = headers + .get(header::AUTHORIZATION) + .cloned() + .unwrap_or_else(|| HeaderValue::from_static("missing")); + let _ = sender.send(authorization); + } + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, final_location)], + ) + } + }), + ) + .route( + "/final", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/octet-stream") + .header(header::CACHE_CONTROL, "public, max-age=3600") + .body(Body::from("asset")) + .unwrap() + }), + ); + let fixture = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://cache.test:{}/start", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(authorization_rx.await.unwrap(), "Basic dXNlcjpzZWNyZXQ="); + assert_eq!( + response.headers()[header::CACHE_CONTROL], + "private, no-store" + ); + fixture.abort(); + } + #[tokio::test] async fn credential_bearing_upstream_and_rewrite_errors_are_no_store() { let (unreachable_runtime, _) = test_runtime( @@ -5702,6 +5832,23 @@ mod tests { assert_eq!(rewritten, body); } + #[test] + fn playlist_rewriter_preserves_cr_lf_crlf_and_lfcr_endings() { + let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); + let body = "one.ts\rtwo.ts\nthree.ts\r\nfour.ts\n\rfive.ts"; + let expected = concat!( + "/proxy/?d=https%3A%2F%2Fmedia.example%2Fpath%2Fone.ts\r", + "/proxy/?d=https%3A%2F%2Fmedia.example%2Fpath%2Ftwo.ts\n", + "/proxy/?d=https%3A%2F%2Fmedia.example%2Fpath%2Fthree.ts\r\n", + "/proxy/?d=https%3A%2F%2Fmedia.example%2Fpath%2Ffour.ts\n\r", + "/proxy/?d=https%3A%2F%2Fmedia.example%2Fpath%2Ffive.ts", + ); + + let rewritten = rewrite_playlist_bounded(body, &base).unwrap(); + + assert_eq!(rewritten, expected); + } + #[test] fn playlist_rewriter_keeps_valid_variables_visible_in_lines_and_attributes() { let base = Url::parse("https://media.example/path/master.m3u8").unwrap(); From 74c5227690b85efd0cd68e83915b14cc7c03a4b9 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:37:27 -0400 Subject: [PATCH 20/25] security: preserve proxy settings integrity --- Cargo.lock | 1 + docs/network-source-security.md | 18 +- server/Cargo.toml | 3 + server/src/diagnostics/mod.rs | 138 +++- server/src/lib.rs | 125 ++- server/src/routes/system.rs | 388 ++++++++-- server/src/safe_file.rs | 85 +++ server/src/settings_control.rs | 238 +++++- server/src/state.rs | 1264 ++++++++++++++++++++++++++++++- server/tests/proxy_security.rs | 27 +- 10 files changed, 2142 insertions(+), 145 deletions(-) create mode 100644 server/src/safe_file.rs diff --git a/Cargo.lock b/Cargo.lock index 6fe05bb..c91dc56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7842,6 +7842,7 @@ dependencies = [ "image", "ipnet", "jni 0.22.4", + "libc", "librqbit", "lz-str", "mimalloc", diff --git a/docs/network-source-security.md b/docs/network-source-security.md index e42ffdf..362fe79 100644 --- a/docs/network-source-security.md +++ b/docs/network-source-security.md @@ -45,7 +45,7 @@ Invoke-RestMethod -Method Post -Uri 'http://127.0.0.1:11470/settings' -Headers $ Remove-Variable settingsToken, headers, body ``` -On Linux or macOS with `curl`: +On Linux with `curl`: ```sh token_file="${XDG_CONFIG_HOME:-$HOME/.config}/stremio-server/settings-control.token" @@ -57,6 +57,18 @@ curl --fail-with-body --request POST 'http://127.0.0.1:11470/settings' \ unset settings_token ``` +On macOS with `curl` (the quoted path normally contains a space): + +```sh +token_file="$HOME/Library/Application Support/stremio-server/settings-control.token" +settings_token="$(tr -d '\r\n' < "$token_file")" +curl --fail-with-body --request POST 'http://127.0.0.1:11470/settings' \ + --header "x-stream-server-settings-token: ${settings_token}" \ + --header 'content-type: application/json' \ + --data '{"allowPrivateNetworkSources":true,"allowInvalidProxyTlsCertificates":false}' +unset settings_token +``` + The token is not returned by the settings API or included in diagnostics exports. Treat the token file as a local secret; do not paste it into logs, issue reports, or configuration files. @@ -80,7 +92,9 @@ environment variables: Accepted values are `1`, `true`, `yes`, or `on`, and `0`, `false`, `no`, or `off`, without leading or trailing whitespace and case-insensitively. Environment values override the persisted file at startup. A runtime GUI/API change can affect the current process, but the environment value wins -again after the next restart. +again after the next restart. Environment-only values are not copied into `settings.json` by +ordinary settings changes, tracker-cache updates, or background saves; removing the environment +variable therefore restores the persisted value on the next restart. ## Destination policy diff --git a/server/Cargo.toml b/server/Cargo.toml index aa08f28..2255e98 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -94,6 +94,9 @@ tray-icon = { version = "0.24.2", default-features = false, features = ["gtk"] } [target.'cfg(target_os = "android")'.dependencies] rustls-platform-verifier = "0.7.0" +[target.'cfg(unix)'.dependencies] +libc = "0.2.189" + [target.'cfg(windows)'.dependencies] windows = { version = "0.62.2", features = [ "Win32_Foundation", diff --git a/server/src/diagnostics/mod.rs b/server/src/diagnostics/mod.rs index c0e4111..9981c6b 100644 --- a/server/src/diagnostics/mod.rs +++ b/server/src/diagnostics/mod.rs @@ -21,6 +21,8 @@ use sysinfo::{Pid, System}; use crate::state::AppState; +const MAX_DIAGNOSTICS_LOG_FILE_LENGTH: u64 = 16 * 1024 * 1024; + #[derive(Debug, Clone, Copy)] struct LocalOnly; @@ -477,23 +479,52 @@ pub(crate) fn tail_lines(path: &Path, max_lines: usize) -> std::io::Result anyhow::Result> { + build_diagnostics_zip_with_log_hook(state, |_| {}) +} + +fn build_diagnostics_zip_with_log_hook( + state: &AppState, + mut after_log_open: F, +) -> anyhow::Result> +where + F: FnMut(&Path), +{ let cursor = std::io::Cursor::new(Vec::new()); let mut zip = zip::ZipWriter::new(cursor); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); - for info in recent_log_files(&state.log_dir, 20) { + let mut exported_logs = 0usize; + for info in recent_log_files(&state.log_dir, usize::MAX) + .into_iter() + .filter(|info| { + matches!( + Path::new(&info.path) + .extension() + .and_then(|extension| extension.to_str()), + Some("log" | "jsonl") + ) + }) + { + if exported_logs == 20 { + break; + } let path = PathBuf::from(&info.path); - if !path.is_file() { + let Ok(bytes) = crate::safe_file::read_regular_file_no_follow( + &path, + MAX_DIAGNOSTICS_LOG_FILE_LENGTH, + None, + || after_log_open(&path), + ) else { continue; - } + }; let name = path .file_name() .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "log".to_string()); zip.start_file(format!("logs/{name}"), options)?; - let bytes = std::fs::read(&path)?; zip.write_all(&bytes)?; + exported_logs += 1; } if let Ok(settings) = std::fs::read_to_string(&state.settings_path) { @@ -570,3 +601,102 @@ fn modified_unix_secs(metadata: &std::fs::Metadata) -> Option { .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) .map(|duration| duration.as_secs()) } + +#[cfg(test)] +mod tests { + use super::*; + use enginefs::EngineFS; + use std::{io::Read, sync::Arc}; + + #[tokio::test] + async fn diagnostics_export_excludes_opaque_dump_files_and_scans_decompressed_bytes() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let log_dir = temp.path().join("logs"); + std::fs::create_dir_all(&log_dir).unwrap(); + let token = "a".repeat(64); + std::fs::write(log_dir.join("proof.dmp"), token.as_bytes()).unwrap(); + let server_log_path = log_dir.join("server.log"); + std::fs::write(&server_log_path, b"safe-log-sentinel").unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&server_log_path) + .unwrap() + .set_times( + std::fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1)), + ) + .unwrap(); + for index in 0..21 { + let path = log_dir.join(format!("newer-{index}.dmp")); + std::fs::write(&path, b"opaque").unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(path) + .unwrap() + .set_times( + std::fs::FileTimes::new() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(2)), + ) + .unwrap(); + } + for index in 0..21 { + std::fs::create_dir(log_dir.join(format!("invalid-{index}.log"))).unwrap(); + } + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let state = AppState::new_with_shared_settings_and_log_dir( + engine, + Arc::new(tokio::sync::RwLock::new( + crate::routes::system::ServerSettings::default(), + )), + temp.path().join("config"), + log_dir, + ); + + let bytes = build_diagnostics_zip(&state).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut entries = Vec::new(); + for index in 0..archive.len() { + let mut entry = archive.by_index(index).unwrap(); + let name = entry.name().to_string(); + let mut body = Vec::new(); + entry.read_to_end(&mut body).unwrap(); + entries.push((name, body)); + } + + assert!(entries.iter().any(|(name, body)| { + name == "logs/server.log" && body.windows(17).any(|part| part == b"safe-log-sentinel") + })); + assert!(entries.iter().all(|(name, body)| { + name != "logs/proof.dmp" + && !body + .windows(token.len()) + .any(|part| part == token.as_bytes()) + })); + + let server_log = state.log_dir.join("server.log"); + let moved_log = state.log_dir.join("server-opened.log"); + let replacement = state.log_dir.join("replacement.log"); + std::fs::write(&server_log, b"opened-handle-sentinel").unwrap(); + std::fs::write(&replacement, b"replacement-path-sentinel").unwrap(); + let bytes = build_diagnostics_zip_with_log_hook(&state, |opened| { + if opened == server_log { + std::fs::rename(&server_log, &moved_log).unwrap(); + std::fs::rename(&replacement, &server_log).unwrap(); + } + }) + .unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut archived_server_log = Vec::new(); + archive + .by_name("logs/server.log") + .unwrap() + .read_to_end(&mut archived_server_log) + .unwrap(); + assert_eq!(archived_server_log, b"opened-handle-sentinel"); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs index a547c61..aeffcde 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -20,6 +20,9 @@ pub const DEFAULT_HTTPS_PORT: u16 = 12470; pub static GLOBAL_STATE: once_cell::sync::Lazy>> = once_cell::sync::Lazy::new(|| std::sync::RwLock::new(None)); +#[cfg(test)] +pub(crate) static TEST_ENGINE_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] pub mod tray; @@ -50,6 +53,7 @@ mod ffmpeg_setup; mod local_addon; mod network_security; mod routes; +mod safe_file; mod settings_control; mod ssdp; mod state; @@ -381,11 +385,13 @@ async fn run_inner( ..routes::system::ServerSettings::default() }; - let settings = AppState::load_settings(&config_dir, &default_settings); + let raw_settings = AppState::load_raw_settings(&config_dir, &default_settings); + let mut settings = raw_settings.clone(); + routes::system::apply_proxy_environment_overrides(&mut settings); let settings_control = settings_control::SettingsControl::load_or_create(&config_dir)?; let settings_arc = Arc::new(tokio::sync::RwLock::new(settings.clone())); let settings_path = config_dir.join("settings.json"); - let settings_persistence = Arc::new(tokio::sync::Mutex::new(())); + let settings_persistence = Arc::new(state::SettingsPersistenceCoordinator::new(raw_settings)); let tracker_storage = Arc::new(state::TrackerStorageBridge::new_with_persistence( settings_arc.clone(), settings_path.clone(), @@ -620,6 +626,8 @@ async fn run_inner( tui::start_tui(Arc::new(state.clone()), rx, shutdown_tx); } + let settings_persistence_for_shutdown = state.settings_persistence.clone(); + let settings_persistence_for_drain = state.settings_persistence.clone(); let app = build_router(state); tracing::info!("listening on {}", bound_http_addr); @@ -650,6 +658,7 @@ async fn run_inner( } }; + settings_persistence_for_shutdown.close(); let _ = shutdown_started_tx.send(source); }; @@ -692,9 +701,11 @@ async fn run_inner( tokio::pin!(server); + let mut force_exit_after_drain = false; + let mut server_result = Ok(()); let shutdown_source = tokio::select! { result = &mut server => { - result?; + server_result = result; match shutdown_started_rx.try_recv() { Ok(source) => Some(source), Err(tokio::sync::oneshot::error::TryRecvError::Empty) => None, @@ -704,7 +715,7 @@ async fn run_inner( Ok(source) = &mut shutdown_started_rx => { match tokio::time::timeout(cfg.graceful_shutdown_timeout, &mut server).await { Ok(result) => { - result?; + server_result = result; } Err(_) => { if cfg.exit_process_on_shutdown_timeout { @@ -713,14 +724,14 @@ async fn run_inner( timeout_secs = cfg.graceful_shutdown_timeout.as_secs(), "Shutdown taking too long, forcing process exit" ); - std::process::exit(0); + force_exit_after_drain = true; + } else { + tracing::warn!( + ?source, + timeout_secs = cfg.graceful_shutdown_timeout.as_secs(), + "Shutdown taking too long, dropping server future so restart can continue" + ); } - - tracing::warn!( - ?source, - timeout_secs = cfg.graceful_shutdown_timeout.as_secs(), - "Shutdown taking too long, dropping server future so restart can continue" - ); } } Some(source) @@ -731,6 +742,14 @@ async fn run_inner( task.abort(); } + finish_settings_shutdown( + settings_persistence_for_drain, + force_exit_after_drain, + || std::process::exit(0), + server_result, + ) + .await?; + #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] { if let Ok(mut guard) = GLOBAL_STATE.write() { @@ -741,6 +760,24 @@ async fn run_inner( Ok(shutdown_source) } +async fn finish_settings_shutdown( + settings_persistence: Arc, + force_exit: bool, + exit_action: F, + server_result: std::io::Result<()>, +) -> anyhow::Result<()> +where + F: FnOnce(), +{ + settings_persistence.close(); + settings_persistence.drain().await; + if force_exit { + exit_action(); + } + server_result?; + Ok(()) +} + async fn maybe_ctrl_c(enabled: bool) { if enabled { let _ = tokio::signal::ctrl_c().await; @@ -931,3 +968,69 @@ async fn root_redirect(State(state): State) -> Redirect { encoded_url )) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[tokio::test] + async fn forced_exit_waits_for_admitted_settings_transactions_to_drain() { + let coordinator = Arc::new(state::SettingsPersistenceCoordinator::new( + routes::system::ServerSettings::default(), + )); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let admitted = coordinator + .register_transaction(async move { + let _ = release_rx.await; + Ok(()) + }) + .unwrap(); + coordinator.close(); + let exit_called = Arc::new(AtomicBool::new(false)); + let observed = exit_called.clone(); + let drain = finish_settings_shutdown( + coordinator, + true, + move || { + observed.store(true, Ordering::Release); + }, + Ok(()), + ); + tokio::pin!(drain); + + assert!(futures_util::poll!(&mut drain).is_pending()); + assert!(!exit_called.load(Ordering::Acquire)); + release_tx.send(()).unwrap(); + drain.await.unwrap(); + admitted.await.unwrap().unwrap(); + assert!(exit_called.load(Ordering::Acquire)); + } + + #[tokio::test] + async fn server_error_is_returned_only_after_settings_transactions_drain() { + let coordinator = Arc::new(state::SettingsPersistenceCoordinator::new( + routes::system::ServerSettings::default(), + )); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let completion = coordinator + .register_transaction(async move { + let _ = release_rx.await; + Ok(()) + }) + .unwrap(); + let finish = finish_settings_shutdown( + coordinator, + false, + || {}, + Err(std::io::Error::other("injected server failure")), + ); + tokio::pin!(finish); + + assert!(futures_util::poll!(&mut finish).is_pending()); + release_tx.send(()).unwrap(); + let error = finish.await.unwrap_err(); + completion.await.unwrap().unwrap(); + assert_eq!(error.to_string(), "injected server failure"); + } +} diff --git a/server/src/routes/system.rs b/server/src/routes/system.rs index 105eb77..5e1dbcd 100644 --- a/server/src/routes/system.rs +++ b/server/src/routes/system.rs @@ -290,30 +290,67 @@ fn parse_environment_bool(value: &str) -> Option { } } -pub(crate) fn apply_proxy_environment_overrides(settings: &mut ServerSettings) { - for (name, target) in [ - ( - "STREMIO_ALLOW_PRIVATE_NETWORK_SOURCES", - &mut settings.allow_private_network_sources, - ), - ( - "STREMIO_ALLOW_INVALID_PROXY_TLS_CERTIFICATES", - &mut settings.allow_invalid_proxy_tls_certificates, - ), - ] { - if let Ok(value) = std::env::var(name) { - if let Some(value) = parse_environment_bool(&value) { - *target = value; - } else { +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct ProxyEnvironmentOverrides { + pub(crate) allow_private_network_sources: Option, + pub(crate) allow_invalid_proxy_tls_certificates: Option, +} + +impl ProxyEnvironmentOverrides { + pub(crate) fn apply_to(self, settings: &mut ServerSettings) { + if let Some(value) = self.allow_private_network_sources { + settings.allow_private_network_sources = value; + } + if let Some(value) = self.allow_invalid_proxy_tls_certificates { + settings.allow_invalid_proxy_tls_certificates = value; + } + } + + fn from_environment() -> Self { + Self::from_reader( + |name| std::env::var(name), + |name| { tracing::warn!( variable = name, "ignoring invalid boolean environment override" ); + }, + ) + } + + fn from_reader(mut read: R, mut warn_invalid: W) -> Self + where + R: FnMut(&str) -> Result, + W: FnMut(&str), + { + let mut overrides = Self::default(); + for (name, target) in [ + ( + "STREMIO_ALLOW_PRIVATE_NETWORK_SOURCES", + &mut overrides.allow_private_network_sources, + ), + ( + "STREMIO_ALLOW_INVALID_PROXY_TLS_CERTIFICATES", + &mut overrides.allow_invalid_proxy_tls_certificates, + ), + ] { + match read(name) { + Ok(value) => match parse_environment_bool(&value) { + Some(value) => *target = Some(value), + None => warn_invalid(name), + }, + Err(std::env::VarError::NotPresent) => {} + Err(std::env::VarError::NotUnicode(_)) => warn_invalid(name), } } + overrides } } +pub(crate) fn apply_proxy_environment_overrides(settings: &mut ServerSettings) { + ProxyEnvironmentOverrides::from_environment().apply_to(settings); +} + fn parse_torrent_encryption_mode(value: &Value) -> Option { if let Some(code) = value.as_u64() { return match code { @@ -476,6 +513,31 @@ impl Default for ServerSettings { pub(crate) struct PreparedSettingsUpdate { pub(crate) next: ServerSettings, + allow_private_network_sources_changed: bool, + allow_invalid_proxy_tls_certificates_changed: bool, +} + +impl PreparedSettingsUpdate { + fn disk_candidate(&self, live: &ServerSettings, raw: &ServerSettings) -> ServerSettings { + let mut disk = live.clone(); + if !self.allow_private_network_sources_changed { + disk.allow_private_network_sources = raw.allow_private_network_sources; + } + if !self.allow_invalid_proxy_tls_certificates_changed { + disk.allow_invalid_proxy_tls_certificates = raw.allow_invalid_proxy_tls_certificates; + } + disk + } +} + +pub(crate) fn preserve_raw_protected_settings( + live: &ServerSettings, + raw: &ServerSettings, +) -> ServerSettings { + let mut disk = live.clone(); + disk.allow_private_network_sources = raw.allow_private_network_sources; + disk.allow_invalid_proxy_tls_certificates = raw.allow_invalid_proxy_tls_certificates; + disk } #[derive(thiserror::Error, Debug)] @@ -497,16 +559,20 @@ fn prepare_settings_update( .as_object() .ok_or(SettingsUpdateError::Invalid("expected a JSON object"))?; let mut next = current.clone(); - for (key, current_value, target) in [ + let mut allow_private_network_sources_changed = false; + let mut allow_invalid_proxy_tls_certificates_changed = false; + for (key, current_value, target, changed) in [ ( "allowPrivateNetworkSources", current.allow_private_network_sources, &mut next.allow_private_network_sources, + &mut allow_private_network_sources_changed, ), ( "allowInvalidProxyTlsCertificates", current.allow_invalid_proxy_tls_certificates, &mut next.allow_invalid_proxy_tls_certificates, + &mut allow_invalid_proxy_tls_certificates_changed, ), ] { if let Some(value) = object.get(key) { @@ -516,11 +582,16 @@ fn prepare_settings_update( if value != current_value && authority == SettingsMutationAuthority::Untrusted { return Err(SettingsUpdateError::Forbidden); } + *changed = value != current_value; *target = value; } } - Ok(PreparedSettingsUpdate { next }) + Ok(PreparedSettingsUpdate { + next, + allow_private_network_sources_changed, + allow_invalid_proxy_tls_certificates_changed, + }) } /// Returns server settings in the SettingsResponse format expected by stremio-core @@ -534,10 +605,52 @@ pub async fn get_settings(State(state): State) -> impl IntoResponse { })) } +#[cfg(not(test))] pub(crate) async fn persist_settings_atomic( path: &std::path::Path, settings: &ServerSettings, -) -> anyhow::Result<()> { +) -> anyhow::Result { + persist_settings_atomic_with_after_rename(path, settings, async {}).await +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum SettingsPersistenceOutcome { + Durable, + CommittedWithDurabilityWarning, +} + +#[cfg(not(test))] +pub(crate) async fn persist_settings_atomic_with_after_rename( + path: &std::path::Path, + settings: &ServerSettings, + after_rename: F, +) -> anyhow::Result +where + F: std::future::Future, +{ + persist_settings_atomic_with_hooks(path, settings, after_rename, sync_settings_parent).await +} + +#[cfg(not(test))] +async fn sync_settings_parent(parent: std::path::PathBuf) -> anyhow::Result<()> { + #[cfg(unix)] + tokio::task::spawn_blocking(move || std::fs::File::open(parent)?.sync_all()).await??; + #[cfg(not(unix))] + let _ = parent; + Ok(()) +} + +pub(crate) async fn persist_settings_atomic_with_hooks( + path: &std::path::Path, + settings: &ServerSettings, + after_rename: F, + sync_parent: S, +) -> anyhow::Result +where + F: std::future::Future, + S: FnOnce(std::path::PathBuf) -> SF, + SF: std::future::Future>, +{ let bytes = serde_json::to_vec_pretty(settings)?; let path = path.to_owned(); let parent = path @@ -545,6 +658,7 @@ pub(crate) async fn persist_settings_atomic( .ok_or_else(|| anyhow::anyhow!("settings path has no parent"))? .to_owned(); tokio::fs::create_dir_all(&parent).await?; + let parent_to_sync = parent.clone(); tokio::task::spawn_blocking(move || -> anyhow::Result<()> { use std::io::Write; let mut temporary = tempfile::NamedTempFile::new_in(&parent)?; @@ -552,12 +666,17 @@ pub(crate) async fn persist_settings_atomic( temporary.flush()?; temporary.as_file().sync_all()?; temporary.persist(&path).map_err(|error| error.error)?; - #[cfg(unix)] - std::fs::File::open(parent)?.sync_all()?; Ok(()) }) .await??; - Ok(()) + after_rename.await; + match sync_parent(parent_to_sync).await { + Ok(()) => Ok(SettingsPersistenceOutcome::Durable), + Err(_) => { + tracing::warn!("settings persistence durability warning"); + Ok(SettingsPersistenceOutcome::CommittedWithDurabilityWarning) + } + } } pub async fn update_settings( @@ -565,7 +684,7 @@ pub async fn update_settings( payload: &Value, authority: SettingsMutationAuthority, ) -> Result<(), SettingsUpdateError> { - let _persistence = state.settings_persistence.lock().await; + let mut raw = state.settings_persistence.lock_owned().await; let current = state.settings.read().await.clone(); let protected = prepare_settings_update(¤t, payload, authority)?; let mut settings = current; @@ -780,29 +899,60 @@ pub async fn update_settings( allow_private_network_sources: settings.allow_private_network_sources, allow_invalid_proxy_tls_certificates: settings.allow_invalid_proxy_tls_certificates, }; - persist_settings_atomic(&state.settings_path, &settings) - .await - .map_err(SettingsUpdateError::Persistence)?; - let mut published = state.settings.write().await; - state.proxy_runtime.begin_reconfigure(proxy_policy); - *published = settings; - state.proxy_runtime.finish_reconfigure(proxy_policy); - drop(published); - - // Apply updated torrent session settings dynamically. - state - .engine - .update_torrent_settings(&new_profile, &new_privacy) - .await; - state - .download_engine - .update_torrent_settings(&new_profile, &new_privacy) - .await; - - state.engine.set_seeding_enabled(seeding_enabled); - state.download_engine.set_seeding_enabled(seeding_enabled); + let disk = protected.disk_candidate(&settings, &raw); + let transaction_state = state.clone(); + let persistence = state.settings_persistence.clone(); + #[cfg(test)] + let before_final_side_effect = state + .settings_persistence + .take_before_final_side_effect_gate(); + let completion = state + .settings_persistence + .register_transaction(async move { + persistence + .persist_settings(&transaction_state.settings_path, &disk) + .await?; + *raw = disk; + let mut published = transaction_state.settings.write().await; + transaction_state + .proxy_runtime + .begin_reconfigure(proxy_policy); + *published = settings; + transaction_state + .proxy_runtime + .finish_reconfigure(proxy_policy); + drop(published); + + transaction_state + .engine + .update_torrent_settings(&new_profile, &new_privacy) + .await; + transaction_state + .download_engine + .update_torrent_settings(&new_profile, &new_privacy) + .await; + + transaction_state + .engine + .set_seeding_enabled(seeding_enabled); + #[cfg(test)] + if let Some(gate) = before_final_side_effect { + gate.reach_and_wait().await; + } + transaction_state + .download_engine + .set_seeding_enabled(seeding_enabled); + Ok(()) + }) + .map_err(|error| SettingsUpdateError::Persistence(error.into()))?; - Ok(()) + match completion.await { + Ok(result) => result.map_err(SettingsUpdateError::Persistence), + Err(error) => { + tracing::error!("settings transaction failed"); + Err(SettingsUpdateError::Persistence(error.into())) + } + } } pub async fn set_settings( @@ -827,17 +977,14 @@ pub async fn set_settings( Json(json!({"success": false, "error": "invalid settings payload"})), ) .into_response(), - Err(SettingsUpdateError::Persistence(_)) => { - tracing::error!("settings persistence failed"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "success": false, - "error": "settings could not be saved" - })), - ) - .into_response() - } + Err(SettingsUpdateError::Persistence(_)) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "success": false, + "error": "settings could not be saved" + })), + ) + .into_response(), } } pub async fn get_device_info() -> impl IntoResponse { @@ -1247,6 +1394,53 @@ mod tests { } } + #[test] + fn same_effective_protected_values_do_not_overwrite_raw_settings() { + for (raw_value, effective_value) in [(false, true), (true, false)] { + for authority in [ + SettingsMutationAuthority::Untrusted, + SettingsMutationAuthority::TrustedLocal, + SettingsMutationAuthority::HttpAuthorized, + ] { + for field in [ + "allowPrivateNetworkSources", + "allowInvalidProxyTlsCertificates", + ] { + let raw = ServerSettings { + allow_private_network_sources: raw_value, + allow_invalid_proxy_tls_certificates: raw_value, + ..ServerSettings::default() + }; + let mut effective = raw.clone(); + effective.allow_private_network_sources = effective_value; + effective.allow_invalid_proxy_tls_certificates = effective_value; + let payload = json!({field: effective_value}); + let prepared = + prepare_settings_update(&effective, &payload, authority).unwrap(); + let mut live = prepared.next.clone(); + live.cache_size = 321.0; + + let disk = prepared.disk_candidate(&live, &raw); + + assert_eq!(disk.allow_private_network_sources, raw_value, "{field}"); + assert_eq!( + disk.allow_invalid_proxy_tls_certificates, raw_value, + "{field}" + ); + assert_eq!(disk.cache_size, 321.0, "{field}"); + assert_eq!( + prepared.next.allow_private_network_sources, effective_value, + "{field}" + ); + assert_eq!( + prepared.next.allow_invalid_proxy_tls_certificates, effective_value, + "{field}" + ); + } + } + } + } + #[test] fn non_boolean_protected_values_are_invalid_even_when_falsey() { let current = ServerSettings::default(); @@ -1278,4 +1472,88 @@ mod tests { assert_eq!(parse_environment_bool(value), None, "{value}"); } } + + #[test] + fn pure_environment_overrides_apply_in_both_directions_and_win_on_restart() { + for (raw_value, override_value) in [(false, true), (true, false)] { + let raw = ServerSettings { + allow_private_network_sources: raw_value, + allow_invalid_proxy_tls_certificates: raw_value, + ..ServerSettings::default() + }; + let overrides = ProxyEnvironmentOverrides { + allow_private_network_sources: Some(override_value), + allow_invalid_proxy_tls_certificates: Some(override_value), + }; + + let mut effective = raw.clone(); + overrides.apply_to(&mut effective); + + assert_eq!(effective.allow_private_network_sources, override_value); + assert_eq!( + effective.allow_invalid_proxy_tls_certificates, + override_value + ); + assert_eq!(raw.allow_private_network_sources, raw_value); + assert_eq!(raw.allow_invalid_proxy_tls_certificates, raw_value); + + let mut restarted = raw; + overrides.apply_to(&mut restarted); + assert_eq!(restarted.allow_private_network_sources, override_value); + assert_eq!( + restarted.allow_invalid_proxy_tls_certificates, + override_value + ); + } + } + + #[test] + fn environment_reader_reports_invalid_unicode_by_name_without_value() { + let mut warnings = Vec::new(); + let overrides = ProxyEnvironmentOverrides::from_reader( + |_| { + Err(std::env::VarError::NotUnicode(std::ffi::OsString::from( + "secret-environment-bytes", + ))) + }, + |name| warnings.push(name.to_string()), + ); + + assert_eq!(overrides, ProxyEnvironmentOverrides::default()); + assert_eq!( + warnings, + [ + "STREMIO_ALLOW_PRIVATE_NETWORK_SOURCES", + "STREMIO_ALLOW_INVALID_PROXY_TLS_CERTIFICATES", + ] + ); + assert!( + warnings + .iter() + .all(|warning| !warning.contains("secret-environment-bytes")) + ); + } + + #[tokio::test] + async fn rename_commit_survives_a_parent_directory_sync_failure() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings.json"); + let candidate = ServerSettings { + cache_size: 654.0, + ..ServerSettings::default() + }; + + let outcome = persist_settings_atomic_with_hooks(&path, &candidate, async {}, |_| async { + Err(anyhow::anyhow!("injected parent sync failure")) + }) + .await + .unwrap(); + + assert_eq!( + outcome, + SettingsPersistenceOutcome::CommittedWithDurabilityWarning + ); + let disk: ServerSettings = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(disk.cache_size, 654.0); + } } diff --git a/server/src/safe_file.rs b/server/src/safe_file.rs new file mode 100644 index 0000000..8678318 --- /dev/null +++ b/server/src/safe_file.rs @@ -0,0 +1,85 @@ +use anyhow::{Context, bail}; +use std::{ + fs::{self, File}, + io::Read, + path::Path, +}; + +pub(crate) fn read_regular_file_no_follow( + path: &Path, + maximum_length: u64, + required_unix_mode: Option, + after_open: F, +) -> anyhow::Result> +where + F: FnOnce(), +{ + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_NOCTTY); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + + let file = options + .open(path) + .with_context(|| format!("failed to open {}", path.display()))?; + read_validated_handle(file, path, maximum_length, required_unix_mode, after_open) +} + +fn read_validated_handle( + file: File, + path: &Path, + maximum_length: u64, + required_unix_mode: Option, + after_open: F, +) -> anyhow::Result> +where + F: FnOnce(), +{ + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect {}", path.display()))?; + if !metadata.file_type().is_file() { + bail!("file must be a regular non-symlink file"); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; + if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 { + bail!("file must be a regular non-reparse-point file"); + } + } + #[cfg(unix)] + if let Some(required_mode) = required_unix_mode { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o777 != required_mode { + bail!("file permissions do not match the required mode"); + } + } + #[cfg(not(unix))] + let _ = required_unix_mode; + + if metadata.len() > maximum_length { + bail!("file is oversized"); + } + after_open(); + + let read_limit = maximum_length + .checked_add(1) + .ok_or_else(|| anyhow::anyhow!("file length limit is too large"))?; + let mut bytes = Vec::with_capacity(read_limit.min(8192) as usize); + file.take(read_limit).read_to_end(&mut bytes)?; + if bytes.len() as u64 > maximum_length { + bail!("file is oversized"); + } + Ok(bytes) +} diff --git a/server/src/settings_control.rs b/server/src/settings_control.rs index 12f1349..48b6ee5 100644 --- a/server/src/settings_control.rs +++ b/server/src/settings_control.rs @@ -29,11 +29,48 @@ pub(crate) enum SettingsMutationAuthority { impl SettingsControl { pub(crate) fn load_or_create(config_dir: &Path) -> anyhow::Result { + Self::load_or_create_with(config_dir, create_token_file) + } + + fn load_or_create_with(config_dir: &Path, create: F) -> anyhow::Result + where + F: FnOnce(&Path) -> io::Result<[u8; TOKEN_LENGTH]>, + { + Self::load_or_create_with_hooks(config_dir, create, || {}) + } + + fn load_or_create_with_hooks( + config_dir: &Path, + create: F, + before_existing_load: H, + ) -> anyhow::Result + where + F: FnOnce(&Path) -> io::Result<[u8; TOKEN_LENGTH]>, + H: FnOnce(), + { fs::create_dir_all(config_dir).with_context(|| { format!("failed to create config directory {}", config_dir.display()) })?; let path = config_dir.join(TOKEN_FILE_NAME); - match create_token_file(&path) { + match fs::symlink_metadata(&path) { + Ok(_) => { + before_existing_load(); + let token = load_token_file(&path)?; + return Ok(Self { + token: Arc::new(token), + }); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to inspect settings control token {}", + path.display() + ) + }); + } + } + match create(&path) { Ok(token) => Ok(Self { token: Arc::new(token), }), @@ -126,33 +163,23 @@ fn create_token_file(path: &Path) -> io::Result<[u8; TOKEN_LENGTH]> { } fn load_token_file(path: &Path) -> anyhow::Result<[u8; TOKEN_LENGTH]> { - validate_token_metadata(path)?; - let metadata = fs::metadata(path)?; - if metadata.len() > MAX_TOKEN_FILE_LENGTH { - bail!("settings control token file is oversized"); - } - let bytes = fs::read(path)?; - parse_token_bytes(&bytes) + load_token_file_with_after_open(path, || {}) } -fn validate_token_metadata(path: &Path) -> anyhow::Result<()> { - let metadata = fs::symlink_metadata(path).with_context(|| { - format!( - "failed to inspect settings control token {}", - path.display() - ) - })?; - if metadata.file_type().is_symlink() || !metadata.file_type().is_file() { - bail!("settings control token must be a regular non-symlink file"); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if metadata.permissions().mode() & 0o077 != 0 { - bail!("settings control token permissions must be 0600"); - } - } - Ok(()) +fn load_token_file_with_after_open( + path: &Path, + after_open: F, +) -> anyhow::Result<[u8; TOKEN_LENGTH]> +where + F: FnOnce(), +{ + let bytes = crate::safe_file::read_regular_file_no_follow( + path, + MAX_TOKEN_FILE_LENGTH, + Some(0o600), + after_open, + )?; + parse_token_bytes(&bytes) } fn parse_token_bytes(bytes: &[u8]) -> anyhow::Result<[u8; TOKEN_LENGTH]> { @@ -280,6 +307,63 @@ mod tests { ); } + #[test] + fn an_existing_valid_token_is_loaded_without_attempting_creation() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings-control.token"); + fs::write(&path, [b'a'; 64]).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let creation_attempted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observed = creation_attempted.clone(); + + let control = SettingsControl::load_or_create_with(temp.path(), move |_| { + observed.store(true, std::sync::atomic::Ordering::Release); + Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "creation must not be attempted", + )) + }) + .unwrap(); + + assert!(!creation_attempted.load(std::sync::atomic::Ordering::Acquire)); + assert_eq!( + control.authorize_http( + "127.0.0.1:40000".parse().unwrap(), + &headers_with(&[b'a'; 64]), + ), + SettingsMutationAuthority::HttpAuthorized + ); + } + + #[test] + fn token_bytes_are_read_from_the_validated_handle_after_path_replacement() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings-control.token"); + let replacement = temp.path().join("replacement.token"); + let original_moved = temp.path().join("original.token"); + fs::write(&path, [b'a'; 64]).unwrap(); + fs::write(&replacement, [b'b'; 64]).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + fs::set_permissions(&replacement, fs::Permissions::from_mode(0o600)).unwrap(); + } + + let token = super::load_token_file_with_after_open(&path, || { + fs::rename(&path, &original_moved).unwrap(); + fs::rename(&replacement, &path).unwrap(); + }) + .unwrap(); + + assert_eq!(token, [b'a'; 64]); + assert_eq!(fs::read(path).unwrap(), [b'b'; 64]); + } + #[test] fn concurrent_token_creation_observes_only_a_complete_winner() { let temp = tempfile::tempdir().unwrap(); @@ -323,4 +407,106 @@ mod tests { assert!(SettingsControl::load_or_create(temp.path()).is_err()); assert_eq!(fs::read(path).unwrap(), b"not-a-token"); } + + #[test] + fn oversized_token_is_rejected_without_truncation_or_replacement() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings-control.token"); + let oversized = vec![b'a'; super::MAX_TOKEN_FILE_LENGTH as usize + 1]; + fs::write(&path, &oversized).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + + assert!(SettingsControl::load_or_create(temp.path()).is_err()); + assert_eq!(fs::read(path).unwrap(), oversized); + } + + #[cfg(unix)] + #[test] + fn unix_token_symlinks_fifos_and_broad_permissions_are_rejected() { + use std::os::{unix::ffi::OsStrExt, unix::fs::PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target.token"); + fs::write(&target, [b'a'; 64]).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + let path = temp.path().join("settings-control.token"); + std::os::unix::fs::symlink(&target, &path).unwrap(); + assert!(SettingsControl::load_or_create(temp.path()).is_err()); + assert_eq!(fs::read(&target).unwrap(), [b'a'; 64]); + + fs::remove_file(&path).unwrap(); + let path_bytes = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(path_bytes.as_ptr(), 0o600) }, 0); + assert!(SettingsControl::load_or_create(temp.path()).is_err()); + + fs::remove_file(&path).unwrap(); + fs::write(&path, [b'a'; 64]).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + assert!(SettingsControl::load_or_create(temp.path()).is_err()); + } + + #[cfg(unix)] + #[test] + fn unix_existing_token_preopen_swaps_cannot_follow_symlinks_or_block_on_fifos() { + use std::os::{unix::ffi::OsStrExt, unix::fs::PermissionsExt}; + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings-control.token"); + let target = temp.path().join("target.token"); + fs::write(&path, [b'a'; 64]).unwrap(); + fs::write(&target, [b'b'; 64]).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + let path_for_swap = path.clone(); + let target_for_swap = target.clone(); + assert!( + SettingsControl::load_or_create_with_hooks( + temp.path(), + |_| panic!("existing token must not create"), + move || { + fs::remove_file(&path_for_swap).unwrap(); + std::os::unix::fs::symlink(&target_for_swap, &path_for_swap).unwrap(); + }, + ) + .is_err() + ); + assert_eq!(fs::read(&target).unwrap(), [b'b'; 64]); + + fs::remove_file(&path).unwrap(); + fs::write(&path, [b'a'; 64]).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let path_for_swap = path.clone(); + assert!( + SettingsControl::load_or_create_with_hooks( + temp.path(), + |_| panic!("existing token must not create"), + move || { + fs::remove_file(&path_for_swap).unwrap(); + let bytes = + std::ffi::CString::new(path_for_swap.as_os_str().as_bytes()).unwrap(); + assert_eq!(unsafe { libc::mkfifo(bytes.as_ptr(), 0o600) }, 0); + }, + ) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn existing_token_reloads_from_a_nonwritable_directory() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("settings-control.token"); + fs::write(&path, [b'a'; 64]).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o500)).unwrap(); + let result = SettingsControl::load_or_create(temp.path()); + fs::set_permissions(temp.path(), fs::Permissions::from_mode(0o700)).unwrap(); + assert!(result.is_ok()); + } } diff --git a/server/src/state.rs b/server/src/state.rs index dfb2a59..3b3a586 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -14,6 +14,350 @@ use tokio::sync::RwLock; use crate::local_addon::LocalIndex; +pub(crate) struct SettingsPersistenceCoordinator { + raw: Arc>, + supervisor: std::sync::Mutex, + tasks: tokio_util::task::TaskTracker, + active_transactions: Arc, + idle_notify: Arc, + next_tracker_sequence: std::sync::atomic::AtomicU64, + #[cfg(test)] + after_rename_gate: std::sync::Mutex>>, + #[cfg(test)] + tracker_before_lock_gate: std::sync::Mutex>>, + #[cfg(test)] + fail_parent_sync: std::sync::atomic::AtomicBool, + #[cfg(test)] + before_final_side_effect_gate: std::sync::Mutex>>, +} + +struct SettingsSupervisorState { + closed: bool, + latest_admitted_tracker_sequence: u64, +} + +#[derive(Debug, thiserror::Error)] +#[error("settings persistence coordinator is closed")] +pub(crate) struct SettingsCoordinatorClosed; + +struct ActiveSettingsTransaction { + active_transactions: Arc, + idle_notify: Arc, +} + +impl Drop for ActiveSettingsTransaction { + fn drop(&mut self) { + if self + .active_transactions + .fetch_sub(1, std::sync::atomic::Ordering::AcqRel) + == 1 + { + self.idle_notify.notify_waiters(); + } + } +} + +#[cfg(test)] +pub(crate) struct SettingsPersistenceTestGate { + reached: std::sync::atomic::AtomicBool, + released: std::sync::atomic::AtomicBool, + reached_notify: tokio::sync::Notify, + release_notify: tokio::sync::Notify, +} + +#[cfg(test)] +impl SettingsPersistenceTestGate { + fn new() -> Self { + Self { + reached: std::sync::atomic::AtomicBool::new(false), + released: std::sync::atomic::AtomicBool::new(false), + reached_notify: tokio::sync::Notify::new(), + release_notify: tokio::sync::Notify::new(), + } + } + + pub(crate) async fn reach_and_wait(&self) { + self.reached + .store(true, std::sync::atomic::Ordering::Release); + self.reached_notify.notify_waiters(); + while !self.released.load(std::sync::atomic::Ordering::Acquire) { + let notified = self.release_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.released.load(std::sync::atomic::Ordering::Acquire) { + break; + } + notified.await; + } + } + + pub(crate) async fn wait_reached(&self) { + while !self.reached.load(std::sync::atomic::Ordering::Acquire) { + let notified = self.reached_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.reached.load(std::sync::atomic::Ordering::Acquire) { + break; + } + notified.await; + } + } + + pub(crate) fn release(&self) { + self.released + .store(true, std::sync::atomic::Ordering::Release); + self.release_notify.notify_waiters(); + } +} + +impl SettingsPersistenceCoordinator { + pub(crate) fn new(raw: ServerSettings) -> Self { + Self { + raw: Arc::new(tokio::sync::Mutex::new(raw)), + supervisor: std::sync::Mutex::new(SettingsSupervisorState { + closed: false, + latest_admitted_tracker_sequence: 0, + }), + tasks: tokio_util::task::TaskTracker::new(), + active_transactions: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + idle_notify: Arc::new(tokio::sync::Notify::new()), + next_tracker_sequence: std::sync::atomic::AtomicU64::new(0), + #[cfg(test)] + after_rename_gate: std::sync::Mutex::new(None), + #[cfg(test)] + tracker_before_lock_gate: std::sync::Mutex::new(None), + #[cfg(test)] + fail_parent_sync: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + before_final_side_effect_gate: std::sync::Mutex::new(None), + } + } + + pub(crate) async fn lock(&self) -> tokio::sync::MutexGuard<'_, ServerSettings> { + self.raw.lock().await + } + + pub(crate) async fn lock_owned( + self: &Arc, + ) -> tokio::sync::OwnedMutexGuard { + self.raw.clone().lock_owned().await + } + + pub(crate) fn register_transaction( + &self, + transaction: F, + ) -> Result>, SettingsCoordinatorClosed> + where + F: std::future::Future> + Send + 'static, + { + self.register_transaction_inner(None, transaction) + } + + fn next_tracker_sequence(&self) -> u64 { + self.next_tracker_sequence + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1 + } + + fn register_tracker_transaction( + &self, + sequence: u64, + transaction: F, + ) -> Result>, SettingsCoordinatorClosed> + where + F: std::future::Future> + Send + 'static, + { + self.register_transaction_inner(Some(sequence), transaction) + } + + fn register_transaction_inner( + &self, + tracker_sequence: Option, + transaction: F, + ) -> Result>, SettingsCoordinatorClosed> + where + F: std::future::Future> + Send + 'static, + { + let mut supervisor = self + .supervisor + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if supervisor.closed { + tracing::error!("settings transaction failed"); + return Err(SettingsCoordinatorClosed); + } + if let Some(sequence) = tracker_sequence { + supervisor.latest_admitted_tracker_sequence = + supervisor.latest_admitted_tracker_sequence.max(sequence); + } + self.active_transactions + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let active = ActiveSettingsTransaction { + active_transactions: self.active_transactions.clone(), + idle_notify: self.idle_notify.clone(), + }; + let (completion_tx, completion_rx) = tokio::sync::oneshot::channel(); + let task = self.tasks.spawn(async move { + let _active = active; + let result = transaction.await; + if result.is_err() { + tracing::error!("settings transaction failed"); + } + let _ = completion_tx.send(result); + }); + drop(task); + Ok(completion_rx) + } + + fn latest_admitted_tracker_sequence(&self) -> u64 { + self.supervisor + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .latest_admitted_tracker_sequence + } + + pub(crate) fn close(&self) { + let mut supervisor = self + .supervisor + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !supervisor.closed { + supervisor.closed = true; + self.tasks.close(); + } + } + + pub(crate) async fn drain(&self) { + self.tasks.wait().await; + } + + #[cfg(test)] + pub(crate) async fn wait_until_idle(&self) { + loop { + let notified = self.idle_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self + .active_transactions + .load(std::sync::atomic::Ordering::Acquire) + == 0 + { + return; + } + notified.await; + } + } + + pub(crate) async fn persist_settings( + &self, + path: &std::path::Path, + settings: &ServerSettings, + ) -> anyhow::Result { + #[cfg(test)] + { + let gate = self + .after_rename_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); + let fail_parent_sync = self + .fail_parent_sync + .swap(false, std::sync::atomic::Ordering::AcqRel); + return crate::routes::system::persist_settings_atomic_with_hooks( + path, + settings, + async move { + if let Some(gate) = gate { + gate.reach_and_wait().await; + } + }, + move |_| async move { + if fail_parent_sync { + Err(anyhow::anyhow!("injected parent sync failure")) + } else { + Ok(()) + } + }, + ) + .await; + } + #[cfg(not(test))] + crate::routes::system::persist_settings_atomic(path, settings).await + } + + #[cfg(test)] + pub(crate) fn gate_next_after_rename(&self) -> Arc { + let gate = Arc::new(SettingsPersistenceTestGate::new()); + let mut next = self + .after_rename_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(next.replace(gate.clone()).is_none(), "gate already armed"); + gate + } + + #[cfg(test)] + pub(crate) fn gate_next_tracker_before_lock(&self) -> Arc { + let gate = Arc::new(SettingsPersistenceTestGate::new()); + let mut next = self + .tracker_before_lock_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(next.replace(gate.clone()).is_none(), "gate already armed"); + gate + } + + #[cfg(test)] + pub(crate) fn fail_next_parent_sync(&self) { + assert!( + !self + .fail_parent_sync + .swap(true, std::sync::atomic::Ordering::AcqRel), + "parent sync failure already armed" + ); + } + + #[cfg(test)] + pub(crate) fn gate_next_before_final_side_effect(&self) -> Arc { + let gate = Arc::new(SettingsPersistenceTestGate::new()); + let mut next = self + .before_final_side_effect_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(next.replace(gate.clone()).is_none(), "gate already armed"); + gate + } + + #[cfg(test)] + pub(crate) fn take_before_final_side_effect_gate( + &self, + ) -> Option> { + self.before_final_side_effect_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } + + #[cfg(test)] + fn take_tracker_before_lock_gate(&self) -> Option> { + self.tracker_before_lock_gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } + + #[cfg(test)] + pub(crate) async fn raw_snapshot(&self) -> ServerSettings { + self.raw.lock().await.clone() + } + + #[cfg(test)] + pub(crate) fn active_transaction_count(&self) -> usize { + self.active_transactions + .load(std::sync::atomic::Ordering::Acquire) + } +} + #[derive(Clone)] pub struct AppState { pub engine: Arc, @@ -33,7 +377,7 @@ pub struct AppState { pub devices: Arc>>, pub(crate) settings_control: SettingsControl, pub(crate) proxy_runtime: Arc, - pub(crate) settings_persistence: Arc>, + pub(crate) settings_persistence: Arc, } impl AppState { @@ -60,6 +404,18 @@ impl AppState { ) } + #[cfg(test)] + pub(crate) fn new_with_raw_and_effective_settings( + engine: Arc, + raw: ServerSettings, + effective: ServerSettings, + config_dir: PathBuf, + ) -> Self { + let mut state = Self::new(engine, effective, config_dir); + state.settings_persistence = Arc::new(SettingsPersistenceCoordinator::new(raw)); + state + } + #[allow(unused)] pub fn new_with_shared_settings( engine: Arc, @@ -96,13 +452,15 @@ impl AppState { ) -> Self { let settings_path = config_dir.join("settings.json"); let updater = Arc::new(crate::updater::UpdateManager::new(config_dir.clone())); - let proxy_policy = settings + let initial_settings = settings .try_read() - .map(|settings| ProxyPolicySettings { - allow_private_network_sources: settings.allow_private_network_sources, - allow_invalid_proxy_tls_certificates: settings.allow_invalid_proxy_tls_certificates, - }) - .unwrap_or_default(); + .expect("shared settings must be uncontended during AppState construction") + .clone(); + let proxy_policy = ProxyPolicySettings { + allow_private_network_sources: initial_settings.allow_private_network_sources, + allow_invalid_proxy_tls_certificates: initial_settings + .allow_invalid_proxy_tls_certificates, + }; let default_http_addr = SocketAddr::from(([127, 0, 0, 1], 11470)); let validator = Arc::new(DestinationValidator::new( Arc::new(SystemDnsResolver), @@ -132,19 +490,35 @@ impl AppState { devices: Arc::new(RwLock::new(Vec::new())), settings_control: SettingsControl::ephemeral(), proxy_runtime: Arc::new(ProxyRuntime::new(proxy_policy, validator)), - settings_persistence: Arc::new(tokio::sync::Mutex::new(())), + settings_persistence: Arc::new(SettingsPersistenceCoordinator::new(initial_settings)), } } pub async fn save_settings(&self) -> anyhow::Result<()> { - let _persistence = self.settings_persistence.lock().await; - let settings = self.settings.read().await.clone(); - crate::routes::system::persist_settings_atomic(&self.settings_path, &settings).await?; - tracing::info!("Settings saved to {:?}", self.settings_path); - Ok(()) + let mut raw = self.settings_persistence.lock_owned().await; + let live = self.settings.read().await.clone(); + let disk = crate::routes::system::preserve_raw_protected_settings(&live, &raw); + let settings_path = self.settings_path.clone(); + let settings_persistence = self.settings_persistence.clone(); + let completion = self.settings_persistence.register_transaction(async move { + settings_persistence + .persist_settings(&settings_path, &disk) + .await?; + *raw = disk; + tracing::info!("Settings saved to {:?}", settings_path); + Ok(()) + })?; + + match completion.await { + Ok(result) => result, + Err(_) => { + tracing::error!("settings transaction failed"); + Err(anyhow::anyhow!("settings transaction failed")) + } + } } - pub fn load_settings( + pub(crate) fn load_raw_settings( config_dir: &std::path::Path, defaults: &ServerSettings, ) -> ServerSettings { @@ -155,11 +529,6 @@ impl AppState { && let Ok(mut settings) = serde_json::from_str::(&content) { tracing::info!("Loaded settings from {:?}", settings_path); - // Ensure the cache_root in the loaded settings matches what we expect from runtime? - // Or do we respect the file? - // User might have customized it in the file. - // If it's the default value (empty or old default), maybe update it? - // For now, let's respect the file, but if missing/empty, use our runtime defaults. if settings.cache_root.is_empty() { settings.cache_root = defaults.cache_root.clone(); } @@ -174,12 +543,18 @@ impl AppState { ); settings.bt_max_connections = enginefs::backend::DEFAULT_BT_MAX_CONNECTIONS; } - crate::routes::system::apply_proxy_environment_overrides(&mut settings); return settings; } tracing::info!("Using default settings"); - let mut settings = defaults.clone(); + defaults.clone() + } + + pub fn load_settings( + config_dir: &std::path::Path, + defaults: &ServerSettings, + ) -> ServerSettings { + let mut settings = Self::load_raw_settings(config_dir, defaults); crate::routes::system::apply_proxy_environment_overrides(&mut settings); settings } @@ -190,23 +565,30 @@ impl AppState { pub struct TrackerStorageBridge { settings: Arc>, settings_path: PathBuf, - settings_persistence: Arc>, + settings_persistence: Arc, } impl TrackerStorageBridge { #[allow(dead_code)] // Retained for embedders that construct the bridge directly. pub fn new(settings: Arc>, settings_path: PathBuf) -> Self { Self::new_with_persistence( - settings, + settings.clone(), settings_path, - Arc::new(tokio::sync::Mutex::new(())), + Arc::new(SettingsPersistenceCoordinator::new( + settings + .try_read() + .expect( + "shared settings must be uncontended during tracker bridge construction", + ) + .clone(), + )), ) } pub fn new_with_persistence( settings: Arc>, settings_path: PathBuf, - settings_persistence: Arc>, + settings_persistence: Arc, ) -> Self { Self { settings, @@ -214,6 +596,50 @@ impl TrackerStorageBridge { settings_persistence, } } + + pub(crate) fn save_trackers_with_completion( + &self, + trackers: Vec, + timestamp: i64, + ) -> tokio::sync::oneshot::Receiver> { + let settings = self.settings.clone(); + let settings_path = self.settings_path.clone(); + let settings_persistence = self.settings_persistence.clone(); + let sequence = settings_persistence.next_tracker_sequence(); + #[cfg(test)] + let before_lock_gate = settings_persistence.take_tracker_before_lock_gate(); + let transaction_persistence = settings_persistence.clone(); + let transaction = async move { + #[cfg(test)] + if let Some(gate) = before_lock_gate { + gate.reach_and_wait().await; + } + let mut raw = transaction_persistence.lock().await; + if sequence < transaction_persistence.latest_admitted_tracker_sequence() { + return Ok(()); + } + let mut next = settings.read().await.clone(); + next.cached_trackers = trackers; + next.trackers_last_updated = timestamp; + let disk = crate::routes::system::preserve_raw_protected_settings(&next, &raw); + transaction_persistence + .persist_settings(&settings_path, &disk) + .await?; + *raw = disk; + *settings.write().await = next; + tracing::debug!("Saved cached trackers to settings"); + Ok(()) + }; + + match settings_persistence.register_tracker_transaction(sequence, transaction) { + Ok(completion) => completion, + Err(error) => { + let (completion_tx, completion_rx) = tokio::sync::oneshot::channel(); + let _ = completion_tx.send(Err(error.into())); + completion_rx + } + } + } } impl enginefs::TrackerStorage for TrackerStorageBridge { @@ -265,24 +691,780 @@ impl enginefs::TrackerStorage for TrackerStorageBridge { } fn save_trackers(&self, trackers: Vec, timestamp: i64) { - let settings = self.settings.clone(); - let settings_path = self.settings_path.clone(); - let settings_persistence = self.settings_persistence.clone(); + drop(self.save_trackers_with_completion(trackers, timestamp)); + } +} - // Spawn async task to update and save - tokio::spawn(async move { - let _persistence = settings_persistence.lock().await; - let mut next = settings.read().await.clone(); - next.cached_trackers = trackers; - next.trackers_last_updated = timestamp; - if let Err(e) = - crate::routes::system::persist_settings_atomic(&settings_path, &next).await - { - tracing::error!("Failed to save settings after tracker update: {}", e); - } else { - *settings.write().await = next; - tracing::debug!("Saved cached trackers to settings"); +#[cfg(test)] +mod tests { + use super::{AppState, ServerSettings}; + use crate::{routes::system::update_settings, settings_control::SettingsMutationAuthority}; + use enginefs::EngineFS; + use serde_json::json; + use std::sync::Arc; + + #[derive(Clone)] + struct TestLogWriter(Arc>>); + + impl std::io::Write for TestLogWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[tokio::test] + async fn explicit_save_preserves_raw_protected_values_under_effective_overrides() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let raw = ServerSettings { + allow_private_network_sources: false, + allow_invalid_proxy_tls_certificates: false, + ..ServerSettings::default() + }; + let mut effective = raw.clone(); + effective.allow_private_network_sources = true; + effective.allow_invalid_proxy_tls_certificates = true; + effective.cache_size = 321.0; + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let state = AppState::new_with_raw_and_effective_settings( + engine, + raw, + effective, + temp.path().join("config"), + ); + + state.save_settings().await.unwrap(); + + let disk: ServerSettings = serde_json::from_slice( + &std::fs::read(temp.path().join("config/settings.json")).unwrap(), + ) + .unwrap(); + assert!(!disk.allow_private_network_sources); + assert!(!disk.allow_invalid_proxy_tls_certificates); + assert_eq!(disk.cache_size, 321.0); + let committed_raw = state.settings_persistence.raw_snapshot().await; + assert!(!committed_raw.allow_private_network_sources); + assert!(!committed_raw.allow_invalid_proxy_tls_certificates); + assert_eq!(committed_raw.cache_size, 321.0); + let live = state.settings.read().await; + assert!(live.allow_private_network_sources); + assert!(live.allow_invalid_proxy_tls_certificates); + } + + #[tokio::test] + async fn http_updates_preserve_raw_values_for_same_effective_protected_fields() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let mut case_index = 0usize; + for (raw_value, effective_value) in [(false, true), (true, false)] { + for authority in [ + SettingsMutationAuthority::Untrusted, + SettingsMutationAuthority::TrustedLocal, + SettingsMutationAuthority::HttpAuthorized, + ] { + for field in [ + "allowPrivateNetworkSources", + "allowInvalidProxyTlsCertificates", + ] { + let raw = ServerSettings { + allow_private_network_sources: raw_value, + allow_invalid_proxy_tls_certificates: raw_value, + ..ServerSettings::default() + }; + let mut effective = raw.clone(); + effective.allow_private_network_sources = effective_value; + effective.allow_invalid_proxy_tls_certificates = effective_value; + let config_dir = temp.path().join(format!("case-{case_index}")); + case_index += 1; + let state = AppState::new_with_raw_and_effective_settings( + engine.clone(), + raw, + effective, + config_dir, + ); + let mut payload = json!({"cacheSize": 321.0}); + payload + .as_object_mut() + .unwrap() + .insert(field.to_string(), json!(effective_value)); + + update_settings(&state, &payload, authority).await.unwrap(); + + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(&state.settings_path).unwrap()) + .unwrap(); + assert_eq!(disk.allow_private_network_sources, raw_value, "{field}"); + assert_eq!( + disk.allow_invalid_proxy_tls_certificates, raw_value, + "{field}" + ); + assert_eq!(disk.cache_size, 321.0, "{field}"); + let committed_raw = state.settings_persistence.raw_snapshot().await; + assert_eq!( + committed_raw.allow_private_network_sources, raw_value, + "{field}" + ); + assert_eq!( + committed_raw.allow_invalid_proxy_tls_certificates, raw_value, + "{field}" + ); + let live = state.settings.read().await.clone(); + assert_eq!( + live.allow_private_network_sources, effective_value, + "{field}" + ); + assert_eq!( + live.allow_invalid_proxy_tls_certificates, effective_value, + "{field}" + ); + assert_eq!(live.cache_size, 321.0, "{field}"); + let request = state.proxy_runtime.try_request().unwrap(); + assert_eq!( + request.settings.allow_private_network_sources, effective_value, + "{field}" + ); + assert_eq!( + request.settings.allow_invalid_proxy_tls_certificates, effective_value, + "{field}" + ); + } } + } + } + + #[tokio::test] + async fn tracker_saves_preserve_raw_protected_values_under_effective_overrides() { + for (raw_value, effective_value) in [(false, true), (true, false)] { + let temp = tempfile::tempdir().unwrap(); + let raw = ServerSettings { + allow_private_network_sources: raw_value, + allow_invalid_proxy_tls_certificates: raw_value, + ..ServerSettings::default() + }; + let mut effective = raw.clone(); + effective.allow_private_network_sources = effective_value; + effective.allow_invalid_proxy_tls_certificates = effective_value; + let settings = Arc::new(tokio::sync::RwLock::new(effective)); + let persistence = Arc::new(super::SettingsPersistenceCoordinator::new(raw)); + let settings_path = temp.path().join("settings.json"); + let bridge = super::TrackerStorageBridge::new_with_persistence( + settings.clone(), + settings_path.clone(), + persistence.clone(), + ); + + bridge + .save_trackers_with_completion(vec!["udp://tracker.example".to_string()], 123) + .await + .unwrap() + .unwrap(); + + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(settings_path).unwrap()).unwrap(); + assert_eq!(disk.allow_private_network_sources, raw_value); + assert_eq!(disk.allow_invalid_proxy_tls_certificates, raw_value); + assert_eq!(disk.cached_trackers, ["udp://tracker.example"]); + assert_eq!(disk.trackers_last_updated, 123); + let committed_raw = persistence.raw_snapshot().await; + assert_eq!(committed_raw.allow_private_network_sources, raw_value); + assert_eq!( + committed_raw.allow_invalid_proxy_tls_certificates, + raw_value + ); + let live = settings.read().await; + assert_eq!(live.allow_private_network_sources, effective_value); + assert_eq!(live.allow_invalid_proxy_tls_certificates, effective_value); + assert_eq!(live.cached_trackers, ["udp://tracker.example"]); + assert_eq!(live.trackers_last_updated, 123); + } + } + + #[tokio::test] + async fn older_tracker_save_cannot_overwrite_a_newer_admitted_save() { + let temp = tempfile::tempdir().unwrap(); + let settings = Arc::new(tokio::sync::RwLock::new(ServerSettings::default())); + let persistence = Arc::new(super::SettingsPersistenceCoordinator::new( + ServerSettings::default(), + )); + let bridge = super::TrackerStorageBridge::new_with_persistence( + settings.clone(), + temp.path().join("settings.json"), + persistence.clone(), + ); + let older_gate = persistence.gate_next_tracker_before_lock(); + let older = bridge.save_trackers_with_completion(vec!["udp://older".to_string()], 1); + older_gate.wait_reached().await; + + let newer = bridge.save_trackers_with_completion(vec!["udp://newer".to_string()], 2); + newer.await.unwrap().unwrap(); + older_gate.release(); + older.await.unwrap().unwrap(); + + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(temp.path().join("settings.json")).unwrap()) + .unwrap(); + assert_eq!(disk.cached_trackers, ["udp://newer"]); + assert_eq!(disk.trackers_last_updated, 2); + let raw = persistence.raw_snapshot().await; + assert_eq!(raw.cached_trackers, ["udp://newer"]); + assert_eq!(raw.trackers_last_updated, 2); + let live = settings.read().await; + assert_eq!(live.cached_trackers, ["udp://newer"]); + assert_eq!(live.trackers_last_updated, 2); + } + + #[tokio::test] + async fn rejected_newer_tracker_save_does_not_stale_an_admitted_save() { + let temp = tempfile::tempdir().unwrap(); + let settings = Arc::new(tokio::sync::RwLock::new(ServerSettings::default())); + let persistence = Arc::new(super::SettingsPersistenceCoordinator::new( + ServerSettings::default(), + )); + let bridge = super::TrackerStorageBridge::new_with_persistence( + settings.clone(), + temp.path().join("settings.json"), + persistence.clone(), + ); + let admitted_gate = persistence.gate_next_tracker_before_lock(); + let admitted = bridge.save_trackers_with_completion(vec!["udp://admitted".to_string()], 1); + admitted_gate.wait_reached().await; + + persistence.close(); + let rejected = bridge.save_trackers_with_completion(vec!["udp://rejected".to_string()], 2); + assert!(rejected.await.unwrap().is_err()); + admitted_gate.release(); + admitted.await.unwrap().unwrap(); + persistence.drain().await; + + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(temp.path().join("settings.json")).unwrap()) + .unwrap(); + assert_eq!(disk.cached_trackers, ["udp://admitted"]); + assert_eq!(disk.trackers_last_updated, 1); + assert_eq!(settings.read().await.cached_trackers, ["udp://admitted"]); + } + + #[tokio::test] + async fn failed_newer_tracker_save_does_not_resurrect_an_older_snapshot() { + let temp = tempfile::tempdir().unwrap(); + let settings_path = temp.path().join("settings.json"); + std::fs::create_dir(&settings_path).unwrap(); + let settings = Arc::new(tokio::sync::RwLock::new(ServerSettings::default())); + let persistence = Arc::new(super::SettingsPersistenceCoordinator::new( + ServerSettings::default(), + )); + let bridge = super::TrackerStorageBridge::new_with_persistence( + settings.clone(), + settings_path.clone(), + persistence.clone(), + ); + let older_gate = persistence.gate_next_tracker_before_lock(); + let older = bridge.save_trackers_with_completion(vec!["udp://older".to_string()], 1); + older_gate.wait_reached().await; + + let failed_newer = bridge.save_trackers_with_completion(vec!["udp://newer".to_string()], 2); + assert!(failed_newer.await.unwrap().is_err()); + older_gate.release(); + older.await.unwrap().unwrap(); + + assert!(settings_path.is_dir()); + assert!(settings.read().await.cached_trackers.is_empty()); + assert!(persistence.raw_snapshot().await.cached_trackers.is_empty()); + } + + #[tokio::test] + async fn coordinator_close_rejects_new_work_and_drain_waits_for_admitted_work() { + let coordinator = Arc::new(super::SettingsPersistenceCoordinator::new( + ServerSettings::default(), + )); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let admitted = coordinator + .register_transaction(async move { + let _ = release_rx.await; + Ok(()) + }) + .unwrap(); + + coordinator.close(); + assert!(coordinator.register_transaction(async { Ok(()) }).is_err()); + let drain = coordinator.drain(); + tokio::pin!(drain); + assert!(futures_util::poll!(&mut drain).is_pending()); + + release_tx.send(()).unwrap(); + drain.await; + admitted.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn caller_abort_after_rename_cannot_split_disk_raw_live_and_proxy_policy() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let state = AppState::new_with_raw_and_effective_settings( + engine, + ServerSettings::default(), + ServerSettings::default(), + temp.path().join("config"), + ); + let gate = state.settings_persistence.gate_next_after_rename(); + let worker_state = state.clone(); + let caller = tokio::spawn(async move { + update_settings( + &worker_state, + &json!({ + "allowPrivateNetworkSources": true, + "cacheSize": 777.0, + }), + SettingsMutationAuthority::TrustedLocal, + ) + .await + }); + gate.wait_reached().await; + + caller.abort(); + gate.release(); + assert!(caller.await.unwrap_err().is_cancelled()); + state.settings_persistence.wait_until_idle().await; + + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(&state.settings_path).unwrap()).unwrap(); + assert!(disk.allow_private_network_sources); + assert_eq!(disk.cache_size, 777.0); + let raw = state.settings_persistence.raw_snapshot().await; + assert!(raw.allow_private_network_sources); + assert_eq!(raw.cache_size, 777.0); + let live = state.settings.read().await; + assert!(live.allow_private_network_sources); + assert_eq!(live.cache_size, 777.0); + let request = state.proxy_runtime.try_request().unwrap(); + assert!(request.settings.allow_private_network_sources); + } + + #[tokio::test] + async fn explicit_save_caller_abort_after_rename_still_commits_raw_state() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let state = AppState::new_with_raw_and_effective_settings( + engine, + ServerSettings::default(), + ServerSettings::default(), + temp.path().join("config"), + ); + state.settings.write().await.cache_size = 888.0; + let gate = state.settings_persistence.gate_next_after_rename(); + let worker_state = state.clone(); + let caller = tokio::spawn(async move { worker_state.save_settings().await }); + gate.wait_reached().await; + + caller.abort(); + gate.release(); + assert!(caller.await.unwrap_err().is_cancelled()); + state.settings_persistence.wait_until_idle().await; + + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(&state.settings_path).unwrap()).unwrap(); + assert_eq!(disk.cache_size, 888.0); + assert_eq!( + state.settings_persistence.raw_snapshot().await.cache_size, + 888.0 + ); + assert_eq!(state.settings.read().await.cache_size, 888.0); + } + + #[tokio::test] + async fn post_rename_durability_warning_is_committed_by_all_settings_writers() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let state = AppState::new_with_raw_and_effective_settings( + engine, + ServerSettings::default(), + ServerSettings::default(), + temp.path().join("config"), + ); + + state.settings.write().await.cache_size = 101.0; + state.settings_persistence.fail_next_parent_sync(); + state.save_settings().await.unwrap(); + assert_eq!( + state.settings_persistence.raw_snapshot().await.cache_size, + 101.0 + ); + + state.settings_persistence.fail_next_parent_sync(); + update_settings( + &state, + &json!({ + "cacheSize": 202.0, + "allowPrivateNetworkSources": true, + }), + SettingsMutationAuthority::TrustedLocal, + ) + .await + .unwrap(); + assert!( + state + .proxy_runtime + .try_request() + .unwrap() + .settings + .allow_private_network_sources + ); + + let bridge = super::TrackerStorageBridge::new_with_persistence( + state.settings.clone(), + state.settings_path.clone(), + state.settings_persistence.clone(), + ); + state.settings_persistence.fail_next_parent_sync(); + bridge + .save_trackers_with_completion(vec!["udp://committed".to_string()], 303) + .await + .unwrap() + .unwrap(); + + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(&state.settings_path).unwrap()).unwrap(); + assert_eq!(disk.cache_size, 202.0); + assert!(disk.allow_private_network_sources); + assert_eq!(disk.cached_trackers, ["udp://committed"]); + assert_eq!(disk.trackers_last_updated, 303); + let raw = state.settings_persistence.raw_snapshot().await; + assert_eq!(raw.cache_size, 202.0); + assert!(raw.allow_private_network_sources); + assert_eq!(raw.cached_trackers, ["udp://committed"]); + let live = state.settings.read().await; + assert_eq!(live.cache_size, 202.0); + assert_eq!(live.cached_trackers, ["udp://committed"]); + } + + #[tokio::test] + async fn caller_cancelled_while_waiting_for_settings_guard_is_never_admitted() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let state = AppState::new_with_raw_and_effective_settings( + engine, + ServerSettings::default(), + ServerSettings::default(), + temp.path().join("config"), + ); + let guard = state.settings_persistence.lock().await; + let worker_state = state.clone(); + let caller = tokio::spawn(async move { + update_settings( + &worker_state, + &json!({"cacheSize": 444.0}), + SettingsMutationAuthority::TrustedLocal, + ) + .await }); + tokio::task::yield_now().await; + assert_eq!(state.settings_persistence.active_transaction_count(), 0); + + caller.abort(); + drop(guard); + assert!(caller.await.unwrap_err().is_cancelled()); + state.settings_persistence.wait_until_idle().await; + assert!(!state.settings_path.exists()); + assert_eq!(state.settings.read().await.cache_size, 10_737_418_240.0); + assert_eq!( + state.settings_persistence.raw_snapshot().await.cache_size, + 10_737_418_240.0 + ); + } + + #[tokio::test] + async fn transaction_guard_is_held_through_the_final_seeding_side_effect() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let state = AppState::new_with_raw_and_effective_settings( + engine, + ServerSettings::default(), + ServerSettings::default(), + temp.path().join("config"), + ); + let final_side_effect = state + .settings_persistence + .gate_next_before_final_side_effect(); + let first_state = state.clone(); + let first = tokio::spawn(async move { + update_settings( + &first_state, + &json!({"cacheSize": 111.0, "seedingEnabled": false}), + SettingsMutationAuthority::TrustedLocal, + ) + .await + }); + final_side_effect.wait_reached().await; + assert_eq!(state.settings_persistence.active_transaction_count(), 1); + assert_eq!(state.settings.read().await.cache_size, 111.0); + assert!(!state.engine.seeding_enabled()); + + let second_state = state.clone(); + let second = tokio::spawn(async move { + update_settings( + &second_state, + &json!({"cacheSize": 222.0, "seedingEnabled": true}), + SettingsMutationAuthority::TrustedLocal, + ) + .await + }); + tokio::task::yield_now().await; + assert_eq!(state.settings_persistence.active_transaction_count(), 1); + assert!(!second.is_finished()); + + final_side_effect.release(); + first.await.unwrap().unwrap(); + second.await.unwrap().unwrap(); + assert_eq!(state.settings_persistence.active_transaction_count(), 0); + assert_eq!(state.settings.read().await.cache_size, 222.0); + assert!(state.engine.seeding_enabled()); + assert_eq!( + state.settings_persistence.raw_snapshot().await.cache_size, + 222.0 + ); + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(&state.settings_path).unwrap()).unwrap(); + assert_eq!(disk.cache_size, 222.0); + assert!(disk.seeding_enabled); + } + + #[tokio::test(flavor = "current_thread")] + async fn failed_transactions_log_one_fixed_category_even_without_a_waiter() { + let logs = Arc::new(std::sync::Mutex::new(Vec::new())); + let writer = TestLogWriter(logs.clone()); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_target(false) + .with_writer(move || writer.clone()) + .finish(); + let _subscriber = tracing::subscriber::set_default(subscriber); + let coordinator = Arc::new(super::SettingsPersistenceCoordinator::new( + ServerSettings::default(), + )); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + let completion = coordinator + .register_transaction(async move { + let _ = release_rx.await; + Err(anyhow::anyhow!( + "secret-source-error token-marker payload-marker" + )) + }) + .unwrap(); + drop(completion); + release_tx.send(()).unwrap(); + coordinator.close(); + coordinator.drain().await; + + let completed_before_drop = Arc::new(super::SettingsPersistenceCoordinator::new( + ServerSettings::default(), + )); + let completion = completed_before_drop + .register_transaction(async { + Err(anyhow::anyhow!( + "second-secret-source token-marker payload-marker" + )) + }) + .unwrap(); + completed_before_drop.wait_until_idle().await; + drop(completion); + completed_before_drop.close(); + completed_before_drop.drain().await; + + let output = String::from_utf8( + logs.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(), + ) + .unwrap(); + assert_eq!(output.matches("settings transaction failed").count(), 2); + for secret in [ + "secret-source-error", + "second-secret-source", + "token-marker", + "payload-marker", + ] { + assert!(!output.contains(secret)); + } + } + + #[tokio::test] + async fn http_and_tracker_updates_serialize_without_losing_unrelated_changes() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + for iteration in 0..25 { + let raw = ServerSettings { + allow_private_network_sources: false, + ..ServerSettings::default() + }; + let mut effective = raw.clone(); + effective.allow_private_network_sources = true; + let state = AppState::new_with_raw_and_effective_settings( + engine.clone(), + raw, + effective, + temp.path().join(format!("race-{iteration}")), + ); + let bridge = super::TrackerStorageBridge::new_with_persistence( + state.settings.clone(), + state.settings_path.clone(), + state.settings_persistence.clone(), + ); + let first_gate = state.settings_persistence.gate_next_after_rename(); + let cache_size = 500.0 + iteration as f64; + let tracker = format!("udp://tracker-{iteration}"); + + if iteration % 2 == 0 { + let worker_state = state.clone(); + let http = tokio::spawn(async move { + update_settings( + &worker_state, + &json!({"cacheSize": cache_size}), + SettingsMutationAuthority::TrustedLocal, + ) + .await + }); + first_gate.wait_reached().await; + let trackers = + bridge.save_trackers_with_completion(vec![tracker.clone()], iteration as i64); + tokio::task::yield_now().await; + first_gate.release(); + http.await.unwrap().unwrap(); + trackers.await.unwrap().unwrap(); + } else { + let trackers = + bridge.save_trackers_with_completion(vec![tracker.clone()], iteration as i64); + first_gate.wait_reached().await; + let worker_state = state.clone(); + let http = tokio::spawn(async move { + update_settings( + &worker_state, + &json!({"cacheSize": cache_size}), + SettingsMutationAuthority::TrustedLocal, + ) + .await + }); + tokio::task::yield_now().await; + first_gate.release(); + trackers.await.unwrap().unwrap(); + http.await.unwrap().unwrap(); + } + + let disk: ServerSettings = + serde_json::from_slice(&std::fs::read(&state.settings_path).unwrap()).unwrap(); + assert_eq!(disk.cache_size, cache_size, "iteration {iteration}"); + assert_eq!( + disk.cached_trackers.as_slice(), + std::slice::from_ref(&tracker), + "iteration {iteration}" + ); + assert!(!disk.allow_private_network_sources, "iteration {iteration}"); + let raw = state.settings_persistence.raw_snapshot().await; + assert_eq!(raw.cache_size, cache_size, "iteration {iteration}"); + assert_eq!( + raw.cached_trackers.as_slice(), + std::slice::from_ref(&tracker), + "iteration {iteration}" + ); + assert!(!raw.allow_private_network_sources, "iteration {iteration}"); + let live = state.settings.read().await; + assert_eq!(live.cache_size, cache_size, "iteration {iteration}"); + assert_eq!(live.cached_trackers, [tracker], "iteration {iteration}"); + assert!(live.allow_private_network_sources, "iteration {iteration}"); + } + } + + #[tokio::test] + async fn shutdown_rejects_http_direct_and_tracker_settings_writers() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let state = AppState::new_with_raw_and_effective_settings( + engine, + ServerSettings::default(), + ServerSettings::default(), + temp.path().join("config"), + ); + let bridge = super::TrackerStorageBridge::new_with_persistence( + state.settings.clone(), + state.settings_path.clone(), + state.settings_persistence.clone(), + ); + state.settings_persistence.close(); + + assert!(state.save_settings().await.is_err()); + assert!(matches!( + update_settings( + &state, + &json!({"cacheSize": 909.0}), + SettingsMutationAuthority::TrustedLocal, + ) + .await, + Err(crate::routes::system::SettingsUpdateError::Persistence(_)) + )); + assert!( + bridge + .save_trackers_with_completion(vec!["udp://rejected".to_string()], 1) + .await + .unwrap() + .is_err() + ); + state.settings_persistence.drain().await; + + assert!(!state.settings_path.exists()); + assert_eq!(state.settings.read().await.cache_size, 10_737_418_240.0); + assert!(state.settings.read().await.cached_trackers.is_empty()); + assert_eq!(state.settings_persistence.active_transaction_count(), 0); } } diff --git a/server/tests/proxy_security.rs b/server/tests/proxy_security.rs index d3d8fb7..929881d 100644 --- a/server/tests/proxy_security.rs +++ b/server/tests/proxy_security.rs @@ -6,7 +6,7 @@ use axum::{ }; use futures_util::{StreamExt, stream}; use serde_json::json; -use std::{convert::Infallible, time::Duration}; +use std::{convert::Infallible, io::Read, time::Duration}; async fn range(headers: HeaderMap) -> Response { let bytes = b"0123456789"; @@ -346,6 +346,9 @@ async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> any assert_eq!(settings["values"]["allowPrivateNetworkSources"], false); assert!(!settings.to_string().contains(&token)); + let log_dir = config_dir.join("logs"); + std::fs::write(log_dir.join("proof.dmp"), token.as_bytes())?; + std::fs::write(log_dir.join("proof.log"), b"diagnostics-log-sentinel")?; let diagnostics = tokio::time::timeout( Duration::from_secs(5), client.get(format!("{base}/diagnostics/export")).send(), @@ -354,11 +357,23 @@ async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> any .error_for_status()? .bytes() .await?; - assert!( - !diagnostics - .windows(token.len()) - .any(|bytes| bytes == token.as_bytes()) - ); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(diagnostics))?; + let mut saw_log_sentinel = false; + for index in 0..archive.len() { + let mut entry = archive.by_index(index)?; + assert_ne!(entry.name(), "logs/proof.dmp"); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + assert!( + !bytes + .windows(token.len()) + .any(|part| part == token.as_bytes()) + ); + saw_log_sentinel |= bytes + .windows(b"diagnostics-log-sentinel".len()) + .any(|part| part == b"diagnostics-log-sentinel"); + } + assert!(saw_log_sentinel); fixture_task.abort(); let _ = fixture_task.await; From 29fa3cec762db33596738f818ec197a07167109b Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:57:33 -0400 Subject: [PATCH 21/25] security: bound proxy playlist lifecycles --- server/src/network_security/mod.rs | 2 +- server/src/network_security/runtime.rs | 210 +++- server/src/routes/proxy.rs | 1476 +++++++++++++++++++++++- 3 files changed, 1630 insertions(+), 58 deletions(-) diff --git a/server/src/network_security/mod.rs b/server/src/network_security/mod.rs index 658a932..027c8c4 100644 --- a/server/src/network_security/mod.rs +++ b/server/src/network_security/mod.rs @@ -7,7 +7,7 @@ pub(crate) use resolver::{ SystemLocalNetworkProvider, }; pub(crate) use runtime::{ - ProxyPolicySettings, ProxyProducerLease, ProxyRequestContext, ProxyRuntime, + ProxyPlaylistPermit, ProxyPolicySettings, ProxyProducerLease, ProxyRequestContext, ProxyRuntime, }; #[cfg(test)] diff --git a/server/src/network_security/runtime.rs b/server/src/network_security/runtime.rs index c87dcb8..b3002b9 100644 --- a/server/src/network_security/runtime.rs +++ b/server/src/network_security/runtime.rs @@ -1,6 +1,8 @@ use super::resolver::{ DestinationError, DestinationValidator, OutboundPolicy, ResolvedDestination, }; +#[cfg(test)] +use std::sync::atomic::{AtomicUsize, Ordering}; use std::{ collections::HashMap, net::IpAddr, @@ -13,6 +15,8 @@ use url::Url; const MAX_CONCURRENT_PROXY_REQUESTS: usize = 64; const MAX_CONCURRENT_PROXY_REQUESTS_PER_PEER: usize = 16; +const MAX_CONCURRENT_PLAYLISTS: usize = 8; +const MAX_CONCURRENT_PLAYLISTS_PER_PEER: usize = 4; #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(crate) struct ProxyPolicySettings { @@ -26,15 +30,23 @@ pub(crate) struct ProxyRequestContext { capacity: ProxyCapacityPermit, #[cfg(test)] producer_probe: Option, + #[cfg(test)] + playlist_body_polls: Arc, } pub(crate) struct ProxyProducerLease { cancellation: CancellationToken, _capacity: ProxyCapacityPermit, + _playlist_capacity: Option, + playlist_delivery_deadline: Option, #[cfg(test)] producer_probe: Option, } +pub(crate) struct ProxyPlaylistPermit { + _capacity: ProxyCapacityPermit, +} + #[cfg(test)] #[derive(Clone)] pub(crate) struct ProxyProducerProbe { @@ -50,6 +62,7 @@ struct ProxyProducerProbeState { #[cfg(test)] struct ProxyProducerProbeSignals { outcome: ProxyProducerProbeOutcome, + published_chunks: usize, } #[cfg(test)] @@ -71,6 +84,7 @@ impl ProxyProducerProbe { state: Arc::new(ProxyProducerProbeState { signals: Mutex::new(ProxyProducerProbeSignals { outcome: ProxyProducerProbeOutcome::Pending, + published_chunks: 0, }), notify: Notify::new(), }), @@ -129,6 +143,31 @@ impl ProxyProducerProbe { self.wait_for(Self::outcome).await } + pub(crate) async fn wait_for_published_chunks( + &self, + expected: usize, + ) -> Result<(), ProxyProducerProbeTerminated> { + self.wait_for(|signals| { + if signals.published_chunks >= expected { + Some(Ok(())) + } else if signals.outcome == ProxyProducerProbeOutcome::Terminated { + Some(Err(ProxyProducerProbeTerminated)) + } else { + None + } + }) + .await + } + + pub(crate) fn mark_chunk_published(&self) { + self.update(|signals| { + signals.published_chunks = signals + .published_chunks + .checked_add(1) + .expect("proxy producer test publication counter overflow"); + }); + } + pub(crate) fn mark_full_deadline_armed(&self) { self.update(|signals| { if signals.outcome == ProxyProducerProbeOutcome::Pending { @@ -147,10 +186,32 @@ impl ProxyProducerProbe { } impl ProxyRequestContext { + #[cfg(test)] + pub(crate) fn playlist_body_polls(&self) -> Arc { + self.playlist_body_polls.clone() + } + pub(crate) fn into_producer_lease(self) -> ProxyProducerLease { ProxyProducerLease { cancellation: self.cancellation, _capacity: self.capacity, + _playlist_capacity: None, + playlist_delivery_deadline: None, + #[cfg(test)] + producer_probe: self.producer_probe, + } + } + + pub(crate) fn into_playlist_producer_lease( + self, + playlist_capacity: ProxyPlaylistPermit, + delivery_deadline: tokio::time::Instant, + ) -> ProxyProducerLease { + ProxyProducerLease { + cancellation: self.cancellation, + _capacity: self.capacity, + _playlist_capacity: Some(playlist_capacity), + playlist_delivery_deadline: Some(delivery_deadline), #[cfg(test)] producer_probe: self.producer_probe, } @@ -162,6 +223,10 @@ impl ProxyProducerLease { &self.cancellation } + pub(crate) fn playlist_delivery_deadline(&self) -> Option { + self.playlist_delivery_deadline + } + #[cfg(test)] pub(crate) fn producer_probe(&self) -> Option { self.producer_probe.clone() @@ -202,16 +267,24 @@ impl ProxyCapacity { fn try_acquire( self: &Arc, peer: Option, + global_limit: usize, + peer_limit: usize, + ) -> Result { + self.try_acquire_normalized(normalize_peer(peer), global_limit, peer_limit) + } + + fn try_acquire_normalized( + self: &Arc, + peer: ProxyPeer, + global_limit: usize, + peer_limit: usize, ) -> Result { - let peer = normalize_peer(peer); let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let peer_active = state.peers.get(&peer).copied().unwrap_or(0); - if state.global >= MAX_CONCURRENT_PROXY_REQUESTS - || peer_active >= MAX_CONCURRENT_PROXY_REQUESTS_PER_PEER - { + if state.global >= global_limit || peer_active >= peer_limit { return Err(ProxyCapacityError); } state.global += 1; @@ -265,9 +338,12 @@ fn normalize_peer(peer: Option) -> ProxyPeer { pub(crate) struct ProxyRuntime { validator: Arc, capacity: Arc, + playlist_capacity: Arc, generation: Mutex, #[cfg(test)] next_producer_probe: Mutex>, + #[cfg(test)] + playlist_body_polls: Arc, } impl ProxyRuntime { @@ -275,12 +351,15 @@ impl ProxyRuntime { Self { validator, capacity: Arc::new(ProxyCapacity::default()), + playlist_capacity: Arc::new(ProxyCapacity::default()), generation: Mutex::new(ProxyGeneration { settings, cancellation: CancellationToken::new(), }), #[cfg(test)] next_producer_probe: Mutex::new(None), + #[cfg(test)] + playlist_body_polls: Arc::new(AtomicUsize::new(0)), } } @@ -293,7 +372,11 @@ impl ProxyRuntime { &self, peer: Option, ) -> Result { - let capacity = self.capacity.try_acquire(peer)?; + let capacity = self.capacity.try_acquire( + peer, + MAX_CONCURRENT_PROXY_REQUESTS, + MAX_CONCURRENT_PROXY_REQUESTS_PER_PEER, + )?; let generation = self .generation .lock() @@ -310,9 +393,26 @@ impl ProxyRuntime { capacity, #[cfg(test)] producer_probe, + #[cfg(test)] + playlist_body_polls: self.playlist_body_polls.clone(), }) } + pub(crate) fn try_playlist( + &self, + context: &ProxyRequestContext, + ) -> Result { + self.playlist_capacity + .try_acquire_normalized( + context.capacity.peer, + MAX_CONCURRENT_PLAYLISTS, + MAX_CONCURRENT_PLAYLISTS_PER_PEER, + ) + .map(|capacity| ProxyPlaylistPermit { + _capacity: capacity, + }) + } + #[cfg(test)] pub(crate) fn probe_next_request_producer(&self) -> ProxyProducerProbe { let probe = ProxyProducerProbe::new(); @@ -338,6 +438,21 @@ impl ProxyRuntime { (state.global, state.peers.len()) } + #[cfg(test)] + pub(crate) fn playlist_capacity_snapshot(&self) -> (usize, usize) { + let state = self + .playlist_capacity + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (state.global, state.peers.len()) + } + + #[cfg(test)] + pub(crate) fn playlist_body_poll_count(&self) -> usize { + self.playlist_body_polls.load(Ordering::SeqCst) + } + pub(crate) async fn validate( &self, context: &ProxyRequestContext, @@ -576,6 +691,91 @@ mod tests { } } + #[test] + fn playlist_admission_limits_one_peer_to_four_without_blocking_another() { + let runtime = runtime(ProxyPolicySettings::default()); + let first = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 21)); + let second = IpAddr::V4(Ipv4Addr::new(192, 0, 2, 22)); + let first_contexts: Vec<_> = (0..5) + .map(|_| runtime.try_request_for_peer(Some(first)).unwrap()) + .collect(); + let permits: Vec<_> = first_contexts[..4] + .iter() + .map(|context| runtime.try_playlist(context).unwrap()) + .collect(); + + assert!(runtime.try_playlist(&first_contexts[4]).is_err()); + let other = runtime.try_request_for_peer(Some(second)).unwrap(); + assert!(runtime.try_playlist(&other).is_ok()); + + drop(permits); + assert!(runtime.try_playlist(&first_contexts[4]).is_ok()); + } + + #[test] + fn playlist_admission_limits_global_work_to_eight() { + let runtime = runtime(ProxyPolicySettings::default()); + let contexts: Vec<_> = (1..=9) + .map(|host| { + runtime + .try_request_for_peer(Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, host)))) + .unwrap() + }) + .collect(); + let mut permits: Vec<_> = contexts[..8] + .iter() + .map(|context| runtime.try_playlist(context).unwrap()) + .collect(); + + assert!(runtime.try_playlist(&contexts[8]).is_err()); + drop(permits.pop().unwrap()); + let replacement = runtime.try_playlist(&contexts[8]).unwrap(); + assert_eq!(runtime.playlist_capacity_snapshot(), (8, 8)); + drop(replacement); + drop(permits); + assert!(runtime.try_playlist(&contexts[8]).is_ok()); + } + + #[test] + fn playlist_admission_normalizes_ipv4_mapped_peers() { + let runtime = runtime(ProxyPolicySettings::default()); + let ipv4 = Ipv4Addr::new(203, 0, 113, 31); + let contexts: Vec<_> = (0..2) + .map(|_| { + runtime + .try_request_for_peer(Some(IpAddr::V4(ipv4))) + .unwrap() + }) + .chain((0..3).map(|_| { + runtime + .try_request_for_peer(Some(IpAddr::V6(ipv4.to_ipv6_mapped()))) + .unwrap() + })) + .collect(); + let permits: Vec<_> = contexts[..4] + .iter() + .map(|context| runtime.try_playlist(context).unwrap()) + .collect(); + + assert!(runtime.try_playlist(&contexts[4]).is_err()); + drop(permits); + assert!(runtime.try_playlist(&contexts[4]).is_ok()); + } + + #[test] + fn dropping_last_playlist_permits_removes_idle_peer_entries() { + let runtime = runtime(ProxyPolicySettings::default()); + + for host in 1..=200 { + let context = runtime + .try_request_for_peer(Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, host)))) + .unwrap(); + drop(runtime.try_playlist(&context).unwrap()); + } + + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + } + #[tokio::test] async fn restrictive_reconfiguration_cancels_the_old_generation() { let runtime = runtime(ProxyPolicySettings { diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index 532607b..d70668f 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -1,7 +1,10 @@ #[cfg(test)] use crate::network_security::ProxyProducerProbe; use crate::{ - network_security::{DestinationError, ProxyProducerLease, ProxyRequestContext, ProxyRuntime}, + network_security::{ + DestinationError, ProxyPlaylistPermit, ProxyProducerLease, ProxyRequestContext, + ProxyRuntime, + }, state::AppState, }; use axum::{ @@ -38,6 +41,7 @@ const RAW_CANONICAL_PATH_OPTION: &str = "&x-stream-path=raw"; const RESPONSE_HEADER_DEADLINE: Duration = Duration::from_secs(30); const UPSTREAM_READ_IDLE_DEADLINE: Duration = Duration::from_secs(30); const DOWNSTREAM_NO_PROGRESS_DEADLINE: Duration = Duration::from_secs(120); +const PLAYLIST_LIFETIME_DEADLINE: Duration = Duration::from_secs(120); const PROXY_BODY_CHUNK_SIZE: usize = 64 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -580,28 +584,47 @@ async fn handle_proxy_suffix_for_peer( if !content_encoding_is_identity_only(&upstream_headers) { return proxy_error_response(ProxyError::Upstream); } - let body = match collect_playlist(upstream, &context).await { + if declared_playlist_length_exceeds_limit(&upstream) { + return proxy_error_response(ProxyError::Upstream); + } + let playlist_capacity = match runtime.try_playlist(&context) { + Ok(permit) => permit, + Err(_) => return proxy_error_response(ProxyError::Capacity), + }; + let collection_deadline = tokio::time::Instant::now() + PLAYLIST_LIFETIME_DEADLINE; + let body = match collect_playlist_until(upstream, &context, collection_deadline).await { Ok(body) => body, Err(error) => return proxy_error_response(error), }; - let body = match String::from_utf8(body) - .map_err(|_| ProxyError::Upstream) - .and_then(|body| { - rewrite_playlist_with_options( - &body, - &final_url, - &effective_custom_request_headers, - &effective_response_headers, - ) - }) { - Ok(body) => body, + let rewritten = match rewrite_playlist_off_thread( + body, + final_url, + effective_custom_request_headers, + effective_response_headers.clone(), + context, + playlist_capacity, + ) + .await + { + Ok(output) => output, Err(error) => return proxy_error_response(error), }; + let PlaylistRewriteOutput { + body, + context, + playlist_capacity, + } = rewritten; return build_proxy_response( status, &upstream_headers, &effective_response_headers, - buffered_proxy_body(Bytes::from(body), context.into_producer_lease()), + buffered_proxy_body( + Bytes::from(body), + context.into_playlist_producer_lease( + playlist_capacity, + tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE, + ), + ), true, credential_bearing, ); @@ -838,6 +861,10 @@ impl ProxyBodySource { .map_or(ProxySourceItem::Eof, ProxySourceItem::Chunk), } } + + fn is_drained(&self) -> bool { + matches!(self, Self::Buffered(None)) + } } enum ProxyHandoffSlot { @@ -868,6 +895,7 @@ struct ProxyHandoff { producer_notify: Notify, consumer_notify: Notify, cancellation: CancellationToken, + fixed_delivery_deadline: Option, #[cfg(test)] producer_probe: Option, } @@ -885,7 +913,10 @@ enum ProxyConsumerItem { } impl ProxyHandoff { - fn new(cancellation: CancellationToken) -> Self { + fn new( + cancellation: CancellationToken, + fixed_delivery_deadline: Option, + ) -> Self { Self { state: Mutex::new(ProxyHandoffState { slot: ProxyHandoffSlot::Empty, @@ -895,6 +926,7 @@ impl ProxyHandoff { producer_notify: Notify::new(), consumer_notify: Notify::new(), cancellation, + fixed_delivery_deadline, #[cfg(test)] producer_probe: None, } @@ -957,6 +989,15 @@ impl ProxyHandoff { if state.terminal != ProxyHandoffTerminal::Running { return Err(ProxyProducerStop::Failed); } + if self + .fixed_delivery_deadline + .is_some_and(|deadline| tokio::time::Instant::now() >= deadline) + { + Self::fail_locked(&mut state); + drop(state); + self.consumer_notify.notify_waiters(); + return Err(ProxyProducerStop::Failed); + } if matches!(state.slot, ProxyHandoffSlot::Empty) { state.slot = ProxyHandoffSlot::Reserved; return Ok(()); @@ -968,6 +1009,10 @@ impl ProxyHandoff { self.fail(); return Err(ProxyProducerStop::Failed); } + _ = wait_for_optional_deadline(self.fixed_delivery_deadline) => { + self.fail(); + return Err(ProxyProducerStop::Failed); + } _ = &mut notified => {} } } @@ -993,12 +1038,29 @@ impl ProxyHandoff { self.consumer_notify.notify_waiters(); return Err(ProxyProducerStop::Failed); } + if self + .fixed_delivery_deadline + .is_some_and(|deadline| tokio::time::Instant::now() >= deadline) + { + Self::fail_locked(&mut state); + drop(state); + #[cfg(test)] + if let Some(producer_probe) = &self.producer_probe { + producer_probe.mark_terminated_before_ready(); + } + self.consumer_notify.notify_waiters(); + return Err(ProxyProducerStop::Failed); + } debug_assert!(matches!(state.slot, ProxyHandoffSlot::Reserved)); state.slot = ProxyHandoffSlot::Full { bytes, deadline }; #[cfg(test)] let consumer_notification_deferred = self.producer_probe.is_some(); drop(state); #[cfg(test)] + if let Some(producer_probe) = &self.producer_probe { + producer_probe.mark_chunk_published(); + } + #[cfg(test)] if consumer_notification_deferred { return Ok(()); } @@ -1006,6 +1068,11 @@ impl ProxyHandoff { Ok(()) } + fn delivery_deadline(&self, sliding_deadline: tokio::time::Instant) -> tokio::time::Instant { + self.fixed_delivery_deadline + .map_or(sliding_deadline, |fixed| fixed.min(sliding_deadline)) + } + async fn wait_until_consumed( &self, deadline: tokio::time::Instant, @@ -1100,7 +1167,10 @@ impl ProxyHandoff { if self.cancellation.is_cancelled() { Self::fail_locked(&mut state); } else if !state.consumer_closed && state.terminal == ProxyHandoffTerminal::Running { - debug_assert!(matches!(state.slot, ProxyHandoffSlot::Reserved)); + debug_assert!(matches!( + state.slot, + ProxyHandoffSlot::Empty | ProxyHandoffSlot::Reserved + )); state.slot = ProxyHandoffSlot::Empty; state.terminal = ProxyHandoffTerminal::Clean; } @@ -1299,6 +1369,10 @@ async fn read_source_chunk( _ = handoff.consumer_closed() => { return Err(ProxyProducerStop::ConsumerClosed); } + _ = wait_for_optional_deadline(handoff.fixed_delivery_deadline) => { + handoff.fail(); + return Err(ProxyProducerStop::Failed); + } _ = &mut deadline => { handoff.fail(); return Err(ProxyProducerStop::Failed); @@ -1318,6 +1392,13 @@ async fn read_source_chunk( } } +async fn wait_for_optional_deadline(deadline: Option) { + match deadline { + Some(deadline) => tokio::time::sleep_until(deadline).await, + None => futures_util::future::pending().await, + } +} + async fn run_proxy_body_producer( mut resources: ProxyProducerResources, handoff: Arc, @@ -1352,7 +1433,8 @@ async fn run_proxy_body_producer( .saturating_add(PROXY_BODY_CHUNK_SIZE) .min(bytes.len()); let chunk = Bytes::copy_from_slice(&bytes[offset..end]); - let deadline = tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE; + let deadline = handoff + .delivery_deadline(tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE); if handoff.publish(chunk, deadline).is_err() { guard.disarm(); return; @@ -1363,6 +1445,12 @@ async fn run_proxy_body_producer( } offset = end; } + if resources.source.is_drained() { + drop(resources); + handoff.clean(); + guard.disarm(); + return; + } } } @@ -1370,7 +1458,10 @@ fn spawn_proxy_body( source: ProxyBodySource, lease: ProxyProducerLease, ) -> (Body, tokio::task::AbortHandle) { - let handoff = ProxyHandoff::new(lease.cancellation().clone()); + let handoff = ProxyHandoff::new( + lease.cancellation().clone(), + lease.playlist_delivery_deadline(), + ); #[cfg(test)] let handoff = handoff.with_producer_probe(lease.producer_probe()); let handoff = Arc::new(handoff); @@ -1409,49 +1500,110 @@ fn buffered_proxy_body(bytes: Bytes, lease: ProxyProducerLease) -> Body { const MAX_PLAYLIST_INPUT: usize = 8 * 1024 * 1024; +fn declared_playlist_length_exceeds_limit(response: &reqwest::Response) -> bool { + response + .content_length() + .and_then(|length| usize::try_from(length).ok()) + .is_some_and(|length| length > MAX_PLAYLIST_INPUT) +} + +#[cfg(test)] async fn collect_playlist( response: reqwest::Response, context: &ProxyRequestContext, ) -> Result, ProxyError> { - if response - .content_length() - .and_then(|length| usize::try_from(length).ok()) - .is_some_and(|length| length > MAX_PLAYLIST_INPUT) - { + collect_playlist_until( + response, + context, + tokio::time::Instant::now() + PLAYLIST_LIFETIME_DEADLINE, + ) + .await +} + +async fn collect_playlist_until( + response: reqwest::Response, + context: &ProxyRequestContext, + collection_deadline: tokio::time::Instant, +) -> Result, ProxyError> { + if declared_playlist_length_exceeds_limit(&response) { return Err(ProxyError::Upstream); } - let mut bytes = Vec::with_capacity( - response - .content_length() - .and_then(|length| usize::try_from(length).ok()) - .unwrap_or(0) - .min(MAX_PLAYLIST_INPUT), - ); - let mut stream = response.bytes_stream(); + let declared_length = response + .content_length() + .and_then(|length| usize::try_from(length).ok()); + #[cfg(test)] + let playlist_body_polls = context.playlist_body_polls(); + let stream = Box::pin(response.bytes_stream().map(move |item| { + #[cfg(test)] + playlist_body_polls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + item.map_err(|_| ProxySourceError) + })); + collect_playlist_stream( + stream, + declared_length, + &context.cancellation, + collection_deadline, + ) + .await +} + +async fn collect_playlist_stream( + mut stream: UpstreamByteStream, + declared_length: Option, + cancellation: &CancellationToken, + collection_deadline: tokio::time::Instant, +) -> Result, ProxyError> { + let mut bytes = Vec::new(); + if let Some(declared_length) = declared_length { + reserve_playlist_input(&mut bytes, declared_length)?; + } + let mut read_deadline = tokio::time::Instant::now() + UPSTREAM_READ_IDLE_DEADLINE; loop { let next = tokio::select! { biased; - _ = context.cancellation.cancelled() => return Err(ProxyError::Cancelled), - result = tokio::time::timeout(Duration::from_secs(30), stream.next()) => { - result.map_err(|_| ProxyError::Upstream)? + _ = cancellation.cancelled() => return Err(ProxyError::Cancelled), + _ = tokio::time::sleep_until(collection_deadline) => { + return Err(ProxyError::Upstream); + } + _ = tokio::time::sleep_until(read_deadline) => { + return Err(ProxyError::Upstream); } + item = stream.next() => item, }; let Some(chunk) = next else { break; }; let chunk = chunk.map_err(|_| ProxyError::Upstream)?; - let next_length = bytes - .len() - .checked_add(chunk.len()) - .ok_or(ProxyError::Upstream)?; - if next_length > MAX_PLAYLIST_INPUT { - return Err(ProxyError::Upstream); + if chunk.is_empty() { + tokio::task::yield_now().await; + continue; } + reserve_playlist_input(&mut bytes, chunk.len())?; bytes.extend_from_slice(&chunk); + read_deadline = tokio::time::Instant::now() + UPSTREAM_READ_IDLE_DEADLINE; } Ok(bytes) } +fn reserve_playlist_input(bytes: &mut Vec, additional: usize) -> Result<(), ProxyError> { + let next_length = bytes + .len() + .checked_add(additional) + .ok_or(ProxyError::Upstream)?; + if next_length > MAX_PLAYLIST_INPUT { + return Err(ProxyError::Upstream); + } + if next_length > bytes.capacity() { + bytes + .try_reserve_exact(next_length - bytes.len()) + .map_err(|_| ProxyError::Upstream)?; + if bytes.capacity() > MAX_PLAYLIST_INPUT { + return Err(ProxyError::Upstream); + } + } + Ok(()) +} + fn build_proxy_response( status: StatusCode, upstream: &HeaderMap, @@ -1573,24 +1725,387 @@ fn proxy_error_response(error: ProxyError) -> Response { const MAX_PLAYLIST_OUTPUT: usize = 16 * 1024 * 1024; +struct PlaylistRewriteJob { + input: Vec, + base_url: Url, + request_headers: HeaderMap, + response_headers: HeaderMap, + context: ProxyRequestContext, + playlist_capacity: ProxyPlaylistPermit, + request_cancellation: CancellationToken, + #[cfg(test)] + worker_gate: Option, +} + +struct PlaylistRewriteOutput { + body: String, + context: ProxyRequestContext, + playlist_capacity: ProxyPlaylistPermit, +} + +#[cfg(test)] +#[derive(Clone)] +struct PlaylistRewriteTestGate { + state: Arc, +} + +#[cfg(test)] +struct PlaylistRewriteTestGateState { + scheduled: std::sync::atomic::AtomicBool, + scheduled_notify: Notify, + claimed: std::sync::atomic::AtomicBool, + claimed_notify: Notify, + released: Mutex, + release_notify: std::sync::Condvar, +} + +#[cfg(test)] +impl PlaylistRewriteTestGate { + fn new() -> Self { + Self { + state: Arc::new(PlaylistRewriteTestGateState { + scheduled: std::sync::atomic::AtomicBool::new(false), + scheduled_notify: Notify::new(), + claimed: std::sync::atomic::AtomicBool::new(false), + claimed_notify: Notify::new(), + released: Mutex::new(false), + release_notify: std::sync::Condvar::new(), + }), + } + } + + fn mark_scheduled(&self) { + self.state + .scheduled + .store(true, std::sync::atomic::Ordering::SeqCst); + self.state.scheduled_notify.notify_waiters(); + } + + async fn wait_until_scheduled(&self) { + loop { + let notified = self.state.scheduled_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self + .state + .scheduled + .load(std::sync::atomic::Ordering::SeqCst) + { + return; + } + notified.await; + } + } + + fn is_claimed(&self) -> bool { + self.state.claimed.load(std::sync::atomic::Ordering::SeqCst) + } + + async fn wait_until_claimed(&self) { + loop { + let notified = self.state.claimed_notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self.state.claimed.load(std::sync::atomic::Ordering::SeqCst) { + return; + } + notified.await; + } + } + + fn block_worker(&self) { + self.state + .claimed + .store(true, std::sync::atomic::Ordering::SeqCst); + self.state.claimed_notify.notify_waiters(); + let released = self + .state + .released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + drop( + self.state + .release_notify + .wait_while(released, |released| !*released) + .unwrap_or_else(std::sync::PoisonError::into_inner), + ); + } + + fn release(&self) { + *self + .state + .released + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + self.state.release_notify.notify_all(); + } +} + +#[derive(Default)] +struct PlaylistJobSlotState { + pending: Option, + completed: Option>, +} + +struct PlaylistJobSlot { + state: Mutex, + notify: Notify, +} + +impl PlaylistJobSlot { + fn new(job: PlaylistRewriteJob) -> Self { + Self { + state: Mutex::new(PlaylistJobSlotState { + pending: Some(job), + completed: None, + }), + notify: Notify::new(), + } + } + + fn lock_state(&self) -> std::sync::MutexGuard<'_, PlaylistJobSlotState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn run(&self) { + let job = self.lock_state().pending.take(); + let Some(job) = job else { + self.notify.notify_waiters(); + return; + }; + #[cfg(test)] + if let Some(worker_gate) = &job.worker_gate { + worker_gate.block_worker(); + } + let result = run_playlist_rewrite_job(job); + self.lock_state().completed = Some(result); + self.notify.notify_waiters(); + } + + fn take_completed(&self) -> Option> { + self.lock_state().completed.take() + } + + fn clear(&self) { + let (pending, completed) = { + let mut state = self.lock_state(); + (state.pending.take(), state.completed.take()) + }; + drop(pending); + drop(completed); + } +} + +struct PlaylistRewriteGuard { + slot: Arc, + request_cancellation: CancellationToken, + abort: tokio::task::AbortHandle, + armed: bool, +} + +impl PlaylistRewriteGuard { + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for PlaylistRewriteGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + self.slot.clear(); + self.request_cancellation.cancel(); + self.abort.abort(); + self.slot.notify.notify_waiters(); + } +} + +struct PlaylistRewriteCancellation<'a> { + policy: &'a CancellationToken, + request: &'a CancellationToken, +} + +impl PlaylistRewriteCancellation<'_> { + fn check(&self) -> Result<(), ProxyError> { + if self.policy.is_cancelled() || self.request.is_cancelled() { + Err(ProxyError::Cancelled) + } else { + Ok(()) + } + } +} + +fn run_playlist_rewrite_job(job: PlaylistRewriteJob) -> Result { + let PlaylistRewriteJob { + input, + base_url, + request_headers, + response_headers, + context, + playlist_capacity, + request_cancellation, + #[cfg(test)] + worker_gate: _, + } = job; + let policy_cancellation = context.cancellation.clone(); + let cancellation = PlaylistRewriteCancellation { + policy: &policy_cancellation, + request: &request_cancellation, + }; + cancellation.check()?; + let input = String::from_utf8(input).map_err(|_| ProxyError::Upstream)?; + let body = rewrite_playlist_with_options_cancellable( + &input, + &base_url, + &request_headers, + &response_headers, + &cancellation, + )?; + cancellation.check()?; + Ok(PlaylistRewriteOutput { + body, + context, + playlist_capacity, + }) +} + +async fn rewrite_playlist_off_thread( + input: Vec, + base_url: Url, + request_headers: HeaderMap, + response_headers: HeaderMap, + context: ProxyRequestContext, + playlist_capacity: ProxyPlaylistPermit, +) -> Result { + let request_cancellation = CancellationToken::new(); + run_playlist_rewrite_off_thread(PlaylistRewriteJob { + input, + base_url, + request_headers, + response_headers, + context, + playlist_capacity, + request_cancellation, + #[cfg(test)] + worker_gate: None, + }) + .await +} + +#[cfg(test)] +async fn rewrite_playlist_off_thread_with_gate( + input: Vec, + base_url: Url, + request_headers: HeaderMap, + response_headers: HeaderMap, + context: ProxyRequestContext, + playlist_capacity: ProxyPlaylistPermit, + worker_gate: PlaylistRewriteTestGate, +) -> Result { + run_playlist_rewrite_off_thread(PlaylistRewriteJob { + input, + base_url, + request_headers, + response_headers, + context, + playlist_capacity, + request_cancellation: CancellationToken::new(), + worker_gate: Some(worker_gate), + }) + .await +} + +async fn run_playlist_rewrite_off_thread( + job: PlaylistRewriteJob, +) -> Result { + let policy_cancellation = job.context.cancellation.clone(); + let request_cancellation = job.request_cancellation.clone(); + #[cfg(test)] + let worker_gate = job.worker_gate.clone(); + let slot = Arc::new(PlaylistJobSlot::new(job)); + let worker_slot = slot.clone(); + let mut worker = tokio::task::spawn_blocking(move || worker_slot.run()); + #[cfg(test)] + if let Some(worker_gate) = worker_gate { + worker_gate.mark_scheduled(); + } + let mut guard = PlaylistRewriteGuard { + slot: slot.clone(), + request_cancellation, + abort: worker.abort_handle(), + armed: true, + }; + + loop { + let notified = slot.notify.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if let Some(result) = slot.take_completed() { + worker.await.map_err(|_| ProxyError::Upstream)?; + guard.disarm(); + return result; + } + tokio::select! { + biased; + _ = policy_cancellation.cancelled() => return Err(ProxyError::Cancelled), + result = &mut worker => { + result.map_err(|_| ProxyError::Upstream)?; + let result = slot.take_completed().ok_or(ProxyError::Upstream)?; + guard.disarm(); + return result; + } + _ = &mut notified => {} + } + } +} + #[cfg(test)] fn rewrite_playlist_bounded(body: &str, base_url: &Url) -> Result { rewrite_playlist_with_options(body, base_url, &HeaderMap::new(), &HeaderMap::new()) } +#[cfg(test)] fn rewrite_playlist_with_options( body: &str, base_url: &Url, request_headers: &HeaderMap, response_headers: &HeaderMap, +) -> Result { + let policy = CancellationToken::new(); + let request = CancellationToken::new(); + rewrite_playlist_with_options_cancellable( + body, + base_url, + request_headers, + response_headers, + &PlaylistRewriteCancellation { + policy: &policy, + request: &request, + }, + ) +} + +fn rewrite_playlist_with_options_cancellable( + body: &str, + base_url: &Url, + request_headers: &HeaderMap, + response_headers: &HeaderMap, + cancellation: &PlaylistRewriteCancellation<'_>, ) -> Result { let mut output = String::new(); output .try_reserve_exact(body.len().min(MAX_PLAYLIST_OUTPUT)) .map_err(|_| ProxyError::Upstream)?; + if output.capacity() > MAX_PLAYLIST_OUTPUT { + return Err(ProxyError::Upstream); + } let bytes = body.as_bytes(); let mut position = 0usize; while position < body.len() { + cancellation.check()?; let ending_start = bytes[position..] .iter() .position(|byte| matches!(byte, b'\r' | b'\n')) @@ -1615,6 +2130,7 @@ fn rewrite_playlist_with_options( request_headers, response_headers, &mut output, + cancellation, )?; } else if line.starts_with('#') || line.bytes().all(|byte| matches!(byte, b' ' | b'\t')) { push_playlist(&mut output, line)?; @@ -1625,6 +2141,7 @@ fn rewrite_playlist_with_options( request_headers, response_headers, &mut output, + cancellation, )?; } push_playlist(&mut output, ending)?; @@ -1642,6 +2159,7 @@ fn rewrite_playlist_tag( request_headers: &HeaderMap, response_headers: &HeaderMap, output: &mut String, + cancellation: &PlaylistRewriteCancellation<'_>, ) -> Result<(), ProxyError> { let Some(colon) = line.find(':') else { return push_playlist(output, line); @@ -1668,7 +2186,14 @@ fn rewrite_playlist_tag( }; push_playlist(output, &line[copied..value_start])?; let value = &line[value_start..value_end]; - rewrite_playlist_reference(value, base_url, request_headers, response_headers, output)?; + rewrite_playlist_reference( + value, + base_url, + request_headers, + response_headers, + output, + cancellation, + )?; copied = value_end; scan_start = value_end + 1; } @@ -1717,7 +2242,9 @@ fn rewrite_playlist_reference( request_headers: &HeaderMap, response_headers: &HeaderMap, output: &mut String, + cancellation: &PlaylistRewriteCancellation<'_>, ) -> Result<(), ProxyError> { + cancellation.check()?; let Some(resolved) = resolve_hls_reference(reference, base_url)? else { return push_playlist(output, reference); }; @@ -2193,6 +2720,9 @@ fn reserve_bounded( additional: usize, maximum: usize, ) -> Result<(), ProxyError> { + if output.capacity() > maximum { + return Err(ProxyError::Upstream); + } let next_length = output .len() .checked_add(additional) @@ -2210,6 +2740,9 @@ fn reserve_bounded( output .try_reserve_exact(desired_capacity - output.len()) .map_err(|_| ProxyError::Upstream)?; + if output.capacity() > maximum { + return Err(ProxyError::Upstream); + } } Ok(()) } @@ -2219,12 +2752,15 @@ mod tests { use super::{ DOWNSTREAM_NO_PROGRESS_DEADLINE, HLS_SCHEME_PRESCAN_BYTES, HLS_VARIABLE_RANGE_SCANS, MAX_HEADER_PAIR, MAX_PLAYLIST_INPUT, MAX_PLAYLIST_OUTPUT, MAX_PROXY_INPUT, MAX_TARGET_URL, - PROXY_BODY_CHUNK_SIZE, ProxyBodySource, ProxyConsumerItem, ProxyError, ProxyHandoff, - ProxyHandoffSlot, ProxyProducerStop, ProxySourceError, apply_redirect_origin_policy, - await_response_headers, buffered_proxy_body, collect_playlist, fetch_with_redirects, - handle_proxy, handle_proxy_suffix, parse_proxy_request, parse_proxy_suffix, - proxy_error_response, resolve_hls_reference, rewrite_playlist_bounded, - rewrite_playlist_with_options, runtime_service, same_origin, spawn_proxy_body, + PROXY_BODY_CHUNK_SIZE, PlaylistRewriteTestGate, ProxyBodySource, ProxyConsumerItem, + ProxyError, ProxyHandoff, ProxyHandoffSlot, ProxyProducerGuard, ProxyProducerResources, + ProxyProducerStop, ProxySourceError, apply_redirect_origin_policy, await_response_headers, + buffered_proxy_body, collect_playlist, collect_playlist_stream, fetch_with_redirects, + handle_proxy, handle_proxy_for_peer, handle_proxy_suffix, parse_proxy_request, + parse_proxy_suffix, proxy_error_response, reserve_bounded, resolve_hls_reference, + rewrite_playlist_bounded, rewrite_playlist_off_thread, + rewrite_playlist_off_thread_with_gate, rewrite_playlist_with_options, + run_proxy_body_producer, runtime_service, same_origin, spawn_proxy_body, streaming_proxy_body, }; use crate::network_security::{ @@ -4071,6 +4607,11 @@ mod tests { .unwrap(); let response = handle_proxy(&runtime, uri, HeaderMap::new(), method).await; assert_eq!(response.status(), status, "{kind}"); + assert_eq!( + runtime.playlist_capacity_snapshot(), + (0, 0), + "{kind} must remain on the ordinary streaming path" + ); assert_eq!(response.headers()[header::CONTENT_LENGTH], "19", "{kind}"); assert_eq!( response.headers()[header::CONTENT_RANGE], @@ -4110,7 +4651,61 @@ mod tests { } else { assert_eq!(body, "#EXTM3U\nsegment.ts\n", "{kind}"); } + wait_for_capacity(&runtime, (0, 0)).await; + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0), "{kind}"); + } + fixture.abort(); + } + + #[tokio::test] + async fn rewritten_playlist_holds_both_quotas_through_delivery_and_releases_them() { + let (address, fixture) = fixture(Router::new().route( + "/master.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from("#EXTM3U\nsegment.ts\n")) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let probe = runtime.probe_next_request_producer(); + let target = format!( + "http://playlist-lifecycle.test:{}/master.m3u8", + address.port() + ); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + probe.wait_for_published_chunks(1).await.unwrap(); + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + assert_eq!(runtime.playlist_capacity_snapshot(), (1, 1)); + + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + assert!( + body.windows(b"/proxy".len()) + .any(|window| window == b"/proxy") + ); + wait_for_capacity(&runtime, (0, 0)).await; + for _ in 0..32 { + if runtime.playlist_capacity_snapshot() == (0, 0) { + break; + } + tokio::task::yield_now().await; } + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); fixture.abort(); } @@ -4608,14 +5203,20 @@ mod tests { ProxyPolicySettings::default(), ); let context = runtime.try_request().unwrap(); + let playlist_capacity = runtime.try_playlist(&context).unwrap(); let cancellation = context.cancellation.clone(); - let lease = context.into_producer_lease(); + let lease = context.into_playlist_producer_lease( + playlist_capacity, + tokio::time::Instant::now() + Duration::from_secs(120), + ); let body = buffered_proxy_body(bytes::Bytes::from_static(b"#EXTM3U\nsegment.ts\n"), lease); assert_eq!(runtime.capacity_snapshot(), (1, 1)); + assert_eq!(runtime.playlist_capacity_snapshot(), (1, 1)); cancellation.cancel(); assert!(axum::body::to_bytes(body, usize::MAX).await.is_err()); wait_for_capacity(&runtime, (0, 0)).await; assert_eq!(runtime.capacity_snapshot(), (0, 0)); + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); } async fn wait_until(mut ready: impl FnMut() -> bool) { @@ -4930,7 +5531,7 @@ mod tests { let producer_probe = runtime.probe_next_request_producer(); let lease = runtime.try_request().unwrap().into_producer_lease(); let handoff = Arc::new( - ProxyHandoff::new(lease.cancellation().clone()) + ProxyHandoff::new(lease.cancellation().clone(), None) .with_producer_probe(lease.producer_probe()), ); assert!(handoff.reserve().await.is_ok()); @@ -4969,7 +5570,7 @@ mod tests { let producer_probe = runtime.probe_next_request_producer(); let lease = runtime.try_request().unwrap().into_producer_lease(); let handoff = Arc::new( - ProxyHandoff::new(lease.cancellation().clone()) + ProxyHandoff::new(lease.cancellation().clone(), None) .with_producer_probe(lease.producer_probe()), ); assert!(handoff.reserve().await.is_ok()); @@ -5005,7 +5606,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn timely_take_wins_when_producer_observes_notify_after_deadline() { - let handoff = ProxyHandoff::new(CancellationToken::new()); + let handoff = ProxyHandoff::new(CancellationToken::new(), None); assert!(handoff.reserve().await.is_ok()); let deadline = tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE; assert!( @@ -5030,7 +5631,7 @@ mod tests { #[tokio::test(start_paused = true)] async fn late_take_fails_without_delivering_expired_chunk() { - let handoff = ProxyHandoff::new(CancellationToken::new()); + let handoff = ProxyHandoff::new(CancellationToken::new(), None); assert!(handoff.reserve().await.is_ok()); let deadline = tokio::time::Instant::now() + DOWNSTREAM_NO_PROGRESS_DEADLINE; assert!( @@ -5398,6 +5999,35 @@ mod tests { assert_eq!(polls.load(Ordering::SeqCst), 0); } + #[tokio::test] + async fn abort_before_first_playlist_producer_poll_reclaims_both_quotas() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let playlist_capacity = runtime.try_playlist(&context).unwrap(); + let (body, abort) = spawn_proxy_body( + ProxyBodySource::Buffered(Some(bytes::Bytes::from_static(b"playlist"))), + context.into_playlist_producer_lease( + playlist_capacity, + tokio::time::Instant::now() + Duration::from_secs(120), + ), + ); + + abort.abort(); + + assert_body_error_then_eof(body).await; + wait_for_capacity(&runtime, (0, 0)).await; + for _ in 0..32 { + if runtime.playlist_capacity_snapshot() == (0, 0) { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + } + #[tokio::test(start_paused = true)] async fn slow_progress_resets_each_downstream_deadline_without_a_total_limit() { let (stream, polls, drops) = TestByteStream::new([ @@ -5432,6 +6062,93 @@ mod tests { assert_eq!(polls.load(Ordering::SeqCst), 4); } + #[tokio::test(start_paused = true)] + async fn playlist_delivery_expires_after_one_hundred_twenty_seconds_despite_progress() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let probe = runtime.probe_next_request_producer(); + let context = runtime.try_request().unwrap(); + let playlist_capacity = runtime.try_playlist(&context).unwrap(); + let started = tokio::time::Instant::now(); + let source = bytes::Bytes::from(vec![b'x'; PROXY_BODY_CHUNK_SIZE * 3]); + let body = buffered_proxy_body( + source, + context.into_playlist_producer_lease( + playlist_capacity, + started + Duration::from_secs(120), + ), + ); + let mut body = body.into_data_stream(); + + probe.wait_for_published_chunks(1).await.unwrap(); + tokio::time::advance(Duration::from_secs(50)).await; + assert_eq!( + body.next().await.unwrap().unwrap().len(), + PROXY_BODY_CHUNK_SIZE + ); + + probe.wait_for_published_chunks(2).await.unwrap(); + tokio::time::advance(Duration::from_secs(50)).await; + assert_eq!( + body.next().await.unwrap().unwrap().len(), + PROXY_BODY_CHUNK_SIZE + ); + + probe.wait_for_published_chunks(3).await.unwrap(); + tokio::time::advance(Duration::from_secs(21)).await; + assert!(body.next().await.unwrap().is_err()); + assert!(body.next().await.is_none()); + assert_eq!( + tokio::time::Instant::now() - started, + Duration::from_secs(121) + ); + wait_for_capacity(&runtime, (0, 0)).await; + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + } + + #[tokio::test(start_paused = true)] + async fn timely_final_playlist_chunk_finishes_cleanly_when_producer_runs_after_deadline() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let playlist_capacity = runtime.try_playlist(&context).unwrap(); + let started = tokio::time::Instant::now(); + let lease = context + .into_playlist_producer_lease(playlist_capacity, started + Duration::from_secs(120)); + let handoff = Arc::new(ProxyHandoff::new( + lease.cancellation().clone(), + lease.playlist_delivery_deadline(), + )); + let guard = ProxyProducerGuard::new(handoff.clone()); + let mut producer = Box::pin(run_proxy_body_producer( + ProxyProducerResources { + source: ProxyBodySource::Buffered(Some(bytes::Bytes::from_static(b"complete"))), + _lease: lease, + }, + handoff.clone(), + guard, + )); + assert!(poll_once(producer.as_mut()).is_pending()); + + tokio::time::advance(Duration::from_secs(119)).await; + match handoff.take().await { + ProxyConsumerItem::Chunk(bytes) => assert_eq!(bytes, "complete"), + ProxyConsumerItem::Failed | ProxyConsumerItem::Eof => { + panic!("timely consumer did not receive the complete playlist") + } + } + tokio::time::advance(Duration::from_secs(2)).await; + + assert!(matches!(poll_once(producer.as_mut()), Poll::Ready(()))); + assert!(matches!(handoff.take().await, ProxyConsumerItem::Eof)); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + } + #[tokio::test] async fn buffered_large_source_crosses_as_independent_bounded_chunks() { let owner_drops = Arc::new(AtomicUsize::new(0)); @@ -5719,6 +6436,650 @@ mod tests { assert_eq!(runtime.capacity_snapshot(), (0, 0)); } + #[tokio::test] + async fn exhausted_playlist_admission_rejects_before_collection() { + let (address, fixture) = fixture(Router::new().route( + "/candidate.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from("#EXTM3U\nsegment.ts\n")) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let contexts: Vec<_> = (1..=8) + .map(|host| { + runtime + .try_request_for_peer(Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, host)))) + .unwrap() + }) + .collect(); + let playlist_permits: Vec<_> = contexts + .iter() + .map(|context| runtime.try_playlist(context).unwrap()) + .collect(); + let target = format!( + "http://playlist-capacity.test:{}/candidate.m3u8", + address.port() + ); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(runtime.playlist_body_poll_count(), 0); + assert_eq!(runtime.playlist_capacity_snapshot(), (8, 8)); + drop(response); + drop(playlist_permits); + drop(contexts); + fixture.abort(); + } + + #[tokio::test] + async fn fifth_same_peer_playlist_is_rejected_without_polling_its_body() { + let (address, fixture) = fixture(Router::new().route( + "/candidate.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from("#EXTM3U\nsegment.ts\n")) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let peer = IpAddr::V4(Ipv4Addr::new(198, 51, 100, 77)); + let contexts: Vec<_> = (0..4) + .map(|_| runtime.try_request_for_peer(Some(peer)).unwrap()) + .collect(); + let playlist_permits: Vec<_> = contexts + .iter() + .map(|context| runtime.try_playlist(context).unwrap()) + .collect(); + let target = format!( + "http://playlist-capacity.test:{}/candidate.m3u8", + address.port() + ); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = + handle_proxy_for_peer(&runtime, Some(peer), uri, HeaderMap::new(), Method::GET).await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(runtime.playlist_body_poll_count(), 0); + assert_eq!(runtime.playlist_capacity_snapshot(), (4, 1)); + drop(response); + drop(playlist_permits); + drop(contexts); + fixture.abort(); + } + + #[tokio::test] + async fn eight_collecting_playlists_release_capacity_for_immediate_re_admission() { + let (address, fixture) = fixture(Router::new().route( + "/pending.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from_stream(futures_util::stream::pending::< + Result, + >())) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let runtime = Arc::new(runtime); + let target = format!( + "http://playlist-concurrency.test:{}/pending.m3u8", + address.port() + ); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + let mut requests = Vec::new(); + for host in 1..=8 { + let request_runtime = runtime.clone(); + let request_uri = uri.clone(); + let peer = IpAddr::V4(Ipv4Addr::new(198, 51, 100, host)); + requests.push(tokio::spawn(async move { + handle_proxy_for_peer( + &request_runtime, + Some(peer), + request_uri, + HeaderMap::new(), + Method::GET, + ) + .await + })); + } + tokio::time::timeout(Duration::from_secs(5), async { + while runtime.playlist_capacity_snapshot() != (8, 8) { + tokio::task::yield_now().await; + } + }) + .await + .expect("eight playlist collections did not acquire capacity"); + let ninth = handle_proxy_for_peer( + &runtime, + Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9))), + uri.clone(), + HeaderMap::new(), + Method::GET, + ) + .await; + assert_eq!(ninth.status(), StatusCode::SERVICE_UNAVAILABLE); + + let released = requests.remove(0); + released.abort(); + assert!(released.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(5), async { + while runtime.playlist_capacity_snapshot().0 != 7 { + tokio::task::yield_now().await; + } + }) + .await + .expect("released playlist collection did not return capacity"); + + let replacement_runtime = runtime.clone(); + let replacement = tokio::spawn(async move { + handle_proxy_for_peer( + &replacement_runtime, + Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, 9))), + uri, + HeaderMap::new(), + Method::GET, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(5), async { + while runtime.playlist_capacity_snapshot() != (8, 8) { + tokio::task::yield_now().await; + } + }) + .await + .expect("replacement playlist was not admitted immediately"); + + replacement.abort(); + assert!(replacement.await.unwrap_err().is_cancelled()); + for request in requests { + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + } + tokio::time::timeout(Duration::from_secs(5), async { + while runtime.capacity_snapshot() != (0, 0) + || runtime.playlist_capacity_snapshot() != (0, 0) + { + tokio::task::yield_now().await; + } + }) + .await + .expect("playlist collection cleanup did not return all capacity"); + fixture.abort(); + } + + #[tokio::test] + async fn declared_oversized_playlist_precedes_exhausted_playlist_admission() { + let (address, fixture) = fixture(Router::new().route( + "/oversized.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .header(header::CONTENT_LENGTH, MAX_PLAYLIST_INPUT + 1) + .body(Body::from_stream(futures_util::stream::pending::< + Result, + >())) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let contexts: Vec<_> = (1..=8) + .map(|host| { + runtime + .try_request_for_peer(Some(IpAddr::V4(Ipv4Addr::new(198, 51, 100, host)))) + .unwrap() + }) + .collect(); + let playlist_permits: Vec<_> = contexts + .iter() + .map(|context| runtime.try_playlist(context).unwrap()) + .collect(); + let target = format!( + "http://playlist-capacity.test:{}/oversized.m3u8", + address.port() + ); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(runtime.playlist_capacity_snapshot(), (8, 8)); + drop(response); + drop(playlist_permits); + drop(contexts); + fixture.abort(); + } + + #[tokio::test] + async fn policy_cancellation_during_playlist_collection_releases_both_quotas() { + let (address, fixture) = fixture(Router::new().route( + "/pending.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from_stream(futures_util::stream::pending::< + Result, + >())) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let runtime = Arc::new(runtime); + let target = format!( + "http://playlist-cancel.test:{}/pending.m3u8", + address.port() + ); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + let request_runtime = runtime.clone(); + let request = tokio::spawn(async move { + handle_proxy(&request_runtime, uri, HeaderMap::new(), Method::GET).await + }); + tokio::time::timeout(Duration::from_secs(5), async { + while runtime.playlist_capacity_snapshot() != (1, 1) { + tokio::task::yield_now().await; + } + }) + .await + .expect("playlist collection did not acquire its permit"); + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + assert_eq!(runtime.playlist_capacity_snapshot(), (1, 1)); + + runtime.begin_reconfigure(ProxyPolicySettings::default()); + let response = request.await.unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + fixture.abort(); + } + + #[tokio::test] + async fn playlist_collection_error_returns_generic_failure_and_releases_both_quotas() { + let (address, fixture) = fixture(Router::new().route( + "/error.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from_stream(futures_util::stream::once(async { + Err::(std::io::Error::other("controlled body error")) + }))) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let target = format!("http://playlist-error.test:{}/error.m3u8", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + + let response = handle_proxy(&runtime, uri, HeaderMap::new(), Method::GET).await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!( + axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(), + "Proxy upstream request failed" + ); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + fixture.abort(); + } + + #[tokio::test] + async fn dropping_handler_during_playlist_collection_releases_both_quotas() { + let (address, fixture) = fixture(Router::new().route( + "/pending.m3u8", + get(|| async { + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from_stream(futures_util::stream::pending::< + Result, + >())) + .unwrap() + }), + )) + .await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let runtime = Arc::new(runtime); + let target = format!("http://playlist-drop.test:{}/pending.m3u8", address.port()); + let uri: Uri = format!("/proxy/?d={}", urlencoding::encode(&target)) + .parse() + .unwrap(); + let request_runtime = runtime.clone(); + let request = tokio::spawn(async move { + handle_proxy(&request_runtime, uri, HeaderMap::new(), Method::GET).await + }); + tokio::time::timeout(Duration::from_secs(5), async { + while runtime.playlist_capacity_snapshot() != (1, 1) { + tokio::task::yield_now().await; + } + }) + .await + .expect("playlist collection did not acquire its permit"); + + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + fixture.abort(); + } + + #[tokio::test(start_paused = true)] + async fn playlist_collection_has_a_fixed_one_hundred_twenty_second_deadline() { + let started = tokio::time::Instant::now(); + let stream = futures_util::stream::unfold(0usize, |index| async move { + tokio::time::sleep(Duration::from_secs(29)).await; + Some((Ok(bytes::Bytes::from_static(b"x")), index + 1)) + }); + let cancellation = CancellationToken::new(); + + let result = collect_playlist_stream( + Box::pin(stream), + None, + &cancellation, + started + Duration::from_secs(120), + ) + .await; + + assert!(matches!(result, Err(ProxyError::Upstream))); + assert_eq!( + tokio::time::Instant::now() - started, + Duration::from_secs(120) + ); + } + + #[tokio::test(start_paused = true)] + async fn empty_playlist_chunks_do_not_reset_read_idle() { + let started = tokio::time::Instant::now(); + let stream = futures_util::stream::once(async { + tokio::time::sleep(Duration::from_secs(29)).await; + Ok(bytes::Bytes::new()) + }) + .chain(futures_util::stream::pending()); + let cancellation = CancellationToken::new(); + + let result = collect_playlist_stream( + Box::pin(stream), + None, + &cancellation, + started + Duration::from_secs(120), + ) + .await; + + assert!(matches!(result, Err(ProxyError::Upstream))); + assert_eq!( + tokio::time::Instant::now() - started, + Duration::from_secs(30) + ); + } + + #[tokio::test(start_paused = true)] + async fn pending_playlist_collection_hits_read_idle_before_absolute_deadline() { + let started = tokio::time::Instant::now(); + let cancellation = CancellationToken::new(); + + let result = collect_playlist_stream( + Box::pin(futures_util::stream::pending()), + None, + &cancellation, + started + Duration::from_secs(120), + ) + .await; + + assert!(matches!(result, Err(ProxyError::Upstream))); + assert_eq!( + tokio::time::Instant::now() - started, + Duration::from_secs(30) + ); + } + + #[tokio::test] + async fn playlist_collection_capacity_never_exceeds_the_input_limit() { + let first = bytes::Bytes::from(vec![b'a'; 5 * 1024 * 1024 + 3]); + let second = bytes::Bytes::from(vec![b'b'; MAX_PLAYLIST_INPUT - first.len()]); + let cancellation = CancellationToken::new(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(120); + + let exact = collect_playlist_stream( + Box::pin(futures_util::stream::iter([Ok(first), Ok(second)])), + None, + &cancellation, + deadline, + ) + .await + .unwrap(); + + assert_eq!(exact.len(), MAX_PLAYLIST_INPUT); + assert!(exact.capacity() <= MAX_PLAYLIST_INPUT); + + let overflow = collect_playlist_stream( + Box::pin(futures_util::stream::iter([ + Ok(bytes::Bytes::from(vec![b'a'; MAX_PLAYLIST_INPUT])), + Ok(bytes::Bytes::from_static(b"x")), + ])), + None, + &cancellation, + tokio::time::Instant::now() + Duration::from_secs(120), + ) + .await; + assert!(matches!(overflow, Err(ProxyError::Upstream))); + } + + #[tokio::test] + async fn blocking_playlist_rewrite_observes_policy_cancellation_and_releases_both_quotas() { + let settings = ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }; + let (runtime, _) = test_runtime("127.0.0.1:1".parse().unwrap(), settings); + let context = runtime.try_request().unwrap(); + let playlist_capacity = runtime.try_playlist(&context).unwrap(); + runtime.begin_reconfigure(ProxyPolicySettings::default()); + + let result = rewrite_playlist_off_thread( + b"#EXTM3U\nsegment.ts\n".to_vec(), + Url::parse("https://media.example/master.m3u8").unwrap(), + HeaderMap::new(), + HeaderMap::new(), + context, + playlist_capacity, + ) + .await; + + assert!(matches!(result, Err(ProxyError::Cancelled))); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + } + + #[tokio::test] + async fn blocking_playlist_rewrite_rejects_invalid_utf8_and_releases_both_quotas() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let playlist_capacity = runtime.try_playlist(&context).unwrap(); + + let result = rewrite_playlist_off_thread( + vec![b'#', b'X', b'\n', 0xff], + Url::parse("https://media.example/master.m3u8").unwrap(), + HeaderMap::new(), + HeaderMap::new(), + context, + playlist_capacity, + ) + .await; + + assert!(matches!(result, Err(ProxyError::Upstream))); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + } + + #[tokio::test] + async fn dropping_async_owner_cancels_a_claimed_playlist_worker_and_releases_payload() { + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let playlist_capacity = runtime.try_playlist(&context).unwrap(); + let gate = PlaylistRewriteTestGate::new(); + let worker_gate = gate.clone(); + let rewrite = tokio::spawn(async move { + rewrite_playlist_off_thread_with_gate( + vec![b'x'; MAX_PLAYLIST_INPUT], + Url::parse("https://media.example/master.m3u8").unwrap(), + HeaderMap::new(), + HeaderMap::new(), + context, + playlist_capacity, + worker_gate, + ) + .await + }); + gate.wait_until_claimed().await; + assert_eq!(runtime.capacity_snapshot(), (1, 1)); + assert_eq!(runtime.playlist_capacity_snapshot(), (1, 1)); + + rewrite.abort(); + let join_error = match rewrite.await { + Err(error) => error, + Ok(_) => panic!("aborted playlist owner unexpectedly completed"), + }; + assert!(join_error.is_cancelled()); + gate.release(); + + wait_for_capacity(&runtime, (0, 0)).await; + for _ in 0..32 { + if runtime.playlist_capacity_snapshot() == (0, 0) { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + } + + #[test] + fn dropping_async_owner_clears_a_queued_playlist_job_before_the_queue_drains() { + let executor = tokio::runtime::Builder::new_current_thread() + .enable_all() + .max_blocking_threads(1) + .build() + .unwrap(); + executor.block_on(async { + let (blocker_started_tx, blocker_started_rx) = tokio::sync::oneshot::channel(); + let (blocker_release_tx, blocker_release_rx) = std::sync::mpsc::channel(); + let blocker = tokio::task::spawn_blocking(move || { + let _ = blocker_started_tx.send(()); + blocker_release_rx.recv().unwrap(); + }); + blocker_started_rx.await.unwrap(); + + let (runtime, _) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let context = runtime.try_request().unwrap(); + let playlist_capacity = runtime.try_playlist(&context).unwrap(); + let gate = PlaylistRewriteTestGate::new(); + let worker_gate = gate.clone(); + let rewrite = tokio::spawn(async move { + rewrite_playlist_off_thread_with_gate( + vec![b'x'; MAX_PLAYLIST_INPUT], + Url::parse("https://media.example/master.m3u8").unwrap(), + HeaderMap::new(), + HeaderMap::new(), + context, + playlist_capacity, + worker_gate, + ) + .await + }); + gate.wait_until_scheduled().await; + assert!(!gate.is_claimed()); + + rewrite.abort(); + let join_error = match rewrite.await { + Err(error) => error, + Ok(_) => panic!("aborted queued playlist owner unexpectedly completed"), + }; + assert!(join_error.is_cancelled()); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + assert_eq!(runtime.playlist_capacity_snapshot(), (0, 0)); + + blocker_release_tx.send(()).unwrap(); + blocker.await.unwrap(); + assert!(!gate.is_claimed()); + }); + } + #[tokio::test] async fn playlist_collection_accepts_exact_limit_and_rejects_streamed_overflow() { let exact = bytes::Bytes::from(vec![b'a'; MAX_PLAYLIST_INPUT]); @@ -6423,6 +7784,7 @@ mod tests { let rewritten = rewrite_playlist_bounded(&exact, &base).unwrap(); assert_eq!(rewritten.len(), MAX_PLAYLIST_OUTPUT); + assert!(rewritten.capacity() <= MAX_PLAYLIST_OUTPUT); exact.push('a'); assert!(matches!( @@ -6431,6 +7793,16 @@ mod tests { )); } + #[test] + fn bounded_playlist_reservation_rejects_an_already_oversized_allocation() { + let mut output = String::with_capacity(MAX_PLAYLIST_OUTPUT + 1); + + assert!(matches!( + reserve_bounded(&mut output, 0, MAX_PLAYLIST_OUTPUT), + Err(ProxyError::Upstream) + )); + } + #[tokio::test] async fn overlong_valid_http_playlist_child_returns_bad_gateway() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); From 64bc84e0b6e2ab3993b16b167fb8effb81aa2813 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:04:21 -0400 Subject: [PATCH 22/25] security: complete proxy destination loop defenses --- server/Cargo.toml | 2 +- server/src/diagnostics/logging.rs | 19 + server/src/lib.rs | 246 ++++++-- server/src/network_security/ip.rs | 267 ++++++-- server/src/network_security/mod.rs | 3 +- server/src/network_security/resolver.rs | 769 +++++++++++++++++++++--- server/src/network_security/runtime.rs | 112 ++++ server/src/routes/proxy.rs | 140 ++++- server/src/state.rs | 3 +- server/tests/proxy_security.rs | 311 +++++++++- 10 files changed, 1708 insertions(+), 164 deletions(-) diff --git a/server/Cargo.toml b/server/Cargo.toml index 2255e98..6156079 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -63,7 +63,7 @@ dirs = "6.0.0" dashmap = "6.2.1" tar = "0.4.46" flate2 = "1.1.9" -uuid = "1.24.1" +uuid = { version = "1.24.1", features = ["v4"] } quick-xml = { version = "0.41.0", features = ["serialize"] } yenc = "0.2.2" tokio-native-tls = "0.3.1" diff --git a/server/src/diagnostics/logging.rs b/server/src/diagnostics/logging.rs index cf7ca5b..7ee7ca7 100644 --- a/server/src/diagnostics/logging.rs +++ b/server/src/diagnostics/logging.rs @@ -54,6 +54,10 @@ fn format_headers(headers: &axum::http::HeaderMap) -> String { if !out.is_empty() { out.push_str(", "); } + if name.as_str() == crate::network_security::PROXY_HOP_HEADER_NAME { + out.push_str(""); + continue; + } out.push_str(name.as_str()); out.push('='); if is_sensitive(name) { @@ -416,6 +420,21 @@ mod tests { assert!(!rendered.contains("aaaaaaaa")); } + #[test] + fn internal_proxy_hop_header_is_replaced_by_a_fixed_placeholder() { + let mut headers = HeaderMap::new(); + headers.insert( + "x-stream-server-proxy-hop", + HeaderValue::from_static("secret-marker-value"), + ); + + let rendered = format_headers(&headers); + assert_eq!(rendered, ""); + assert!(!rendered.contains("x-stream-server-proxy-hop")); + assert!(!rendered.contains("secret-marker-value")); + assert!(!rendered.contains("19")); + } + #[test] fn proxy_targets_are_redacted_before_extractors_run() { let uri: Uri = "/proxy/d=http%3A%2F%2Fuser%3Asecret%40host/private?token=secret" diff --git a/server/src/lib.rs b/server/src/lib.rs index aeffcde..6173da7 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -152,6 +152,7 @@ pub enum ShutdownSource { pub struct ServerHandle { http_addr: SocketAddr, bound_http_addr: SocketAddr, + bound_https_addr: Option, shutdown_tx: tokio::sync::mpsc::Sender<()>, join: std::thread::JoinHandle>>, } @@ -165,6 +166,10 @@ impl ServerHandle { self.bound_http_addr } + pub fn bound_https_addr(&self) -> Option { + self.bound_https_addr + } + pub fn shutdown(&self) -> anyhow::Result<()> { match self.shutdown_tx.try_send(()) { Ok(()) | Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => Ok(()), @@ -190,11 +195,17 @@ pub fn start(cfg: ServerConfig) -> anyhow::Result { .name("stream-server".to_string()) .spawn(move || { let rt = tokio::runtime::Runtime::new()?; - rt.block_on(run(thread_cfg, shutdown_rx, Some(ready_tx))) + rt.block_on(run_inner( + thread_cfg, + shutdown_rx, + None, + None, + Some(ready_tx), + )) })?; - let bound_http_addr = match ready_rx.blocking_recv() { - Ok(addr) => addr, + let bindings = match ready_rx.blocking_recv() { + Ok(bindings) => bindings, Err(_) => { return match join.join() { Ok(result) => match result { @@ -209,8 +220,9 @@ pub fn start(cfg: ServerConfig) -> anyhow::Result { }; Ok(ServerHandle { - http_addr: connectable_addr(bound_http_addr), - bound_http_addr, + http_addr: connectable_addr(bindings.http), + bound_http_addr: bindings.http, + bound_https_addr: bindings.https, shutdown_tx, join, }) @@ -221,7 +233,7 @@ pub async fn run( external_shutdown_rx: tokio::sync::mpsc::Receiver<()>, ready_tx: Option>, ) -> anyhow::Result> { - run_inner(cfg, external_shutdown_rx, None, ready_tx).await + run_inner(cfg, external_shutdown_rx, None, ready_tx, None).await } pub async fn run_with_tray_stats( @@ -230,7 +242,30 @@ pub async fn run_with_tray_stats( tray_stats: Arc, ready_tx: Option>, ) -> anyhow::Result> { - run_inner(cfg, external_shutdown_rx, Some(tray_stats), ready_tx).await + run_inner(cfg, external_shutdown_rx, Some(tray_stats), ready_tx, None).await +} + +struct StartupBindings { + http: SocketAddr, + https: Option, +} + +struct PreparedHttpsListener { + bound_addr: SocketAddr, + server: axum_server::Server, +} + +async fn prepare_https_listener( + configured_addr: SocketAddr, + cert_path: &std::path::Path, + key_path: &std::path::Path, +) -> anyhow::Result { + let tls = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?; + let listener = std::net::TcpListener::bind(configured_addr)?; + listener.set_nonblocking(true)?; + let bound_addr = listener.local_addr()?; + let server = axum_server::from_tcp_rustls(listener, tls)?; + Ok(PreparedHttpsListener { bound_addr, server }) } async fn run_inner( @@ -238,6 +273,7 @@ async fn run_inner( mut external_shutdown_rx: tokio::sync::mpsc::Receiver<()>, tray_stats: Option>, ready_tx: Option>, + startup_ready_tx: Option>, ) -> anyhow::Result> { let listener = tokio::net::TcpListener::bind(cfg.http_addr) .await @@ -481,18 +517,35 @@ async fn run_inner( state.settings_persistence = settings_persistence; let https_cert_path = config_dir.join("https-cert.pem"); let https_key_path = config_dir.join("https-key.pem"); + let prepared_https = if https_cert_path.exists() && https_key_path.exists() { + match cfg.https_addr { + Some(https_addr) => { + match prepare_https_listener(https_addr, &https_cert_path, &https_key_path).await { + Ok(prepared) => Some(prepared), + Err(_) => { + tracing::error!("HTTPS server preparation failed"); + None + } + } + } + None => None, + } + } else { + if let Some(https_addr) = cfg.https_addr { + tracing::info!( + "No HTTPS certificates found in {:?}, skipping HTTPS server on {:?}", + config_dir, + https_addr + ); + } + None + }; + let bound_https_addr = prepared_https.as_ref().map(|prepared| prepared.bound_addr); let mut listeners = vec![network_security::ListenerBinding { - address: bound_http_addr.ip(), - port: bound_http_addr.port(), + socket: bound_http_addr, }]; - if https_cert_path.exists() - && https_key_path.exists() - && let Some(https_addr) = cfg.https_addr - { - listeners.push(network_security::ListenerBinding { - address: https_addr.ip(), - port: https_addr.port(), - }); + if let Some(https_addr) = bound_https_addr { + listeners.push(network_security::ListenerBinding { socket: https_addr }); } let validator = Arc::new(network_security::DestinationValidator::new( Arc::new(network_security::SystemDnsResolver), @@ -638,6 +691,12 @@ async fn run_inner( if let Some(ready_tx) = ready_tx { let _ = ready_tx.send(bound_http_addr); } + if let Some(startup_ready_tx) = startup_ready_tx { + let _ = startup_ready_tx.send(StartupBindings { + http: bound_http_addr, + https: bound_https_addr, + }); + } let (shutdown_started_tx, mut shutdown_started_rx) = tokio::sync::oneshot::channel::(); @@ -662,34 +721,25 @@ async fn run_inner( let _ = shutdown_started_tx.send(source); }; - if let Some(https_addr) = cfg.https_addr { - if https_cert_path.exists() && https_key_path.exists() { - tracing::info!("Found HTTPS certificates, starting HTTPS server on {https_addr}"); - let https_app = app.clone(); - let https_config = axum_server::tls_rustls::RustlsConfig::from_pem_file( - https_cert_path, - https_key_path, - ) - .await?; - - background_tasks.push(diagnostics::logging::spawn_logged( - "https-server", - async move { - if let Err(e) = axum_server::bind_rustls(https_addr, https_config) - .serve(https_app.into_make_service_with_connect_info::()) - .await - { - tracing::error!("HTTPS server error: {}", e); - } - }, - )); - } else { - tracing::info!( - "No HTTPS certificates found in {:?}, skipping HTTPS server on {:?}", - config_dir, - https_addr - ); - } + if let Some(prepared_https) = prepared_https { + tracing::info!( + "Found HTTPS certificates, starting HTTPS server on {}", + prepared_https.bound_addr + ); + let https_app = app.clone(); + background_tasks.push(diagnostics::logging::spawn_logged( + "https-server", + async move { + if prepared_https + .server + .serve(https_app.into_make_service_with_connect_info::()) + .await + .is_err() + { + tracing::error!("HTTPS server failed"); + } + }, + )); } let server = axum::serve( @@ -798,6 +848,22 @@ fn connectable_addr(addr: SocketAddr) -> SocketAddr { } } +async fn reject_proxy_hop_reentry( + State(runtime): State>, + request: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let path = request.uri().path(); + let is_proxy_path = path == "/proxy" || path.starts_with("/proxy/"); + if !is_proxy_path && runtime.matches_inbound_hop_marker(request.headers()) { + return routes::proxy::blocked_response(); + } + next.run(request).await +} + +/// Builds the legacy convenience router. Its proxy self-listener policy uses +/// the default `127.0.0.1:11470` binding installed by [`AppState`]. Embedders +/// serving on any other socket must use [`build_router_with_listeners`]. pub fn build_router(state: AppState) -> Router { fn peer_from_request(req: &axum::extract::Request) -> Option { req.extensions() @@ -957,10 +1023,47 @@ pub fn build_router(state: AppState) -> Router { ) }), ) + .layer(axum::middleware::from_fn_with_state( + state.proxy_runtime.clone(), + reject_proxy_hop_reentry, + )) .layer(CorsLayer::permissive()) .with_state(state) } +/// Builds a router whose proxy destination policy knows the complete sockets +/// on which the caller will serve it. Pass every actual bound `SocketAddr` +/// after binding, including IPv6 scope IDs; configured or requested addresses +/// are not a substitute for the sockets returned by the operating system. +pub fn build_router_with_listeners(mut state: AppState, listeners: Vec) -> Router { + assert!( + !listeners.is_empty(), + "listener-aware router requires at least one actual bound socket" + ); + let settings = state + .settings + .try_read() + .expect("settings must be uncontended during router construction") + .clone(); + let validator = Arc::new(network_security::DestinationValidator::new( + Arc::new(network_security::SystemDnsResolver), + Arc::new(network_security::SystemLocalNetworkProvider), + Arc::new(network_security::SystemClock), + listeners + .into_iter() + .map(|socket| network_security::ListenerBinding { socket }) + .collect(), + )); + state.proxy_runtime = Arc::new(network_security::ProxyRuntime::new( + network_security::ProxyPolicySettings { + allow_private_network_sources: settings.allow_private_network_sources, + allow_invalid_proxy_tls_certificates: settings.allow_invalid_proxy_tls_certificates, + }, + validator, + )); + build_router(state) +} + async fn root_redirect(State(state): State) -> Redirect { let encoded_url = urlencoding::encode(&state.base_url); Redirect::temporary(&format!( @@ -974,6 +1077,59 @@ mod tests { use super::*; use std::sync::atomic::{AtomicBool, Ordering}; + #[tokio::test] + async fn listener_aware_embedding_blocks_supplied_socket_while_legacy_keeps_default() { + let _engine_test_guard = TEST_ENGINE_MUTEX.lock().await; + let upstream_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let upstream_address = upstream_listener.local_addr().unwrap(); + let upstream = tokio::spawn(async move { + axum::serve( + upstream_listener, + Router::new().route("/ok", get(|| async { "fixture-ok" })), + ) + .await + .unwrap(); + }); + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let settings = routes::system::ServerSettings { + allow_private_network_sources: true, + ..routes::system::ServerSettings::default() + }; + let state = AppState::new(engine, settings, temp.path().join("config")); + let target = format!("http://{upstream_address}/ok"); + + for (router, expected) in [ + ( + build_router_with_listeners(state.clone(), vec![upstream_address]), + StatusCode::FORBIDDEN, + ), + (build_router(state), StatusCode::OK), + ] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + let response = reqwest::get(format!( + "http://{address}/proxy/?d={}", + urlencoding::encode(&target) + )) + .await + .unwrap(); + assert_eq!(response.status(), expected); + if expected == StatusCode::OK { + assert_eq!(response.text().await.unwrap(), "fixture-ok"); + } + server.abort(); + } + upstream.abort(); + } + #[tokio::test] async fn forced_exit_waits_for_admitted_settings_transactions_to_drain() { let coordinator = Arc::new(state::SettingsPersistenceCoordinator::new( diff --git a/server/src/network_security/ip.rs b/server/src/network_security/ip.rs index f9e57b2..0ddf085 100644 --- a/server/src/network_security/ip.rs +++ b/server/src/network_security/ip.rs @@ -13,16 +13,41 @@ pub(crate) enum DestinationClass { #[derive(Clone, Debug, Default, Eq, PartialEq)] pub(crate) struct LocalNetworks { - pub(crate) interfaces: Vec, + pub(crate) interfaces: Vec, +} + +#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) struct LocalNetworkEntry { + pub(crate) network: IpNet, + pub(crate) name: String, + pub(crate) index: Option, + pub(crate) adapter_id: Option, +} + +impl std::str::FromStr for LocalNetworkEntry { + type Err = ipnet::AddrParseError; + + fn from_str(value: &str) -> Result { + Ok(Self { + network: value.parse()?, + name: String::new(), + index: None, + adapter_id: None, + }) + } } impl LocalNetworks { pub(crate) fn contains(&self, ip: IpAddr) -> bool { - self.interfaces.iter().any(|network| network.contains(&ip)) + self.interfaces + .iter() + .any(|interface| interface.network.contains(&ip)) } pub(crate) fn contains_address(&self, ip: IpAddr) -> bool { - self.interfaces.iter().any(|network| network.addr() == ip) + self.interfaces + .iter() + .any(|interface| interface.network.addr() == ip) } } @@ -32,6 +57,11 @@ pub(crate) struct Nat64Prefix { pub(crate) length: u8, } +const WELL_KNOWN_NAT64: Nat64Prefix = Nat64Prefix { + network: Ipv6Addr::new(0x64, 0xff9b, 0, 0, 0, 0, 0, 0), + length: 96, +}; + // IANA registry snapshot: 2026-08-19 // https://www.iana.org/assignments/iana-ipv4-special-registry // https://www.iana.org/assignments/iana-ipv6-special-registry @@ -64,6 +94,7 @@ const METADATA_V4: &[&str] = &[ "169.254.0.23/32", "169.254.10.10/32", "169.254.42.42/32", + "169.254.169.253/32", "169.254.169.254/32", "169.254.170.2/32", "169.254.170.23/32", @@ -180,54 +211,96 @@ pub(crate) fn extract_rfc6052(ip: Ipv6Addr, prefix: Nat64Prefix) -> Option [bytes[12], bytes[13], bytes[14], bytes[15]], _ => return None, }; - if prefix.length != 96 && bytes[8] != 0 { + if bytes[8] != 0 { return None; } Some(embedded.into()) } -fn embedded_nat64(ip: Ipv6Addr, prefixes: &[Nat64Prefix]) -> Option { - const WELL_KNOWN: Nat64Prefix = Nat64Prefix { - network: Ipv6Addr::new(0x64, 0xff9b, 0, 0, 0, 0, 0, 0), - length: 96, - }; - const LOCAL_USE: Nat64Prefix = Nat64Prefix { - network: Ipv6Addr::new(0x64, 0xff9b, 1, 0, 0, 0, 0, 0), - length: 48, +pub(crate) fn embedded_ipv4_candidates(ip: Ipv6Addr, prefixes: &[Nat64Prefix]) -> Vec { + let mut candidates = Vec::new(); + if let Some(mapped) = ip.to_ipv4_mapped() { + candidates.push(mapped); + } + if let Some(embedded) = extract_rfc6052(ip, WELL_KNOWN_NAT64) { + candidates.push(embedded); + } + candidates.extend( + prefixes + .iter() + .filter(|prefix| nat64_prefix_address_space_is_valid(**prefix)) + .filter_map(|prefix| extract_rfc6052(ip, *prefix)), + ); + candidates.sort_unstable(); + candidates.dedup(); + candidates +} + +fn nat64_prefix_address_space_is_valid(prefix: Nat64Prefix) -> bool { + nat64_prefix_is_usable(prefix) +} + +pub(crate) fn nat64_prefix_is_usable(prefix: Nat64Prefix) -> bool { + const VALID_LENGTHS: &[u8] = &[32, 40, 48, 56, 64, 96]; + if !VALID_LENGTHS.contains(&prefix.length) || prefix.network.octets()[8] != 0 { + return false; + } + let Ok(candidate) = ipnet::Ipv6Net::new(prefix.network, prefix.length) else { + return false; }; + if candidate.network() != prefix.network { + return false; + } - extract_rfc6052(ip, WELL_KNOWN) - // RFC 8215 reserves a /48. Existing Stremio clients commonly encode the - // IPv4 value in the low 32 bits, so inspect that form as well as RFC 6052. - .or_else(|| { - let value = u128::from(ip); - let prefix = u128::from(LOCAL_USE.network); - let mask = u128::MAX << 80; - if value & mask != prefix & mask { - return None; - } - let rfc6052 = extract_rfc6052(ip, LOCAL_USE)?; - if rfc6052 == Ipv4Addr::UNSPECIFIED { - Some(Ipv4Addr::from(value as u32)) - } else { - Some(rfc6052) + let overlaps_blocked = ALWAYS_BLOCKED_V6_NETS + .iter() + .chain(METADATA_V6_NETS.iter()) + .any(|blocked| match blocked { + IpNet::V6(blocked) => { + candidate.contains(&blocked.network()) || blocked.contains(&candidate.network()) } - }) - .or_else(|| { - prefixes - .iter() - .filter(|prefix| nat64_prefix_address_space_is_valid(**prefix)) - .find_map(|prefix| extract_rfc6052(ip, *prefix)) - }) -} + IpNet::V4(_) => false, + }); + if overlaps_blocked { + return false; + } -fn nat64_prefix_address_space_is_valid(prefix: Nat64Prefix) -> bool { + if prefix == WELL_KNOWN_NAT64 { + return true; + } + + let rfc8215: ipnet::Ipv6Net = "64:ff9b:1::/48" + .parse() + .expect("RFC 8215 reservation is valid"); + let is_valid_rfc8215_subprefix = + matches!(prefix.length, 56 | 64 | 96) && rfc8215.contains(&candidate.network()); let ip = IpAddr::V6(prefix.network); - GLOBAL_UNICAST_V6_NET.contains(&ip) || prefix.network.segments()[0] & 0xfe00 == 0xfc00 + GLOBAL_UNICAST_V6_NET.contains(&ip) + || prefix.network.segments()[0] & 0xfe00 == 0xfc00 + || is_valid_rfc8215_subprefix +} + +pub(crate) fn is_rfc8215_address(ip: Ipv6Addr) -> bool { + let reservation: ipnet::Ipv6Net = "64:ff9b:1::/48" + .parse() + .expect("RFC 8215 reservation is valid"); + reservation.contains(&ip) } pub(crate) fn normalized_embedded_ipv4(ip: Ipv6Addr, nat64: &[Nat64Prefix]) -> Option { - ip.to_ipv4_mapped().or_else(|| embedded_nat64(ip, nat64)) + embedded_ipv4_candidates(ip, nat64).into_iter().next() +} + +fn combine_classes(left: DestinationClass, right: DestinationClass) -> DestinationClass { + match (left, right) { + (DestinationClass::AlwaysBlocked, _) | (_, DestinationClass::AlwaysBlocked) => { + DestinationClass::AlwaysBlocked + } + (DestinationClass::PrivateSource, _) | (_, DestinationClass::PrivateSource) => { + DestinationClass::PrivateSource + } + (DestinationClass::Public, DestinationClass::Public) => DestinationClass::Public, + } } fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> DestinationClass { @@ -245,8 +318,16 @@ fn classify_v6(ip: Ipv6Addr, local: &LocalNetworks, nat64: &[Nat64Prefix]) -> De if contains(&METADATA_V6_NETS, native) || contains(&ALWAYS_BLOCKED_V6_NETS, native) { return DestinationClass::AlwaysBlocked; } - if let Some(embedded) = embedded_nat64(ip, nat64) { - return classify_v4(embedded, local); + let embedded = embedded_ipv4_candidates(ip, nat64); + if !embedded.is_empty() { + let mut class = embedded + .into_iter() + .map(|embedded| classify_v4(embedded, local)) + .fold(DestinationClass::Public, combine_classes); + if local.contains_address(native) { + class = combine_classes(class, DestinationClass::PrivateSource); + } + return class; } if local.contains(native) || contains(&PRIVATE_V6_NETS, native) { @@ -272,6 +353,7 @@ pub(crate) fn classify_ip( #[cfg(test)] mod tests { use super::{DestinationClass, LocalNetworks, Nat64Prefix, classify_ip}; + use std::net::{IpAddr, Ipv6Addr}; #[test] fn ipv4_special_purpose_ranges_are_not_public() { @@ -324,6 +406,7 @@ mod tests { "169.254.0.23", "169.254.10.10", "169.254.42.42", + "169.254.169.253", "169.254.169.254", "169.254.170.2", "169.254.170.23", @@ -508,8 +591,6 @@ mod tests { for (value, prefixes) in [ ("::ffff:93.184.216.34", &[][..]), ("64:ff9b::5db8:d822", &[][..]), - ("64:ff9b:1::5db8:d822", &[][..]), - ("64:ff9b:1:5db8:d8:2200::", &[][..]), ("2001:4860:64::5db8:d822", &discovered[..]), ] { assert_eq!( @@ -537,6 +618,108 @@ mod tests { } } + #[test] + fn ibm_metadata_is_blocked_through_standard_and_discovered_nat64() { + let local = LocalNetworks::default(); + let discovered = [Nat64Prefix { + network: "2001:4860:64::".parse().unwrap(), + length: 96, + }]; + + for (value, prefixes) in [ + ("64:ff9b::a9fe:a9fd", &[][..]), + ("2001:4860:64::a9fe:a9fd", &discovered[..]), + ] { + assert_eq!( + classify_ip(value.parse().unwrap(), &local, prefixes), + DestinationClass::AlwaysBlocked, + "{value}" + ); + } + } + + #[test] + fn overlapping_nat64_interpretations_use_explicit_security_precedence() { + let local = LocalNetworks::default(); + let metadata_address: Ipv6Addr = "2001:4860:64:1:8:808:a9fe:a9fd".parse().unwrap(); + let metadata_prefixes = [ + Nat64Prefix { + network: "2001:4860:64:1::".parse().unwrap(), + length: 64, + }, + Nat64Prefix { + network: "2001:4860:64:1:8:808::".parse().unwrap(), + length: 96, + }, + ]; + let private_address: Ipv6Addr = "2001:4860:64:1:8:808:a00:1".parse().unwrap(); + let private_prefixes = [ + Nat64Prefix { + network: "2001:4860:64:1::".parse().unwrap(), + length: 64, + }, + Nat64Prefix { + network: "2001:4860:64:1:8:808::".parse().unwrap(), + length: 96, + }, + ]; + + for prefixes in [ + metadata_prefixes, + [metadata_prefixes[1], metadata_prefixes[0]], + ] { + assert_eq!( + classify_ip(IpAddr::V6(metadata_address), &local, &prefixes), + DestinationClass::AlwaysBlocked + ); + } + for prefixes in [private_prefixes, [private_prefixes[1], private_prefixes[0]]] { + assert_eq!( + classify_ip(IpAddr::V6(private_address), &local, &prefixes), + DestinationClass::PrivateSource + ); + } + } + + #[test] + fn rfc8215_space_has_no_discovery_free_embedding_rule() { + let local = LocalNetworks::default(); + assert_eq!( + classify_ip( + "64:ff9b:1:808:8:800:a9fe:a9fd".parse().unwrap(), + &local, + &[], + ), + DestinationClass::AlwaysBlocked + ); + } + + #[test] + fn exact_native_interface_address_stays_private_over_public_embedding() { + let address: Ipv6Addr = "2001:4860:64::5db8:d822".parse().unwrap(); + let local = LocalNetworks { + interfaces: vec!["2001:4860:64::5db8:d822/96".parse().unwrap()], + }; + let discovered = [Nat64Prefix { + network: "2001:4860:64::".parse().unwrap(), + length: 96, + }]; + + assert_eq!( + classify_ip(IpAddr::V6(address), &local, &discovered), + DestinationClass::PrivateSource + ); + assert_eq!( + classify_ip( + "2001:4860:64::5db8:d823".parse().unwrap(), + &local, + &discovered, + ), + DestinationClass::Public, + "mere membership in the connected translator subnet is not local endpoint identity" + ); + } + #[test] fn directly_connected_public_prefixes_are_private_sources() { let local = LocalNetworks { diff --git a/server/src/network_security/mod.rs b/server/src/network_security/mod.rs index 027c8c4..f6a0ff8 100644 --- a/server/src/network_security/mod.rs +++ b/server/src/network_security/mod.rs @@ -7,7 +7,8 @@ pub(crate) use resolver::{ SystemLocalNetworkProvider, }; pub(crate) use runtime::{ - ProxyPlaylistPermit, ProxyPolicySettings, ProxyProducerLease, ProxyRequestContext, ProxyRuntime, + PROXY_HOP_HEADER_NAME, ProxyPlaylistPermit, ProxyPolicySettings, ProxyProducerLease, + ProxyRequestContext, ProxyRuntime, }; #[cfg(test)] diff --git a/server/src/network_security/resolver.rs b/server/src/network_security/resolver.rs index bdc1743..e794343 100644 --- a/server/src/network_security/resolver.rs +++ b/server/src/network_security/resolver.rs @@ -1,8 +1,8 @@ -use super::ip::{DestinationClass, LocalNetworks, Nat64Prefix, extract_rfc6052}; +use super::ip::{DestinationClass, LocalNetworkEntry, LocalNetworks, Nat64Prefix, extract_rfc6052}; use async_trait::async_trait; use std::{ io, - net::{IpAddr, SocketAddr}, + net::{IpAddr, SocketAddr, SocketAddrV6}, sync::Arc, time::{Duration, Instant}, }; @@ -76,7 +76,7 @@ impl LocalNetworkProvider for SystemLocalNetworkProvider { } } -fn network_for_interface(interface: &if_addrs::Interface) -> Option { +fn network_for_interface(interface: &if_addrs::Interface) -> Option { if matches!( interface.oper_status, if_addrs::IfOperStatus::Down @@ -90,7 +90,23 @@ fn network_for_interface(interface: &if_addrs::Interface) -> Option (IpAddr::V4(address.ip), address.prefixlen), if_addrs::IfAddr::V6(address) => (IpAddr::V6(address.ip), address.prefixlen), }; - ipnet::IpNet::new(ip, prefix).ok() + let network = ipnet::IpNet::new(ip, prefix).ok()?; + Some(LocalNetworkEntry { + network, + name: interface.name.clone(), + index: interface.index, + adapter_id: adapter_id(interface), + }) +} + +#[cfg(windows)] +fn adapter_id(interface: &if_addrs::Interface) -> Option { + Some(interface.adapter_name.clone()) +} + +#[cfg(not(windows))] +fn adapter_id(_interface: &if_addrs::Interface) -> Option { + None } #[derive(Clone, Debug)] @@ -102,8 +118,7 @@ pub(crate) struct ResolvedDestination { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct ListenerBinding { - pub(crate) address: IpAddr, - pub(crate) port: u16, + pub(crate) socket: SocketAddr, } #[derive(thiserror::Error, Debug, Eq, PartialEq)] @@ -147,7 +162,12 @@ impl DestinationValidator { resolver, local_networks, clock, - listeners, + listeners: listeners + .into_iter() + .map(|listener| ListenerBinding { + socket: normalize_socket(listener.socket), + }) + .collect(), nat64_cache: tokio::sync::Mutex::new(None), nat64_refresh: tokio::sync::Mutex::new(()), } @@ -198,6 +218,7 @@ impl DestinationValidator { } for address in &mut resolved { address.set_port(port); + *address = normalize_socket(*address); } resolved.sort_unstable(); resolved.dedup(); @@ -233,6 +254,12 @@ impl DestinationValidator { policy: OutboundPolicy, ) -> Result<(), DestinationError> { for address in addresses { + if let SocketAddr::V6(address) = address + && address.ip().is_unicast_link_local() + && address.scope_id() == 0 + { + return Err(DestinationError::Blocked); + } if self.matches_listener(*address, local, nat64) { return Err(DestinationError::Blocked); } @@ -253,31 +280,35 @@ impl DestinationValidator { local: &LocalNetworks, nat64: &[Nat64Prefix], ) -> bool { - let target_ip = normalized_listener_ip(target.ip(), nat64); + let target = normalize_socket(target); + let target_candidates = listener_socket_candidates(target, nat64); self.listeners.iter().any(|listener| { - if listener.port != target.port() { + if listener.socket.port() != target.port() { return false; } - let listener_ip = normalized_listener_ip(listener.address, nat64); - if listener.address.is_unspecified() { - return match (listener.address, target_ip) { - (IpAddr::V4(_), IpAddr::V4(ip)) => { - ip.is_loopback() || local.contains_address(IpAddr::V4(ip)) - } - (IpAddr::V6(_), IpAddr::V6(ip)) => { - ip.is_loopback() || local.contains_address(IpAddr::V6(ip)) - } - // An unspecified IPv6 socket can be dual-stack. Treat mapped - // IPv4 addresses on local interfaces as self-listener targets. - (IpAddr::V6(_), IpAddr::V4(ip)) => { - ip.is_loopback() || local.contains_address(IpAddr::V4(ip)) + if listener.socket.ip().is_unspecified() { + return target_candidates.iter().any(|candidate| { + let candidate_ip = candidate.ip(); + match listener.socket { + SocketAddr::V4(_) => { + candidate.is_ipv4() + && (candidate_ip.is_loopback() + || local.contains_address(candidate_ip)) + } + SocketAddr::V6(_) => { + candidate_ip.is_loopback() || local.contains_address(candidate_ip) + } } - _ => false, - }; + }); } - listener_ip == target_ip + let listener_candidates = listener_socket_candidates(listener.socket, nat64); + listener_candidates.iter().any(|listener_candidate| { + target_candidates.iter().any(|target_candidate| { + listener_endpoint_matches(*listener_candidate, *target_candidate) + }) + }) }) } @@ -293,6 +324,9 @@ impl DestinationValidator { if super::ip::normalized_embedded_ipv4(ip, &[]).is_some() { return false; } + if super::ip::is_rfc8215_address(ip) { + return true; + } match super::ip::classify_ip(IpAddr::V6(ip), local, &[]) { DestinationClass::Public => true, DestinationClass::PrivateSource => { @@ -317,16 +351,8 @@ impl DestinationValidator { return prefixes; } - let discovered = match self.resolver.resolve("ipv4only.arpa", 0).await { - Ok(answers) if !answers.is_empty() && answers.len() <= MAX_DNS_ANSWERS => { - let has_ipv6 = answers.iter().any(SocketAddr::is_ipv6); - let prefixes = discover_nat64_prefixes(&answers); - if has_ipv6 && prefixes.is_empty() { - Err(DestinationError::ResolutionFailed) - } else { - Ok(prefixes) - } - } + let discovered = match self.resolver.resolve("ipv4only.arpa.", 0).await { + Ok(answers) if answers.len() <= MAX_DNS_ANSWERS => discover_nat64_prefixes(&answers), Ok(_) | Err(_) => Err(DestinationError::ResolutionFailed), }; @@ -364,29 +390,82 @@ impl DestinationValidator { } } -fn normalized_listener_ip(ip: IpAddr, nat64: &[Nat64Prefix]) -> IpAddr { - match ip { - IpAddr::V6(ip) => super::ip::normalized_embedded_ipv4(ip, nat64) - .map(IpAddr::V4) - .unwrap_or(IpAddr::V6(ip)), - ip => ip, +fn normalize_socket(address: SocketAddr) -> SocketAddr { + match address { + SocketAddr::V4(_) => address, + SocketAddr::V6(address) => SocketAddr::V6(SocketAddrV6::new( + *address.ip(), + address.port(), + 0, + if address.ip().is_unicast_link_local() { + address.scope_id() + } else { + 0 + }, + )), + } +} + +fn listener_socket_candidates(socket: SocketAddr, nat64: &[Nat64Prefix]) -> Vec { + let socket = normalize_socket(socket); + let mut candidates = vec![socket]; + if let SocketAddr::V6(address) = socket { + candidates.extend( + super::ip::embedded_ipv4_candidates(*address.ip(), nat64) + .into_iter() + .map(|ip| SocketAddr::new(IpAddr::V4(ip), address.port())), + ); + } + candidates.sort_unstable(); + candidates.dedup(); + candidates +} + +fn listener_endpoint_matches(listener: SocketAddr, target: SocketAddr) -> bool { + if listener.port() != target.port() { + return false; + } + match (listener, target) { + (SocketAddr::V4(listener), SocketAddr::V4(target)) => listener.ip() == target.ip(), + (SocketAddr::V6(listener), SocketAddr::V6(target)) => { + listener.ip() == target.ip() + && (!listener.ip().is_unicast_link_local() + || listener.scope_id() == 0 + || listener.scope_id() == target.scope_id()) + } + _ => false, } } -fn discover_nat64_prefixes(answers: &[SocketAddr]) -> Vec { +fn discover_nat64_prefixes(answers: &[SocketAddr]) -> Result, DestinationError> { const IPV4ONLY: [std::net::Ipv4Addr; 2] = [ std::net::Ipv4Addr::new(192, 0, 0, 170), std::net::Ipv4Addr::new(192, 0, 0, 171), ]; const LENGTHS: [u8; 6] = [32, 40, 48, 56, 64, 96]; - let ipv6_answers: Vec<_> = answers - .iter() - .filter_map(|answer| match answer.ip() { - IpAddr::V6(ip) => Some(ip), - IpAddr::V4(_) => None, - }) - .collect(); + if answers.is_empty() { + return Err(DestinationError::ResolutionFailed); + } + let mut seen_ipv4 = [false; 2]; + let mut ipv6_answers = Vec::new(); + for answer in answers { + match answer.ip() { + IpAddr::V4(ip) if ip == IPV4ONLY[0] => seen_ipv4[0] = true, + IpAddr::V4(ip) if ip == IPV4ONLY[1] => seen_ipv4[1] = true, + IpAddr::V4(_) => return Err(DestinationError::ResolutionFailed), + IpAddr::V6(ip) => ipv6_answers.push(ip), + } + } + if seen_ipv4 != [false, false] && seen_ipv4 != [true, true] { + return Err(DestinationError::ResolutionFailed); + } + if ipv6_answers.is_empty() { + return (seen_ipv4 == [true, true]) + .then(Vec::new) + .ok_or(DestinationError::ResolutionFailed); + } + let mut prefixes = Vec::new(); for length in LENGTHS { let mask = u128::MAX << (128 - u32::from(length)); @@ -395,6 +474,9 @@ fn discover_nat64_prefixes(answers: &[SocketAddr]) -> Vec { network: std::net::Ipv6Addr::from(u128::from(*address) & mask), length, }; + if !super::ip::nat64_prefix_is_usable(prefix) { + continue; + } let mut seen = [false; 2]; for candidate in &ipv6_answers { if let Some(extracted) = extract_rfc6052(*candidate, prefix) { @@ -411,20 +493,33 @@ fn discover_nat64_prefixes(answers: &[SocketAddr]) -> Vec { } } prefixes.sort_unstable_by_key(|prefix| (prefix.length, u128::from(prefix.network))); - prefixes + let all_ipv6_answers_accounted_for = ipv6_answers.iter().all(|answer| { + prefixes.iter().any(|prefix| { + extract_rfc6052(*answer, *prefix).is_some_and(|embedded| IPV4ONLY.contains(&embedded)) + }) + }); + if prefixes.is_empty() || !all_ipv6_answers_accounted_for { + return Err(DestinationError::ResolutionFailed); + } + + prefixes.retain(|prefix| { + !(prefix.network == std::net::Ipv6Addr::new(0x64, 0xff9b, 0, 0, 0, 0, 0, 0) + && prefix.length == 96) + }); + Ok(prefixes) } #[cfg(test)] mod tests { - use super::super::ip::LocalNetworks; + use super::super::ip::{LocalNetworkEntry, LocalNetworks, Nat64Prefix}; use super::{ Clock, DestinationError, DestinationValidator, DnsResolver, ListenerBinding, - LocalNetworkProvider, OutboundPolicy, network_for_interface, + LocalNetworkProvider, OutboundPolicy, discover_nat64_prefixes, network_for_interface, }; use async_trait::async_trait; use std::{ io, - net::SocketAddr, + net::{Ipv6Addr, SocketAddr, SocketAddrV6}, sync::{ Arc, Mutex, atomic::{AtomicUsize, Ordering}, @@ -448,6 +543,19 @@ mod tests { struct SlowThenStalledResolver; + struct RecordingResolver { + answer: Vec, + hosts: Mutex>, + } + + #[async_trait] + impl DnsResolver for RecordingResolver { + async fn resolve(&self, host: &str, _port: u16) -> io::Result> { + self.hosts.lock().unwrap().push(host.to_owned()); + Ok(self.answer.clone()) + } + } + #[async_trait] impl DnsResolver for SlowThenStalledResolver { async fn resolve(&self, host: &str, _port: u16) -> io::Result> { @@ -679,6 +787,33 @@ mod tests { ); } + #[tokio::test] + async fn ibm_metadata_literal_and_dns_answer_remain_blocked_with_private_opt_in() { + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + let literal_resolver = FakeResolver::new(Vec::new()); + let literal = validator(literal_resolver.clone()); + assert_eq!( + literal + .validate(&Url::parse("http://169.254.169.253/").unwrap(), policy) + .await + .unwrap_err(), + DestinationError::Blocked + ); + assert_eq!(literal_resolver.calls.load(Ordering::SeqCst), 0); + + let dns = validator(FakeResolver::new(vec![ + "169.254.169.253:80".parse().unwrap(), + ])); + assert_eq!( + dns.validate(&Url::parse("http://metadata.example/").unwrap(), policy) + .await + .unwrap_err(), + DestinationError::Blocked + ); + } + #[tokio::test] async fn empty_failed_and_oversized_dns_answers_fail_closed() { for resolver in [ @@ -718,6 +853,105 @@ mod tests { assert_eq!(result.addrs, vec!["93.184.216.34:8080".parse().unwrap()]); } + #[tokio::test] + async fn scoped_link_local_answers_require_opt_in_and_preserve_normalized_scope() { + let ip: Ipv6Addr = "fe80::1234".parse().unwrap(); + let resolver = FakeResolver::new(vec![ + SocketAddr::V6(SocketAddrV6::new(ip, 1234, 7, 2)), + SocketAddr::V6(SocketAddrV6::new(ip, 4321, 11, 2)), + ]); + let validator = validator(resolver); + let target = Url::parse("http://link-local.example:8080/").unwrap(); + + assert_eq!( + validator + .validate(&target, OutboundPolicy::default()) + .await + .unwrap_err(), + DestinationError::Blocked + ); + let resolved = validator + .validate( + &target, + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap(); + assert_eq!( + resolved.addrs, + vec![SocketAddr::V6(SocketAddrV6::new(ip, 8080, 0, 2))] + ); + } + + #[tokio::test] + async fn link_local_answers_with_zero_scope_are_always_blocked() { + let validator = validator(FakeResolver::new(vec!["[fe80::1234]:80".parse().unwrap()])); + + assert_eq!( + validator + .validate( + &Url::parse("http://link-local.example/").unwrap(), + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + } + + #[tokio::test] + async fn link_local_url_literals_are_rejected_without_dns() { + let resolver = FakeResolver::new(Vec::new()); + let validator = validator(resolver.clone()); + + assert_eq!( + validator + .validate( + &Url::parse("http://[fe80::1234]/").unwrap(), + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); + for target in ["http://[fe80::1234%2]/", "http://[fe80::1234%252]/"] { + assert!(Url::parse(target).is_err(), "{target}"); + } + } + + #[tokio::test] + async fn link_local_answers_on_different_nonzero_scopes_remain_distinct() { + let ip: Ipv6Addr = "fe80::1234".parse().unwrap(); + let validator = validator(FakeResolver::new(vec![ + SocketAddr::V6(SocketAddrV6::new(ip, 80, 0, 3)), + SocketAddr::V6(SocketAddrV6::new(ip, 80, 0, 2)), + ])); + + let resolved = validator + .validate( + &Url::parse("http://link-local.example/").unwrap(), + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap(); + assert_eq!( + resolved.addrs, + vec![ + SocketAddr::V6(SocketAddrV6::new(ip, 80, 0, 2)), + SocketAddr::V6(SocketAddrV6::new(ip, 80, 0, 3)), + ] + ); + } + #[tokio::test] async fn exact_and_wildcard_self_listeners_are_always_blocked() { let local = LocalNetworks { @@ -731,8 +965,7 @@ mod tests { FakeResolver::new(vec!["93.184.216.34:11470".parse().unwrap()]), LocalNetworks::default(), vec![ListenerBinding { - address: "93.184.216.34".parse().unwrap(), - port: 11470, + socket: "93.184.216.34:11470".parse().unwrap(), }], ); assert_eq!( @@ -747,8 +980,7 @@ mod tests { FakeResolver::new(vec!["8.8.8.8:11470".parse().unwrap()]), local, vec![ListenerBinding { - address: "0.0.0.0".parse().unwrap(), - port: 11470, + socket: "0.0.0.0:11470".parse().unwrap(), }], ); assert_eq!( @@ -769,8 +1001,7 @@ mod tests { interfaces: vec!["8.8.8.9/29".parse().unwrap()], }; let listeners = vec![ListenerBinding { - address: "0.0.0.0".parse().unwrap(), - port: 11470, + socket: "0.0.0.0:11470".parse().unwrap(), }]; let policy = OutboundPolicy { allow_private_network_sources: true, @@ -813,8 +1044,7 @@ mod tests { interfaces: vec!["8.8.8.8/29".parse().unwrap()], }, vec![ListenerBinding { - address: "::".parse().unwrap(), - port: 11470, + socket: "[::]:11470".parse().unwrap(), }], ); assert_eq!( @@ -837,8 +1067,7 @@ mod tests { FakeResolver::new(vec!["93.184.216.34:8080".parse().unwrap()]), LocalNetworks::default(), vec![ListenerBinding { - address: "93.184.216.34".parse().unwrap(), - port: 11470, + socket: "93.184.216.34:11470".parse().unwrap(), }], ); assert!( @@ -858,8 +1087,7 @@ mod tests { FakeResolver::new(Vec::new()), LocalNetworks::default(), vec![ListenerBinding { - address: "93.184.216.34".parse().unwrap(), - port: 80, + socket: "93.184.216.34:80".parse().unwrap(), }], ); for target in [ @@ -877,6 +1105,136 @@ mod tests { } } + #[tokio::test] + async fn exact_link_local_listener_matches_only_its_nonzero_scope() { + let ip: Ipv6Addr = "fe80::1234".parse().unwrap(); + let local = LocalNetworks { + interfaces: vec!["fe80::1234/64".parse().unwrap()], + }; + let listeners = vec![ListenerBinding { + socket: SocketAddr::V6(SocketAddrV6::new(ip, 11470, 77, 2)), + }]; + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + + let same_scope = validator_with_listeners( + FakeResolver::new(vec![SocketAddr::V6(SocketAddrV6::new(ip, 11470, 42, 2))]), + local.clone(), + listeners.clone(), + ); + assert_eq!( + same_scope + .validate( + &Url::parse("http://same-scope.example:11470/").unwrap(), + policy + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + + let different_scope = validator_with_listeners( + FakeResolver::new(vec![SocketAddr::V6(SocketAddrV6::new(ip, 11470, 11, 3))]), + local, + listeners, + ); + assert!( + different_scope + .validate( + &Url::parse("http://different-scope.example:11470/").unwrap(), + policy, + ) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn zero_scope_and_wildcard_ipv6_listeners_block_scoped_local_targets() { + let ip: Ipv6Addr = "fe80::1234".parse().unwrap(); + let local = LocalNetworks { + interfaces: vec!["fe80::1234/64".parse().unwrap()], + }; + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + + for socket in [ + SocketAddr::V6(SocketAddrV6::new(ip, 11470, 9, 0)), + "[::]:11470".parse().unwrap(), + ] { + for scope in [2, 3] { + let validator = validator_with_listeners( + FakeResolver::new(vec![SocketAddr::V6(SocketAddrV6::new(ip, 11470, 0, scope))]), + local.clone(), + vec![ListenerBinding { socket }], + ); + assert_eq!( + validator + .validate( + &Url::parse("http://scoped-local.example:11470/").unwrap(), + policy, + ) + .await + .unwrap_err(), + DestinationError::Blocked, + "listener={socket}, scope={scope}" + ); + } + } + } + + #[tokio::test] + async fn non_link_local_listener_scope_and_ipv6_flowinfo_are_not_endpoint_identity() { + let ip: Ipv6Addr = "64:ff9b::5db8:d822".parse().unwrap(); + let validator = validator_with_listeners( + FakeResolver::new(vec![SocketAddr::V6(SocketAddrV6::new(ip, 11470, 3, 8))]), + LocalNetworks::default(), + vec![ListenerBinding { + socket: SocketAddr::V6(SocketAddrV6::new(ip, 11470, 99, 4)), + }], + ); + assert_eq!( + validator + .validate( + &Url::parse("http://same-native-ip.example:11470/").unwrap(), + OutboundPolicy::default(), + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + } + + #[tokio::test] + async fn ipv6_wildcard_keeps_native_identity_when_nat64_also_decodes_target() { + let validator = validator_with_listeners( + FakeResolver::new(vec![ + "[2001:4860:64::c000:aa]:0".parse().unwrap(), + "[2001:4860:64::c000:ab]:0".parse().unwrap(), + ]), + LocalNetworks { + interfaces: vec!["2001:4860:64::5db8:d822/128".parse().unwrap()], + }, + vec![ListenerBinding { + socket: "[::]:80".parse().unwrap(), + }], + ); + assert_eq!( + validator + .validate( + &Url::parse("http://[2001:4860:64::5db8:d822]/").unwrap(), + OutboundPolicy { + allow_private_network_sources: true, + }, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + } + #[tokio::test] async fn discovered_nat64_prefix_exposes_embedded_metadata_and_is_cached() { let resolver = FakeResolver::new(vec![ @@ -898,6 +1256,236 @@ mod tests { assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn pref64_discovery_uses_the_absolute_ipv4only_name() { + let resolver = Arc::new(RecordingResolver { + answer: vec![ + "192.0.0.170:0".parse().unwrap(), + "192.0.0.171:0".parse().unwrap(), + ], + hosts: Mutex::new(Vec::new()), + }); + let validator = DestinationValidator::new( + resolver.clone(), + Arc::new(StaticLocalNetworks(LocalNetworks::default())), + Arc::new(FixedClock(Instant::now())), + Vec::new(), + ); + + assert!( + validator + .validate( + &Url::parse("http://[2001:4860:4860::8888]/").unwrap(), + OutboundPolicy::default(), + ) + .await + .is_ok() + ); + assert_eq!( + *resolver.hosts.lock().unwrap(), + vec!["ipv4only.arpa.".to_owned()] + ); + } + + #[test] + fn strict_pref64_discovery_accepts_complete_public_ula_and_reserved_pairs() { + let cases = [ + ( + vec!["192.0.0.170:0", "192.0.0.171:0"], + Vec::::new(), + ), + ( + vec!["[64:ff9b::c000:aa]:0", "[64:ff9b::c000:ab]:0"], + Vec::new(), + ), + ( + vec!["[2001:4860:64::c000:aa]:0", "[2001:4860:64::c000:ab]:0"], + vec![Nat64Prefix { + network: "2001:4860:64::".parse().unwrap(), + length: 96, + }], + ), + ( + vec!["[fd12:3456:789a::c000:aa]:0", "[fd12:3456:789a::c000:ab]:0"], + vec![Nat64Prefix { + network: "fd12:3456:789a::".parse().unwrap(), + length: 96, + }], + ), + ( + vec!["[64:ff9b:1:1::c000:aa]:0", "[64:ff9b:1:1::c000:ab]:0"], + vec![Nat64Prefix { + network: "64:ff9b:1:1::".parse().unwrap(), + length: 96, + }], + ), + ]; + + for (answers, expected) in cases { + let answers = answers + .into_iter() + .map(|value| value.parse().unwrap()) + .collect::>(); + assert_eq!(discover_nat64_prefixes(&answers).unwrap(), expected); + } + } + + #[test] + fn strict_pref64_discovery_accepts_every_allowed_rfc6052_length_in_reserved_space() { + fn embed(prefix: Nat64Prefix, ipv4: std::net::Ipv4Addr) -> Ipv6Addr { + let mut bytes = prefix.network.octets(); + let ipv4 = ipv4.octets(); + match prefix.length { + 32 => bytes[4..8].copy_from_slice(&ipv4), + 40 => { + bytes[5..8].copy_from_slice(&ipv4[..3]); + bytes[9] = ipv4[3]; + } + 48 => { + bytes[6..8].copy_from_slice(&ipv4[..2]); + bytes[9..11].copy_from_slice(&ipv4[2..]); + } + 56 => { + bytes[7] = ipv4[0]; + bytes[9..12].copy_from_slice(&ipv4[1..]); + } + 64 => bytes[9..13].copy_from_slice(&ipv4), + 96 => bytes[12..16].copy_from_slice(&ipv4), + _ => panic!("unsupported test prefix length"), + } + Ipv6Addr::from(bytes) + } + + for prefix in [ + Nat64Prefix { + network: "64:ff9b:1:100::".parse().unwrap(), + length: 56, + }, + Nat64Prefix { + network: "64:ff9b:1:1::".parse().unwrap(), + length: 64, + }, + Nat64Prefix { + network: "64:ff9b:1:1::".parse().unwrap(), + length: 96, + }, + ] { + let answers = [ + embed(prefix, std::net::Ipv4Addr::new(192, 0, 0, 170)), + embed(prefix, std::net::Ipv4Addr::new(192, 0, 0, 171)), + ] + .map(|ip| SocketAddr::new(ip.into(), 0)); + let discovered = discover_nat64_prefixes(&answers).unwrap(); + assert!(discovered.contains(&prefix), "prefix={prefix:?}"); + } + } + + #[test] + fn strict_pref64_discovery_rejects_incomplete_poisoned_and_unusable_answers() { + let invalid = [ + vec![], + vec!["192.0.0.170:0"], + vec!["198.51.100.10:0", "192.0.0.170:0", "192.0.0.171:0"], + vec![ + "[64:ff9b::c000:aa]:0", + "[64:ff9b::c000:ab]:0", + "198.51.100.10:0", + ], + vec![ + "[2001:4860:64::c000:aa]:0", + "[2001:4860:64::c000:ab]:0", + "[2606:4700:64::c000:aa]:0", + ], + vec!["[fe80::c000:aa]:0", "[fe80::c000:ab]:0"], + vec!["[2001:db8::c000:aa]:0", "[2001:db8::c000:ab]:0"], + vec!["[fd00:42::c000:aa]:0", "[fd00:42::c000:ab]:0"], + vec![ + "[2001:4860:64:0:100::c000:aa]:0", + "[2001:4860:64:0:100::c000:ab]:0", + ], + vec![ + "[2001:4860:64::c000:aa]:0", + "[2001:4860:64::c000:ab]:0", + "[fe80::c000:aa]:0", + "[fe80::c000:ab]:0", + ], + ]; + + for values in invalid { + let answers = values + .into_iter() + .map(|value| value.parse().unwrap()) + .collect::>(); + assert!( + discover_nat64_prefixes(&answers).is_err(), + "unexpected valid answers: {answers:?}" + ); + } + } + + #[tokio::test] + async fn nat64_cache_identity_preserves_address_to_interface_assignment() { + fn entry(network: &str, name: &str, index: u32) -> LocalNetworkEntry { + LocalNetworkEntry { + network: network.parse().unwrap(), + name: name.to_owned(), + index: Some(index), + adapter_id: Some(format!("adapter-{index}")), + } + } + + let resolver = Arc::new(MutableResolver { + answer: Mutex::new(vec![ + "[2001:4860:64::c000:aa]:0".parse().unwrap(), + "[2001:4860:64::c000:ab]:0".parse().unwrap(), + ]), + calls: AtomicUsize::new(0), + }); + let local = Arc::new(MutableLocalNetworks(Mutex::new(LocalNetworks { + interfaces: vec![ + entry("192.168.1.10/24", "ethernet", 1), + entry("10.0.0.10/24", "wifi", 2), + ], + }))); + let validator = DestinationValidator::new( + resolver.clone(), + local.clone(), + Arc::new(FixedClock(Instant::now())), + Vec::new(), + ); + let policy = OutboundPolicy { + allow_private_network_sources: true, + }; + + assert_eq!( + validator + .validate( + &Url::parse("http://[2001:4860:64::a9fe:a9fd]/").unwrap(), + policy, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + local.replace(LocalNetworks { + interfaces: vec![ + entry("10.0.0.10/24", "ethernet", 1), + entry("192.168.1.10/24", "wifi", 2), + ], + }); + assert_eq!( + validator + .validate( + &Url::parse("http://[2001:4860:64::a9fe:a9fd]/").unwrap(), + policy, + ) + .await + .unwrap_err(), + DestinationError::Blocked + ); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 2); + } + #[tokio::test] async fn discovered_ula_nat64_prefix_exposes_embedded_metadata() { let resolver = FakeResolver::new(vec![ @@ -924,17 +1512,58 @@ mod tests { #[tokio::test] async fn nat64_discovery_failure_rejects_unclassifiable_public_ipv6() { - let validator = validator(FakeResolver::failing()); + let resolver = FakeResolver::failing(); + let validator = validator(resolver.clone()); + for _ in 0..2 { + assert_eq!( + validator + .validate( + &Url::parse("http://[2001:4860:4860::8888]/").unwrap(), + OutboundPolicy::default(), + ) + .await + .unwrap_err(), + DestinationError::ResolutionFailed + ); + } + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn discovered_more_specific_rfc8215_prefix_can_authorize_public_embedding() { + let resolver = FakeResolver::new(vec![ + "[64:ff9b:1:1::c000:aa]:0".parse().unwrap(), + "[64:ff9b:1:1::c000:ab]:0".parse().unwrap(), + ]); + let validator = validator(resolver.clone()); + let resolved = validator + .validate( + &Url::parse("http://[64:ff9b:1:1::5db8:d822]/").unwrap(), + OutboundPolicy::default(), + ) + .await + .unwrap(); assert_eq!( + resolved.addrs, + vec!["[64:ff9b:1:1::5db8:d822]:80".parse().unwrap()] + ); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn well_known_nat64_public_target_never_triggers_discovery() { + let resolver = FakeResolver::failing(); + let validator = validator(resolver.clone()); + assert!( validator .validate( - &Url::parse("http://[2001:4860:4860::8888]/").unwrap(), + &Url::parse("http://[64:ff9b::5db8:d822]/").unwrap(), OutboundPolicy::default(), ) .await - .unwrap_err(), - DestinationError::ResolutionFailed + .is_ok() ); + assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); } #[tokio::test] @@ -1099,7 +1728,7 @@ mod tests { let up = interface("8.8.8.8".parse().unwrap(), 24, if_addrs::IfOperStatus::Up); assert_eq!( - network_for_interface(&up).unwrap().to_string(), + network_for_interface(&up).unwrap().network.to_string(), "8.8.8.8/24" ); diff --git a/server/src/network_security/runtime.rs b/server/src/network_security/runtime.rs index b3002b9..38162bd 100644 --- a/server/src/network_security/runtime.rs +++ b/server/src/network_security/runtime.rs @@ -1,6 +1,7 @@ use super::resolver::{ DestinationError, DestinationValidator, OutboundPolicy, ResolvedDestination, }; +use axum::http::{HeaderMap, HeaderValue}; #[cfg(test)] use std::sync::atomic::{AtomicUsize, Ordering}; use std::{ @@ -13,6 +14,7 @@ use tokio::sync::Notify; use tokio_util::sync::CancellationToken; use url::Url; +pub(crate) const PROXY_HOP_HEADER_NAME: &str = "x-stream-server-proxy-hop"; const MAX_CONCURRENT_PROXY_REQUESTS: usize = 64; const MAX_CONCURRENT_PROXY_REQUESTS_PER_PEER: usize = 16; const MAX_CONCURRENT_PLAYLISTS: usize = 8; @@ -337,6 +339,7 @@ fn normalize_peer(peer: Option) -> ProxyPeer { pub(crate) struct ProxyRuntime { validator: Arc, + hop_marker: HeaderValue, capacity: Arc, playlist_capacity: Arc, generation: Mutex, @@ -348,8 +351,20 @@ pub(crate) struct ProxyRuntime { impl ProxyRuntime { pub(crate) fn new(settings: ProxyPolicySettings, validator: Arc) -> Self { + let marker = uuid::Uuid::new_v4().to_string(); + let marker = HeaderValue::from_str(&marker).expect("UUID v4 is a valid HTTP header value"); + Self::with_hop_marker(settings, validator, marker) + } + + fn with_hop_marker( + settings: ProxyPolicySettings, + validator: Arc, + mut hop_marker: HeaderValue, + ) -> Self { + hop_marker.set_sensitive(true); Self { validator, + hop_marker, capacity: Arc::new(ProxyCapacity::default()), playlist_capacity: Arc::new(ProxyCapacity::default()), generation: Mutex::new(ProxyGeneration { @@ -363,6 +378,30 @@ impl ProxyRuntime { } } + #[cfg(test)] + pub(crate) fn new_with_hop_marker( + settings: ProxyPolicySettings, + validator: Arc, + hop_marker: HeaderValue, + ) -> Self { + Self::with_hop_marker(settings, validator, hop_marker) + } + + pub(crate) fn hop_marker(&self) -> &HeaderValue { + &self.hop_marker + } + + pub(crate) fn matches_inbound_hop_marker(&self, headers: &HeaderMap) -> bool { + use subtle::ConstantTimeEq; + + headers + .get_all(PROXY_HOP_HEADER_NAME) + .iter() + .flat_map(|value| value.as_bytes().split(|byte| *byte == b',')) + .map(trim_http_ows) + .any(|value| bool::from(value.ct_eq(self.hop_marker.as_bytes()))) + } + #[cfg(test)] pub(crate) fn try_request(&self) -> Result { self.try_request_for_peer(None) @@ -512,6 +551,22 @@ impl ProxyRuntime { } } +fn trim_http_ows(mut value: &[u8]) -> &[u8] { + while value + .first() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + value = &value[1..]; + } + while value + .last() + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + value = &value[..value.len() - 1]; + } + value +} + #[cfg(test)] mod tests { use super::super::{ @@ -520,6 +575,7 @@ mod tests { }; use super::{ProxyPolicySettings, ProxyRuntime}; use async_trait::async_trait; + use axum::http::{HeaderMap, HeaderValue}; use std::{ io, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, @@ -563,6 +619,62 @@ mod tests { ProxyRuntime::new(settings, validator) } + fn runtime_with_marker(marker: HeaderValue) -> ProxyRuntime { + let validator = Arc::new(DestinationValidator::new( + Arc::new(NoDns), + Arc::new(NoLocalNetworks), + Arc::new(FixedClock), + Vec::new(), + )); + ProxyRuntime::new_with_hop_marker(ProxyPolicySettings::default(), validator, marker) + } + + #[test] + fn hop_marker_is_stable_across_requests_and_reconfiguration_but_unique_per_runtime() { + let first_runtime = runtime(ProxyPolicySettings::default()); + let marker = first_runtime.hop_marker().clone(); + let request = first_runtime.try_request().unwrap(); + assert_eq!(first_runtime.hop_marker(), &marker); + drop(request); + + first_runtime.begin_reconfigure(ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }); + first_runtime.finish_reconfigure(ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }); + assert_eq!(first_runtime.hop_marker(), &marker); + assert_ne!( + first_runtime.hop_marker(), + runtime(ProxyPolicySettings::default()).hop_marker() + ); + assert!(first_runtime.hop_marker().is_sensitive()); + } + + #[test] + fn inbound_hop_marker_matches_raw_repeated_and_comma_coalesced_fields() { + let runtime = runtime_with_marker(HeaderValue::from_static("test-marker")); + let mut headers = HeaderMap::new(); + headers.append( + "x-stream-server-proxy-hop", + HeaderValue::from_bytes(b"\x80-not-utf8, other").unwrap(), + ); + headers.append( + "x-stream-server-proxy-hop", + HeaderValue::from_static("not-it,\ttest-marker \t"), + ); + assert!(runtime.matches_inbound_hop_marker(&headers)); + + headers.clear(); + headers.insert( + "x-stream-server-proxy-hop", + HeaderValue::from_static("not-test-marker"), + ); + assert!(!runtime.matches_inbound_hop_marker(&headers)); + } + #[test] fn sixty_fifth_request_is_rejected_without_waiting() { let runtime = runtime(ProxyPolicySettings::default()); diff --git a/server/src/routes/proxy.rs b/server/src/routes/proxy.rs index d70668f..505c5c8 100644 --- a/server/src/routes/proxy.rs +++ b/server/src/routes/proxy.rs @@ -2,8 +2,8 @@ use crate::network_security::ProxyProducerProbe; use crate::{ network_security::{ - DestinationError, ProxyPlaylistPermit, ProxyProducerLease, ProxyRequestContext, - ProxyRuntime, + DestinationError, PROXY_HOP_HEADER_NAME, ProxyPlaylistPermit, ProxyProducerLease, + ProxyRequestContext, ProxyRuntime, }, state::AppState, }; @@ -319,6 +319,7 @@ fn request_header_forbidden(name: &HeaderName) -> bool { | "http2-settings" | "x-real-ip" | "x-host" + | PROXY_HOP_HEADER_NAME ) || name.starts_with("x-forwarded-") || name.starts_with("x-original-") || name.starts_with("x-rewrite-") @@ -401,6 +402,7 @@ async fn fetch_with_redirects( header::ACCEPT_ENCODING, HeaderValue::from_static("identity"), ); + headers.insert(PROXY_HOP_HEADER_NAME, runtime.hop_marker().clone()); let send = client .request(method.clone(), destination.url.clone()) .headers(headers) @@ -543,6 +545,9 @@ async fn handle_proxy_suffix_for_peer( if raw_suffix.len() > MAX_PROXY_INPUT { return proxy_error_response(ProxyError::InvalidRequest); } + if runtime.matches_inbound_hop_marker(&headers) { + return proxy_error_response(ProxyError::Blocked); + } if method == Method::CONNECT { return proxy_error_response(ProxyError::InvalidRequest); } @@ -648,6 +653,10 @@ async fn handle_proxy_suffix_for_peer( ) } +pub(crate) fn blocked_response() -> Response { + proxy_error_response(ProxyError::Blocked) +} + fn content_type_is_playlist(value: &HeaderValue) -> bool { let media_type = trim_ascii_ows( value @@ -3339,6 +3348,7 @@ mod tests { "h=Content-Length%3A4", "h=Connection%3Akeep-alive", "h=Expect%3A100-continue", + "h=X-Stream-Server-Proxy-Hop%3Acustom-marker", "h=X-Test%3Aok%0D%0AX-Evil%3Ayes", "r=Set-Cookie%3Astolen%3D1", "r=Transfer-Encoding%3Achunked", @@ -3355,6 +3365,53 @@ mod tests { } } + #[tokio::test] + async fn matching_inbound_marker_runs_after_raw_cap_but_before_all_proxy_work() { + let (runtime, resolver) = test_runtime( + "127.0.0.1:1".parse().unwrap(), + ProxyPolicySettings::default(), + ); + let mut headers = HeaderMap::new(); + headers.append("x-stream-server-proxy-hop", runtime.hop_marker().clone()); + + let prefix = "?d=http%3A%2F%2Fblocked.example&unknown="; + let overlong = format!("{prefix}{}", "a".repeat(MAX_PROXY_INPUT + 1 - prefix.len())); + let response = + handle_proxy_suffix(&runtime, &overlong, headers.clone(), Method::CONNECT).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + for (suffix, method) in [ + ("?malformed", Method::GET), + ("?d=http%3A%2F%2Fblocked.example", Method::CONNECT), + ] { + let response = handle_proxy_suffix(&runtime, suffix, headers.clone(), method).await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_response_isolated(&response); + } + assert_eq!(resolver.calls.load(Ordering::SeqCst), 0); + assert_eq!(runtime.capacity_snapshot(), (0, 0)); + + let permits = (0..64) + .map(|index| { + runtime + .try_request_for_peer(Some(std::net::IpAddr::V6(std::net::Ipv6Addr::from( + index + 1, + )))) + .unwrap() + }) + .collect::>(); + let response = handle_proxy_suffix( + &runtime, + "?d=http%3A%2F%2Fblocked.example", + headers, + Method::GET, + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!(runtime.capacity_snapshot(), (64, 64)); + drop(permits); + } + #[test] fn every_stable_error_response_has_route_owned_isolation_headers() { for (error, status) in [ @@ -4215,6 +4272,85 @@ mod tests { fixture.abort(); } + #[tokio::test] + async fn route_owned_hop_marker_is_overwritten_on_every_redirect_hop() { + let observed = Arc::new(std::sync::Mutex::new(Vec::::new())); + let first_observed = observed.clone(); + let final_observed = observed.clone(); + let router = Router::new() + .route( + "/first", + get(move |headers: HeaderMap| { + let observed = first_observed.clone(); + async move { + observed + .lock() + .unwrap() + .push(headers["x-stream-server-proxy-hop"].clone()); + ( + StatusCode::TEMPORARY_REDIRECT, + [(header::LOCATION, "/final")], + ) + } + }), + ) + .route( + "/final", + get(move |headers: HeaderMap| { + let observed = final_observed.clone(); + async move { + observed + .lock() + .unwrap() + .push(headers["x-stream-server-proxy-hop"].clone()); + Response::builder() + .header(header::CONTENT_TYPE, "application/vnd.apple.mpegurl") + .body(Body::from("#EXTM3U\nsegment.ts\n")) + .unwrap() + } + }), + ); + let (address, fixture) = fixture(router).await; + let (runtime, _) = test_runtime( + address, + ProxyPolicySettings { + allow_private_network_sources: true, + allow_invalid_proxy_tls_certificates: false, + }, + ); + let expected = runtime.hop_marker().clone(); + let uri: Uri = format!( + "/proxy/?d=http%3A%2F%2Fmarker.test%3A{}%2Ffirst", + address.port() + ) + .parse() + .unwrap(); + let mut incoming = HeaderMap::new(); + incoming.insert( + "x-stream-server-proxy-hop", + HeaderValue::from_static("attacker-controlled-nonmatch"), + ); + let response = handle_proxy(&runtime, uri, incoming, Method::GET).await; + assert_eq!(response.status(), StatusCode::OK); + let body = String::from_utf8( + axum::body::to_bytes(response.into_body(), MAX_PLAYLIST_OUTPUT) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + assert!(!body.contains("x-stream-server-proxy-hop")); + assert!(!body.contains("attacker-controlled-nonmatch")); + let child = body + .lines() + .find(|line| line.starts_with("/proxy")) + .unwrap(); + let parsed = parse_proxy_suffix(child.strip_prefix("/proxy").unwrap()).unwrap(); + assert!(parsed.request_headers.is_empty()); + assert_eq!(*observed.lock().unwrap(), vec![expected.clone(), expected]); + fixture.abort(); + } + #[tokio::test] async fn redirects_preserve_method_and_strip_cross_authority_secrets() { let (seen_tx, seen_rx) = tokio::sync::oneshot::channel(); diff --git a/server/src/state.rs b/server/src/state.rs index 3b3a586..83d46ca 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -467,8 +467,7 @@ impl AppState { Arc::new(SystemLocalNetworkProvider), Arc::new(SystemClock), vec![ListenerBinding { - address: default_http_addr.ip(), - port: default_http_addr.port(), + socket: default_http_addr, }], )); diff --git a/server/tests/proxy_security.rs b/server/tests/proxy_security.rs index 929881d..a6c4924 100644 --- a/server/tests/proxy_security.rs +++ b/server/tests/proxy_security.rs @@ -1,12 +1,19 @@ use axum::{ Router, body::Body, + extract::Path, http::{HeaderMap, Response, StatusCode, header}, + response::IntoResponse, routing::get, }; use futures_util::{StreamExt, stream}; use serde_json::json; -use std::{convert::Infallible, io::Read, time::Duration}; +use std::{ + convert::Infallible, + io::Read, + sync::{Arc, Mutex}, + time::Duration, +}; async fn range(headers: HeaderMap) -> Response { let bytes = b"0123456789"; @@ -116,10 +123,312 @@ async fn start_tls_fixture() -> anyhow::Result<( Ok((address, task)) } +fn install_https_fixture(config_dir: &std::path::Path) -> anyhow::Result<()> { + std::fs::create_dir_all(config_dir)?; + std::fs::copy( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/localhost-cert.pem" + ), + config_dir.join("https-cert.pem"), + )?; + std::fs::copy( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/localhost-key.pem" + ), + config_dir.join("https-key.pem"), + )?; + Ok(()) +} + fn proxy_url(server: std::net::SocketAddr, target: &str) -> String { format!("http://{server}/proxy/?d={}", urlencoding::encode(target)) } +#[tokio::test] +async fn marker_preserving_reverse_proxy_cannot_reenter_application_routes() -> anyhow::Result<()> { + let server_address = Arc::new(Mutex::new(None::)); + let fixture_server_address = server_address.clone(); + let fixture_router = Router::new().route( + "/{mode}", + get(move |Path(mode): Path, headers: HeaderMap| { + let server_address = fixture_server_address.clone(); + async move { + let server = server_address.lock().unwrap().unwrap(); + let client = reqwest::Client::new(); + let (method, path, preserve_marker) = match mode.as_str() { + "heartbeat" => (reqwest::Method::GET, "/heartbeat", true), + "proxyevil" => (reqwest::Method::GET, "/proxyevil", true), + "strip" => (reqwest::Method::GET, "/heartbeat", false), + "preflight" => (reqwest::Method::OPTIONS, "/heartbeat", true), + _ => return StatusCode::NOT_FOUND.into_response(), + }; + let mut request = client.request(method, format!("http://{server}{path}")); + if preserve_marker { + request = request.header( + "x-stream-server-proxy-hop", + headers["x-stream-server-proxy-hop"].clone(), + ); + } + if mode == "preflight" { + request = request + .header(header::ORIGIN, "https://app.example") + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET"); + } + let response = request.send().await.unwrap(); + let status = response.status(); + let body = response.bytes().await.unwrap(); + (status, body).into_response() + } + }), + ); + let fixture_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let fixture_address = fixture_listener.local_addr()?; + let fixture_task = tokio::spawn(async move { + axum::serve(fixture_listener, fixture_router).await.unwrap(); + }); + + let config = tempfile::tempdir()?; + let cache = tempfile::tempdir()?; + let config_dir = config.path().join("config"); + let server_config = stream_server::ServerConfig { + http_addr: "127.0.0.1:0".parse().unwrap(), + config_dir: Some(config_dir.clone()), + cache_dir: Some(cache.path().join("cache")), + ..stream_server::ServerConfig::embedded() + }; + let server = tokio::task::spawn_blocking(move || stream_server::start(server_config)).await??; + *server_address.lock().unwrap() = Some(server.http_addr()); + let client = reqwest::Client::new(); + let token = std::fs::read_to_string(config_dir.join("settings-control.token"))?; + assert_eq!( + client + .post(format!("http://{}/settings", server.http_addr())) + .header("x-stream-server-settings-token", token) + .json(&json!({"allowPrivateNetworkSources": true})) + .send() + .await? + .status(), + StatusCode::OK + ); + + for (mode, expected) in [ + ("heartbeat", StatusCode::FORBIDDEN), + ("proxyevil", StatusCode::FORBIDDEN), + ("strip", StatusCode::OK), + ("preflight", StatusCode::OK), + ] { + let target = format!("http://{fixture_address}/{mode}"); + let response = client + .get(proxy_url(server.http_addr(), &target)) + .send() + .await?; + assert_eq!(response.status(), expected, "mode={mode}"); + } + + let shutdown = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + server.shutdown()?; + server.join() + }) + .await??; + assert_eq!(shutdown, Some(stream_server::ShutdownSource::External)); + fixture_task.abort(); + Ok(()) +} + +#[tokio::test] +async fn managed_https_port_zero_reports_serves_and_blocks_the_exact_socket() -> anyhow::Result<()> +{ + let config = tempfile::tempdir()?; + let cache = tempfile::tempdir()?; + let config_dir = config.path().join("config"); + install_https_fixture(&config_dir)?; + let server_config = stream_server::ServerConfig { + http_addr: "127.0.0.1:0".parse().unwrap(), + https_addr: Some("127.0.0.1:0".parse().unwrap()), + config_dir: Some(config_dir.clone()), + cache_dir: Some(cache.path().join("cache")), + ..stream_server::ServerConfig::embedded() + }; + let server = tokio::task::spawn_blocking(move || stream_server::start(server_config)).await??; + let https_address = server + .bound_https_addr() + .expect("prepared HTTPS listener must be exposed"); + assert_ne!(https_address.port(), 0); + let tls_client = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build()?; + assert_eq!( + tls_client + .get(format!("https://{https_address}/heartbeat")) + .send() + .await? + .status(), + StatusCode::OK + ); + + let client = reqwest::Client::new(); + let token = std::fs::read_to_string(config_dir.join("settings-control.token"))?; + assert_eq!( + client + .post(format!("http://{}/settings", server.http_addr())) + .header("x-stream-server-settings-token", token) + .json(&json!({"allowPrivateNetworkSources": true})) + .send() + .await? + .status(), + StatusCode::OK + ); + let target = format!("https://{https_address}/heartbeat"); + assert_eq!( + client + .get(proxy_url(server.http_addr(), &target)) + .send() + .await? + .status(), + StatusCode::FORBIDDEN + ); + + let shutdown = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + server.shutdown()?; + server.join() + }) + .await??; + assert_eq!(shutdown, Some(stream_server::ShutdownSource::External)); + Ok(()) +} + +#[tokio::test] +async fn failed_https_preparation_is_not_registered_and_http_remains_usable() -> anyhow::Result<()> +{ + let occupied_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let occupied_address = occupied_listener.local_addr()?; + let fixture = tokio::spawn(async move { + axum::serve( + occupied_listener, + Router::new().route("/ok", get(|| async { "occupied-fixture" })), + ) + .await + .unwrap(); + }); + let config = tempfile::tempdir()?; + let cache = tempfile::tempdir()?; + let config_dir = config.path().join("config"); + install_https_fixture(&config_dir)?; + let server_config = stream_server::ServerConfig { + http_addr: "127.0.0.1:0".parse().unwrap(), + https_addr: Some(occupied_address), + config_dir: Some(config_dir.clone()), + cache_dir: Some(cache.path().join("cache")), + ..stream_server::ServerConfig::embedded() + }; + let server = tokio::task::spawn_blocking(move || stream_server::start(server_config)).await??; + assert_eq!(server.bound_https_addr(), None); + let client = reqwest::Client::new(); + assert_eq!( + client + .get(format!("http://{}/heartbeat", server.http_addr())) + .send() + .await? + .status(), + StatusCode::OK + ); + let token = std::fs::read_to_string(config_dir.join("settings-control.token"))?; + client + .post(format!("http://{}/settings", server.http_addr())) + .header("x-stream-server-settings-token", token) + .json(&json!({"allowPrivateNetworkSources": true})) + .send() + .await? + .error_for_status()?; + let target = format!("http://{occupied_address}/ok"); + let response = client + .get(proxy_url(server.http_addr(), &target)) + .send() + .await?; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.text().await?, "occupied-fixture"); + + let shutdown = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + server.shutdown()?; + server.join() + }) + .await??; + assert_eq!(shutdown, Some(stream_server::ShutdownSource::External)); + fixture.abort(); + Ok(()) +} + +#[tokio::test] +async fn malformed_https_pem_leaves_http_ready_without_a_stale_listener() -> anyhow::Result<()> { + let config = tempfile::tempdir()?; + let cache = tempfile::tempdir()?; + let config_dir = config.path().join("config"); + std::fs::create_dir_all(&config_dir)?; + std::fs::write(config_dir.join("https-cert.pem"), b"not a certificate")?; + std::fs::write(config_dir.join("https-key.pem"), b"not a key")?; + let server_config = stream_server::ServerConfig { + http_addr: "127.0.0.1:0".parse().unwrap(), + https_addr: Some("127.0.0.1:0".parse().unwrap()), + config_dir: Some(config_dir), + cache_dir: Some(cache.path().join("cache")), + ..stream_server::ServerConfig::embedded() + }; + let server = tokio::task::spawn_blocking(move || stream_server::start(server_config)).await??; + assert_eq!(server.bound_https_addr(), None); + assert_eq!( + reqwest::get(format!("http://{}/heartbeat", server.http_addr())) + .await? + .status(), + StatusCode::OK + ); + let shutdown = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + server.shutdown()?; + server.join() + }) + .await??; + assert_eq!(shutdown, Some(stream_server::ShutdownSource::External)); + Ok(()) +} + +#[tokio::test] +async fn unreadable_https_pem_leaves_http_ready_without_a_stale_listener() -> anyhow::Result<()> { + let config = tempfile::tempdir()?; + let cache = tempfile::tempdir()?; + let config_dir = config.path().join("config"); + std::fs::create_dir_all(config_dir.join("https-cert.pem"))?; + std::fs::copy( + concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/localhost-key.pem" + ), + config_dir.join("https-key.pem"), + )?; + let server_config = stream_server::ServerConfig { + http_addr: "127.0.0.1:0".parse().unwrap(), + https_addr: Some("127.0.0.1:0".parse().unwrap()), + config_dir: Some(config_dir), + cache_dir: Some(cache.path().join("cache")), + ..stream_server::ServerConfig::embedded() + }; + let server = tokio::task::spawn_blocking(move || stream_server::start(server_config)).await??; + assert_eq!(server.bound_https_addr(), None); + assert_eq!( + reqwest::get(format!("http://{}/heartbeat", server.http_addr())) + .await? + .status(), + StatusCode::OK + ); + let shutdown = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + server.shutdown()?; + server.join() + }) + .await??; + assert_eq!(shutdown, Some(stream_server::ShutdownSource::External)); + Ok(()) +} + #[tokio::test] async fn normal_encoded_core_path_form_reaches_destination_policy() -> anyhow::Result<()> { let config = tempfile::tempdir()?; From bf3b1c1d4686dc97eefe90d9ba1b71f7a8c4bc51 Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:28:20 -0400 Subject: [PATCH 23/25] security: persist redacted proxy request traces --- Cargo.lock | 1 + server/Cargo.toml | 3 + server/src/lib.rs | 220 +++++++++++++++++++++++++++++++-- server/tests/proxy_security.rs | 129 +++++++++++++++++++ 4 files changed, 345 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c91dc56..c0fa274 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7872,6 +7872,7 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-util", + "tower", "tower-http 0.7.0", "tracing", "tracing-appender", diff --git a/server/Cargo.toml b/server/Cargo.toml index 6156079..eb60d22 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -110,6 +110,9 @@ windows = { version = "0.62.2", features = [ "Win32_UI_WindowsAndMessaging", ] } +[dev-dependencies] +tower = { version = "0.5.3", features = ["util"] } + [features] default = ["libtorrent"] diff --git a/server/src/lib.rs b/server/src/lib.rs index 6173da7..8f6bd58 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1014,14 +1014,22 @@ pub fn build_router(state: AppState) -> Router { .fallback(fallback_handler) .method_not_allowed_fallback(method_not_allowed_handler) .layer( - TraceLayer::new_for_http().make_span_with(|request: &axum::http::Request<_>| { - let target = diagnostics::logging::sanitize_request_target(request.uri()); - tracing::info_span!( - "request", - method = %request.method(), - path = %target.path, - ) - }), + TraceLayer::new_for_http() + .make_span_with(|request: &axum::http::Request<_>| { + let target = diagnostics::logging::sanitize_request_target(request.uri()); + tracing::info_span!( + "request", + method = %request.method(), + path = %target.path, + ) + }) + .on_request( + |request: &axum::http::Request, span: &tracing::Span| { + if request.uri().path().starts_with("/proxy") { + tracing::info!(parent: span, "proxy request started"); + } + }, + ), ) .layer(axum::middleware::from_fn_with_state( state.proxy_runtime.clone(), @@ -1077,6 +1085,202 @@ mod tests { use super::*; use std::sync::atomic::{AtomicBool, Ordering}; + #[tokio::test(flavor = "current_thread")] + async fn proxy_request_traces_and_diagnostics_export_only_redacted_targets() { + use std::io::Read; + use tower::ServiceExt; + + const SECRETS: &[&str] = &[ + "parser-header-secret-9c30", + "policy-user-secret-9c30", + "policy-query-secret-9c30", + "policy-header-secret-9c30", + "redirect-user-secret-9c30", + "redirect-query-secret-9c30", + "redirect-header-secret-9c30", + "upstream-user-secret-9c30", + "upstream-query-secret-9c30", + "upstream-header-secret-9c30", + ]; + + #[derive(Clone)] + struct TestLogWriter(Arc>>); + + impl std::io::Write for TestLogWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + let _engine_test_guard = TEST_ENGINE_MUTEX.lock().await; + let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let redirect_address = redirect_listener.local_addr().unwrap(); + let redirect_task = tokio::spawn(async move { + axum::serve( + redirect_listener, + Router::new().route( + "/redirect", + get(|| async { + ( + StatusCode::TEMPORARY_REDIRECT, + [( + axum::http::header::LOCATION, + concat!( + "http://redirect-user:redirect-user-secret-9c30@169.254.169.254/", + "latest/meta-data/?token=redirect-query-secret-9c30" + ), + )], + ) + }), + ), + ) + .await + .unwrap(); + }); + let closed_listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let closed_address = closed_listener.local_addr().unwrap(); + drop(closed_listener); + + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let settings = routes::system::ServerSettings { + allow_private_network_sources: true, + ..routes::system::ServerSettings::default() + }; + let state = AppState::new(engine, settings, temp.path().join("config")); + std::fs::create_dir_all(&state.log_dir).unwrap(); + let router = build_router(state.clone()); + let requests = [ + ( + format!( + "/proxy/?d=not-a-url&h={}", + urlencoding::encode("X-Api-Key:parser-header-secret-9c30") + ), + StatusCode::BAD_REQUEST, + "Invalid proxy request", + ), + ( + format!( + "/proxy/?d={}&h={}", + urlencoding::encode(concat!( + "http://policy-user:policy-user-secret-9c30@169.254.169.254/", + "latest/meta-data/?token=policy-query-secret-9c30" + )), + urlencoding::encode("X-Api-Key:policy-header-secret-9c30") + ), + StatusCode::FORBIDDEN, + "Proxy destination is blocked", + ), + ( + format!( + "/proxy/?d={}&h={}", + urlencoding::encode(&format!( + "http://redirect-user:redirect-user-secret-9c30@{redirect_address}/redirect?token=redirect-query-secret-9c30" + )), + urlencoding::encode("X-Api-Key:redirect-header-secret-9c30") + ), + StatusCode::FORBIDDEN, + "Proxy destination is blocked", + ), + ( + format!( + "/proxy/?d={}&h={}", + urlencoding::encode(&format!( + "http://upstream-user:upstream-user-secret-9c30@{closed_address}/asset?token=upstream-query-secret-9c30" + )), + urlencoding::encode("X-Api-Key:upstream-header-secret-9c30") + ), + StatusCode::BAD_GATEWAY, + "Proxy upstream request failed", + ), + ]; + + let logs = Arc::new(std::sync::Mutex::new(Vec::new())); + let writer = TestLogWriter(logs.clone()); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_target(false) + .with_max_level(tracing::Level::INFO) + .with_writer(move || writer.clone()) + .finish(); + let subscriber_guard = tracing::subscriber::set_default(subscriber); + for (uri, expected_status, expected_body) in requests { + let request = axum::http::Request::builder() + .uri(uri) + .body(axum::body::Body::empty()) + .unwrap(); + let response = router.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), expected_status); + let body = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + assert_eq!(body, expected_body); + for secret in SECRETS { + assert!( + !body + .windows(secret.len()) + .any(|part| part == secret.as_bytes()) + ); + } + } + drop(subscriber_guard); + + let captured = logs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + let captured_text = String::from_utf8_lossy(&captured); + assert_eq!( + captured_text.matches("proxy request started").count(), + 4, + "unexpected proxy trace output:\n{captured_text}" + ); + assert!(captured_text.matches("/proxy/").count() >= 4); + for secret in SECRETS { + assert!( + !captured_text.contains(secret), + "captured log leaked {secret}" + ); + } + std::fs::write(state.log_dir.join("proxy-redaction.log"), &captured).unwrap(); + + let diagnostics = diagnostics::build_diagnostics_zip(&state).unwrap(); + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(diagnostics)).unwrap(); + let mut saw_redacted_proxy = false; + for index in 0..archive.len() { + let mut entry = archive.by_index(index).unwrap(); + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).unwrap(); + saw_redacted_proxy |= bytes + .windows(b"/proxy/".len()) + .any(|part| part == b"/proxy/"); + for secret in SECRETS { + assert!( + !bytes + .windows(secret.len()) + .any(|part| part == secret.as_bytes()), + "diagnostics entry {} leaked {secret}", + entry.name() + ); + } + } + assert!(saw_redacted_proxy); + redirect_task.abort(); + } + #[tokio::test] async fn listener_aware_embedding_blocks_supplied_socket_while_legacy_keeps_default() { let _engine_test_guard = TEST_ENGINE_MUTEX.lock().await; diff --git a/server/tests/proxy_security.rs b/server/tests/proxy_security.rs index a6c4924..ea62b95 100644 --- a/server/tests/proxy_security.rs +++ b/server/tests/proxy_security.rs @@ -146,6 +146,135 @@ fn proxy_url(server: std::net::SocketAddr, target: &str) -> String { format!("http://{server}/proxy/?d={}", urlencoding::encode(target)) } +#[tokio::test] +async fn proxy_failure_bodies_never_expose_request_credentials() -> anyhow::Result<()> { + const SECRETS: &[&str] = &[ + "parser-header-secret-9c30", + "policy-user-secret-9c30", + "policy-query-secret-9c30", + "policy-header-secret-9c30", + "redirect-user-secret-9c30", + "redirect-query-secret-9c30", + "redirect-header-secret-9c30", + "upstream-user-secret-9c30", + "upstream-query-secret-9c30", + "upstream-header-secret-9c30", + ]; + + let redirect_router = Router::new().route( + "/redirect", + get(|| async { + ( + StatusCode::TEMPORARY_REDIRECT, + [( + header::LOCATION, + concat!( + "http://redirect-user:redirect-user-secret-9c30@169.254.169.254/", + "latest/meta-data/?token=redirect-query-secret-9c30" + ), + )], + ) + }), + ); + let redirect_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let redirect_address = redirect_listener.local_addr()?; + let redirect_task = tokio::spawn(async move { + axum::serve(redirect_listener, redirect_router) + .await + .unwrap(); + }); + let closed_listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let closed_address = closed_listener.local_addr()?; + drop(closed_listener); + + let config = tempfile::tempdir()?; + let cache = tempfile::tempdir()?; + let config_dir = config.path().join("config"); + let server_config = stream_server::ServerConfig { + http_addr: "127.0.0.1:0".parse().unwrap(), + config_dir: Some(config_dir.clone()), + cache_dir: Some(cache.path().join("cache")), + ..stream_server::ServerConfig::embedded() + }; + let server = tokio::task::spawn_blocking(move || stream_server::start(server_config)).await??; + let client = reqwest::Client::new(); + let token = std::fs::read_to_string(config_dir.join("settings-control.token"))?; + client + .post(format!("http://{}/settings", server.http_addr())) + .header("x-stream-server-settings-token", token) + .json(&json!({"allowPrivateNetworkSources": true})) + .send() + .await? + .error_for_status()?; + + let requests = [ + ( + format!( + "http://{}/proxy/?d=not-a-url&h={}", + server.http_addr(), + urlencoding::encode("X-Api-Key:parser-header-secret-9c30") + ), + StatusCode::BAD_REQUEST, + "Invalid proxy request", + ), + ( + format!( + "http://{}/proxy/?d={}&h={}", + server.http_addr(), + urlencoding::encode(concat!( + "http://policy-user:policy-user-secret-9c30@169.254.169.254/", + "latest/meta-data/?token=policy-query-secret-9c30" + )), + urlencoding::encode("X-Api-Key:policy-header-secret-9c30") + ), + StatusCode::FORBIDDEN, + "Proxy destination is blocked", + ), + ( + format!( + "http://{}/proxy/?d={}&h={}", + server.http_addr(), + urlencoding::encode(&format!( + "http://redirect-user:redirect-user-secret-9c30@{redirect_address}/redirect?token=redirect-query-secret-9c30" + )), + urlencoding::encode("X-Api-Key:redirect-header-secret-9c30") + ), + StatusCode::FORBIDDEN, + "Proxy destination is blocked", + ), + ( + format!( + "http://{}/proxy/?d={}&h={}", + server.http_addr(), + urlencoding::encode(&format!( + "http://upstream-user:upstream-user-secret-9c30@{closed_address}/asset?token=upstream-query-secret-9c30" + )), + urlencoding::encode("X-Api-Key:upstream-header-secret-9c30") + ), + StatusCode::BAD_GATEWAY, + "Proxy upstream request failed", + ), + ]; + for (url, expected_status, expected_body) in requests { + let response = client.get(url).send().await?; + assert_eq!(response.status(), expected_status); + let body = response.text().await?; + assert_eq!(body, expected_body); + for secret in SECRETS { + assert!(!body.contains(secret)); + } + } + + let shutdown = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + server.shutdown()?; + server.join() + }) + .await??; + assert_eq!(shutdown, Some(stream_server::ShutdownSource::External)); + redirect_task.abort(); + Ok(()) +} + #[tokio::test] async fn marker_preserving_reverse_proxy_cannot_reenter_application_routes() -> anyhow::Result<()> { let server_address = Arc::new(Mutex::new(None::)); From 1546dad5eb27bf51c6846ca191ea35ddd1d12aba Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:39:49 -0400 Subject: [PATCH 24/25] test: close proxy security delivery gaps --- docs/network-source-security.md | 125 +++++++++++++++---- server/src/routes/system.rs | 215 +++++++++++++++++++++++++++++++- server/src/settings_control.rs | 41 ++++++ server/src/state.rs | 16 +++ settings-gui/ui/app.slint | 4 +- 5 files changed, 373 insertions(+), 28 deletions(-) diff --git a/docs/network-source-security.md b/docs/network-source-security.md index 362fe79..dbd5bea 100644 --- a/docs/network-source-security.md +++ b/docs/network-source-security.md @@ -5,25 +5,40 @@ Stream Server protects the `/proxy` endpoint from server-side request forgery (S These defaults prevent a webpage or LAN client that can reach Stream Server from using it to probe services on your computer, local network, or cloud metadata endpoints. -This protection applies to `/proxy` only. Subtitle, archive, FTP, NZB/NNTP, HLS/casting, remote -torrent, tracker, and updater network inputs are outside this policy. +> **Important:** both security exceptions described below are global policies for every `/proxy` +> request while enabled. They are not per-site allowlists. Any browser or LAN client that can reach +> the Stream Server listener may ask `/proxy` to contact an otherwise eligible private destination. +> Enable an exception only when required, restrict listener access with host firewall and network +> controls, and disable it afterward. Stream Server does not automatically add or change Windows +> Firewall rules. + +## Scope of this protection + +This policy applies only to `/proxy` in this release. The same destination validator does not yet +protect subtitle downloads, archives or local paths, FTP/curl inputs, NZB/NNTP, non-proxy HLS, +casting or FFmpeg inputs, remote torrent or tracker inputs, BitTorrent-backend fetches, or updater +inputs. Treat those as separate trust boundaries until later security work covers them. + +`BitTorrent SSRF mitigation` is also separate. It maps to `btSsrfMitigation`, remains enabled by +default, and controls libtorrent behavior rather than `/proxy`. ## Configure the options in the settings app Open **Settings > Privacy**. Two protected controls are available: -- **Allow private/LAN proxy sources** lets `/proxy` reach loopback, private, link-local, CGNAT, - IPv6 ULA, and directly connected network sources. Enable it only for a media source you trust. -- **Allow invalid proxy TLS certificates** disables certificate verification for `/proxy` only. - Enable it only for a trusted self-signed HTTPS source. +- **Allow private/LAN proxy sources** lets every `/proxy` request reach eligible loopback, private, + carrier-grade NAT, IPv6 ULA, IPv4 link-local, and directly connected sources. Known metadata and + other always-blocked addresses remain denied. +- **Allow invalid proxy TLS certificates** disables certificate verification for every `/proxy` + request. Enable it only when a required source uses a certificate you have independently trusted. The standalone settings app enables these controls only when it connects to an IP-literal loopback address and can read the local `settings-control.token` file. The embedded tray settings window is trusted directly. A remote settings connection can still read settings and change ordinary options, but cannot change either protected option. -`BitTorrent SSRF mitigation` is separate. It maps to `btSsrfMitigation`, remains enabled by -default, and controls libtorrent behavior rather than `/proxy`. +The invalid-certificate option does not broaden the address policy. A self-signed private source +requires both exceptions. Prefer a valid certificate whenever possible. ## Configure the local HTTP API @@ -101,30 +116,90 @@ variable therefore restores the persisted value on the next restart. | Destination class | Default | With private/LAN opt-in | | --- | --- | --- | | Public HTTP/HTTPS address | Allowed | Allowed | -| Loopback and RFC 1918 private address | Blocked | Allowed | -| CGNAT, IPv6 ULA, and non-metadata link-local address | Blocked | Allowed | -| Current directly connected network | Blocked | Allowed | -| Stream Server's own HTTP/HTTPS listener | Blocked | Blocked | -| Known cloud/container metadata address | Blocked | Blocked | +| Loopback and private address | Blocked | Allowed | +| CGNAT, IPv6 ULA, IPv4 link-local, and current connected network | Blocked | Allowed | +| Stream Server's registered HTTP/HTTPS listeners | Blocked | Blocked | +| Known cloud, container, and platform metadata addresses | Blocked | Blocked | | Unspecified, multicast, broadcast, documentation, benchmark, reserved, or future-use address | Blocked | Blocked | -Every DNS answer and every redirect destination must pass the policy. Stream Server pins validated -DNS results to the outbound connection, ignores system HTTP proxies for `/proxy`, blocks HTTPS to -HTTP redirect downgrades, and never permits its own listener through the proxy. - -The invalid-certificate option does not broaden the address policy. For example, a self-signed LAN -source requires both the private/LAN option and the invalid-certificate option. Prefer installing a -valid certificate whenever possible. +Every DNS answer must pass the policy; one unsafe answer blocks the destination. Validated socket +addresses are pinned into a fresh outbound client that ignores system HTTP proxies. Every redirect +is resolved, revalidated, and pinned again, and HTTPS-to-HTTP downgrades are blocked. + +IPv4 link-local sources require the private/LAN exception, but known metadata addresses remain +blocked even with that exception. Resolver-supplied IPv6 link-local addresses require a nonzero +interface scope and retain that scope when pinned. Scoped IPv6 URL literals are not supported. +Meaningless scope and flow identifiers on non-link-local IPv6 addresses are normalized away. + +The only NAT64 prefix interpreted without network discovery is the well-known `64:ff9b::/96` +prefix. Network-specific prefixes are accepted only after strict discovery through the absolute DNS +name `ipv4only.arpa.` and recognition of both required `192.0.0.170` and `192.0.0.171` embeddings. +The full `64:ff9b:1::/48` reservation is not treated as an embedding rule without that discovery. +Successful and failed discovery results are cached briefly and bound to the current per-address +network-interface identity; an interface or address assignment change invalidates the cache. A +global or eligible ULA IPv6 destination that requires discovery fails closed while discovery is +unavailable. The well-known prefix remains independently classifiable. + +Exact current interface addresses and all registered listener sockets are checked in their native, +IPv4-mapped, and discovered NAT64 forms. Applications that call `build_router` but serve the router +on additional sockets must instead call `build_router_with_listeners` and provide every actual +listener address; otherwise the validator cannot identify those caller-owned sockets. + +Each server runtime creates a random sensitive hop marker and overwrites that internal header on +every outbound proxy hop. A matching marker returning to the application is rejected before `/proxy` +or non-proxy route handlers run. An outer CORS `OPTIONS` preflight remains an empty local response: it +does not dispatch upstream or consume proxy capacity. A reverse proxy that strips the hop marker, or +a separately constructed router with an independent runtime marker, remains a loop risk unless the +registered listener identity also blocks it. + +## Capacity and timeout limits + +- Active proxy requests: 64 globally and 16 per normalized client address. +- Playlist transformations: 8 globally and 4 per normalized client address, in addition to the + active-request limit. +- Upstream response headers and read-idle periods: 30 seconds per hop/period. +- A full downstream handoff slot: 120 seconds without consumption. +- Playlist collection and delivery each have fixed 120-second lifecycle deadlines; bounded blocking + rewrite work observes cooperative cancellation. +- A capacity rejection returns `503 Proxy capacity is exhausted` with `Retry-After: 1`. + +Ordinary media streams do not have a fixed total lifetime: continued downstream progress permits +long playback. Dropped, cancelled, idle, or stalled bodies release their producer-owned permits. +Playlist input and output are separately bounded, and rewrite work runs off the asynchronous runtime. + +## Redirects, headers, playlists, and browser isolation + +On a cross-origin redirect, Stream Server clears caller-supplied request headers, URL userinfo, and +`If-Range`; `Range` is retained for media/CDN compatibility. Each new origin still undergoes the full +destination and TLS policy. Credential-bearing or rewritten responses are forced to +`Cache-Control: private, no-store`. + +All proxy success and error responses receive route-owned active-content isolation headers, +including a restrictive sandboxed Content Security Policy, `nosniff`, `no-referrer`, and frame +denial. Custom response headers are narrowly validated and cannot replace these controls. + +Full `200` HLS playlists may be safely rewritten. An upstream `Cache-Control: no-transform`, a raw +`206 Partial Content` response, `HEAD`, or a non-success status stays on the unmodified streaming +path. Rewritten bodies remove stale length/range/encoding/validator metadata, disable ranges, and +use private non-storable caching. Raw `206` framing and validators are preserved because its bytes +are not transformed. + +These controls do not make a publicly reachable Stream Server a safe general-purpose application +proxy. Browser clients that can reach the listener can still request any destination allowed by the +current global policy and can consume server bandwidth and capacity. Keep the listener and firewall +exposure as narrow as your installation permits. ## Troubleshooting - `400 Invalid proxy request`: the URL/options are malformed, use an unsupported scheme, contain an unsafe custom header, or exceed an input limit. -- `403 Proxy destination is blocked`: the resolved address, a redirect, or the server's own listener - is denied. Enable private/LAN sources only if the destination is a trusted local media source. +- `403 Proxy destination is blocked`: an address, redirect, self-listener, metadata destination, or + returned hop marker is denied. Enable private/LAN sources only if the source and every reachable + browser/LAN client are trusted for this global exception. - HTTP `403` JSON from `POST /settings`: a protected value changed without a valid local token, or the request did not originate from loopback. - `502 Proxy upstream request failed`: DNS, TLS, connection, redirect, response encoding, playlist - size, or read timeout validation failed. A self-signed source may require the TLS opt-in. -- `503 Proxy capacity is exhausted`: 64 proxy requests are already active. Retry after the response's - `Retry-After` delay. + size, collection/rewrite, or timeout validation failed. A self-signed source may require the TLS + exception. +- `503 Proxy capacity is exhausted`: a global, per-client, or playlist quota is full. Retry after the + response's `Retry-After` delay. diff --git a/server/src/routes/system.rs b/server/src/routes/system.rs index 5e1dbcd..e06879e 100644 --- a/server/src/routes/system.rs +++ b/server/src/routes/system.rs @@ -923,6 +923,10 @@ pub async fn update_settings( .finish_reconfigure(proxy_policy); drop(published); + #[cfg(test)] + transaction_state + .settings_persistence + .record_post_persist_side_effects(); transaction_state .engine .update_torrent_settings(&new_profile, &new_privacy) @@ -1335,7 +1339,216 @@ pub async fn get_file_stats( #[cfg(test)] mod tests { use super::*; - use crate::settings_control::SettingsMutationAuthority; + use crate::settings_control::{ + SETTINGS_TOKEN_HEADER, SettingsControl, SettingsMutationAuthority, + }; + use enginefs::EngineFS; + use std::sync::Arc; + + #[tokio::test] + async fn real_settings_handler_uses_connect_info_token_and_ignores_forwarded_headers() { + async fn call( + state: &AppState, + peer: &str, + token: Option<&[u8]>, + forwarded_for: Option<&str>, + value: bool, + ) -> StatusCode { + let mut headers = HeaderMap::new(); + if let Some(token) = token { + headers.insert( + SETTINGS_TOKEN_HEADER, + axum::http::HeaderValue::from_bytes(token).unwrap(), + ); + } + if let Some(forwarded_for) = forwarded_for { + headers.insert( + "x-forwarded-for", + axum::http::HeaderValue::from_str(forwarded_for).unwrap(), + ); + headers.insert( + "forwarded", + axum::http::HeaderValue::from_str(&format!("for={forwarded_for}")).unwrap(), + ); + } + set_settings( + ConnectInfo(peer.parse().unwrap()), + State(state.clone()), + headers, + Json(json!({"allowPrivateNetworkSources": value})), + ) + .await + .status() + } + + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let mut state = AppState::new( + engine, + ServerSettings::default(), + temp.path().join("config"), + ); + let token = [b'a'; 64]; + state.settings_control = SettingsControl::for_test(token); + + for (peer, next) in [ + ("127.0.0.1:40000", true), + ("[::1]:40000", false), + ("[::ffff:127.0.0.1]:40000", true), + ] { + assert_eq!( + call(&state, peer, Some(&token), None, next).await, + StatusCode::OK + ); + } + assert_eq!( + call( + &state, + "127.0.0.1:40000", + Some(&token), + Some("192.168.1.50"), + false, + ) + .await, + StatusCode::OK, + "forwarded remote address must not override loopback ConnectInfo" + ); + + for (peer, token_value, forwarded) in [ + ("192.168.1.50:40000", Some(&token[..]), Some("127.0.0.1")), + ("[::ffff:192.168.1.50]:40000", Some(&token[..]), None), + ("127.0.0.1:40000", None, None), + ("127.0.0.1:40000", Some(&[b'b'; 64][..]), None), + ] { + assert_eq!( + call(&state, peer, token_value, forwarded, true).await, + StatusCode::FORBIDDEN, + "peer={peer}" + ); + } + assert_eq!( + call(&state, "192.168.1.50:40000", None, Some("127.0.0.1"), false,).await, + StatusCode::OK, + "unchanged protected values remain compatible for untrusted callers" + ); + assert!(!state.settings.read().await.allow_private_network_sources); + } + + #[tokio::test] + async fn persistence_failure_rolls_back_handler_policy_engine_and_tracker_state() { + let _engine_test_guard = crate::TEST_ENGINE_MUTEX.lock().await; + let temp = tempfile::tempdir().unwrap(); + let engine = Arc::new( + EngineFS::new(temp.path().join("engine"), Default::default()) + .await + .unwrap(), + ); + let mut state = AppState::new( + engine, + ServerSettings::default(), + temp.path().join("config"), + ); + let token = [b'a'; 64]; + state.settings_control = SettingsControl::for_test(token); + std::fs::create_dir_all(&state.settings_path).unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert( + SETTINGS_TOKEN_HEADER, + axum::http::HeaderValue::from_bytes(&token).unwrap(), + ); + let response = set_settings( + ConnectInfo("127.0.0.1:40000".parse().unwrap()), + State(state.clone()), + headers, + Json(json!({ + "allowPrivateNetworkSources": true, + "btMaxConnections": 321, + "btProxyPassword": "unique-secret-marker", + "cacheSize": 123.0, + "seedingEnabled": false, + })), + ) + .await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let outward = String::from_utf8( + axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap() + .to_vec(), + ) + .unwrap(); + assert_eq!( + outward, + r#"{"error":"settings could not be saved","success":false}"# + ); + assert!(!outward.contains("unique-secret-marker")); + assert!(!outward.contains(state.settings_path.to_string_lossy().as_ref())); + + let live = state.settings.read().await.clone(); + assert!(!live.allow_private_network_sources); + assert_eq!( + live.bt_max_connections, + ServerSettings::default().bt_max_connections + ); + assert_eq!(live.cache_size, ServerSettings::default().cache_size); + assert!(live.bt_proxy_password.is_empty()); + assert!(live.cached_trackers.is_empty()); + assert!(live.seeding_enabled); + let raw = state.settings_persistence.raw_snapshot().await; + assert!(!raw.allow_private_network_sources); + assert_eq!( + raw.bt_max_connections, + ServerSettings::default().bt_max_connections + ); + assert_eq!(raw.cache_size, ServerSettings::default().cache_size); + assert!(raw.bt_proxy_password.is_empty()); + assert!(raw.cached_trackers.is_empty()); + assert!(raw.seeding_enabled); + let request = state.proxy_runtime.try_request().unwrap(); + assert!(!request.settings.allow_private_network_sources); + assert!(state.engine.seeding_enabled()); + assert!(state.download_engine.seeding_enabled()); + assert_eq!( + state.settings_persistence.post_persist_side_effect_count(), + 0 + ); + + let bridge = crate::state::TrackerStorageBridge::new_with_persistence( + state.settings.clone(), + state.settings_path.clone(), + state.settings_persistence.clone(), + ); + assert!( + bridge + .save_trackers_with_completion( + vec!["udp://unique-tracker-secret".to_string()], + 123, + ) + .await + .unwrap() + .is_err() + ); + assert!(state.settings.read().await.cached_trackers.is_empty()); + assert!( + state + .settings_persistence + .raw_snapshot() + .await + .cached_trackers + .is_empty() + ); + assert_eq!( + state.settings_persistence.post_persist_side_effect_count(), + 0 + ); + assert!(state.settings_path.is_dir()); + } #[test] fn server_version_default_uses_crate_version() { diff --git a/server/src/settings_control.rs b/server/src/settings_control.rs index 48b6ee5..52c2b60 100644 --- a/server/src/settings_control.rs +++ b/server/src/settings_control.rs @@ -424,6 +424,47 @@ mod tests { assert_eq!(fs::read(path).unwrap(), oversized); } + #[cfg(unix)] + #[test] + fn unix_new_token_is_created_with_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + SettingsControl::load_or_create(temp.path()).unwrap(); + let mode = fs::metadata(temp.path().join("settings-control.token")) + .unwrap() + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + + #[cfg(windows)] + #[test] + fn windows_token_symlink_is_rejected_or_reports_missing_symlink_privilege() { + const ERROR_PRIVILEGE_NOT_HELD: i32 = 1314; + + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target.token"); + fs::write(&target, [b'a'; 64]).unwrap(); + let path = temp.path().join("settings-control.token"); + match std::os::windows::fs::symlink_file(&target, &path) { + Ok(()) => { + assert!(SettingsControl::load_or_create(temp.path()).is_err()); + assert_eq!(fs::read(target).unwrap(), [b'a'; 64]); + eprintln!("Windows token symlink regression executed"); + } + Err(error) => { + assert_eq!( + error.raw_os_error(), + Some(ERROR_PRIVILEGE_NOT_HELD), + "unexpected Windows symlink creation error: {error}" + ); + eprintln!("Windows token symlink regression skipped: ERROR_PRIVILEGE_NOT_HELD"); + } + } + } + #[cfg(unix)] #[test] fn unix_token_symlinks_fifos_and_broad_permissions_are_rejected() { diff --git a/server/src/state.rs b/server/src/state.rs index 83d46ca..5585a33 100644 --- a/server/src/state.rs +++ b/server/src/state.rs @@ -29,6 +29,8 @@ pub(crate) struct SettingsPersistenceCoordinator { fail_parent_sync: std::sync::atomic::AtomicBool, #[cfg(test)] before_final_side_effect_gate: std::sync::Mutex>>, + #[cfg(test)] + post_persist_side_effect_count: std::sync::atomic::AtomicUsize, } struct SettingsSupervisorState { @@ -130,6 +132,8 @@ impl SettingsPersistenceCoordinator { fail_parent_sync: std::sync::atomic::AtomicBool::new(false), #[cfg(test)] before_final_side_effect_gate: std::sync::Mutex::new(None), + #[cfg(test)] + post_persist_side_effect_count: std::sync::atomic::AtomicUsize::new(0), } } @@ -356,6 +360,18 @@ impl SettingsPersistenceCoordinator { self.active_transactions .load(std::sync::atomic::Ordering::Acquire) } + + #[cfg(test)] + pub(crate) fn record_post_persist_side_effects(&self) { + self.post_persist_side_effect_count + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + } + + #[cfg(test)] + pub(crate) fn post_persist_side_effect_count(&self) -> usize { + self.post_persist_side_effect_count + .load(std::sync::atomic::Ordering::Acquire) + } } #[derive(Clone)] diff --git a/settings-gui/ui/app.slint b/settings-gui/ui/app.slint index 55838d7..6bee7c3 100644 --- a/settings-gui/ui/app.slint +++ b/settings-gui/ui/app.slint @@ -362,13 +362,13 @@ export component AppWindow inherits Window { ToggleRow { label: "Validate HTTPS trackers"; checked <=> root.bt-validate-https-trackers; } ToggleRow { label: "Allow private/LAN proxy sources"; - description: "Lets /proxy reach devices and services on this computer or LAN. Known cloud metadata addresses remain blocked."; + description: "Global for every /proxy request. Allows eligible computer/LAN services; metadata stays blocked."; enabled: root.protected-settings-enabled; checked <=> root.allow-private-network-sources; } ToggleRow { label: "Allow invalid proxy TLS certificates"; - description: "Disables certificate verification for /proxy only. Enable only for a trusted self-signed source."; + description: "Global for every /proxy request. Disables certificate checks; enable only when required."; enabled: root.protected-settings-enabled; checked <=> root.allow-invalid-proxy-tls-certificates; } From 0a4d362749b2543c4d2ff3e016ee2ae13600f33b Mon Sep 17 00:00:00 2001 From: jahvari <75337667+jahvari@users.noreply.github.com> Date: Fri, 21 Aug 2026 14:51:04 -0400 Subject: [PATCH 25/25] test: serialize embedded proxy servers --- server/tests/proxy_security.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/tests/proxy_security.rs b/server/tests/proxy_security.rs index ea62b95..2aee983 100644 --- a/server/tests/proxy_security.rs +++ b/server/tests/proxy_security.rs @@ -15,6 +15,12 @@ use std::{ time::Duration, }; +static EMBEDDED_SERVER_TEST_MUTEX: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +async fn serialize_embedded_servers() -> tokio::sync::MutexGuard<'static, ()> { + EMBEDDED_SERVER_TEST_MUTEX.lock().await +} + async fn range(headers: HeaderMap) -> Response { let bytes = b"0123456789"; if headers @@ -148,6 +154,7 @@ fn proxy_url(server: std::net::SocketAddr, target: &str) -> String { #[tokio::test] async fn proxy_failure_bodies_never_expose_request_credentials() -> anyhow::Result<()> { + let _server_test_guard = serialize_embedded_servers().await; const SECRETS: &[&str] = &[ "parser-header-secret-9c30", "policy-user-secret-9c30", @@ -277,6 +284,7 @@ async fn proxy_failure_bodies_never_expose_request_credentials() -> anyhow::Resu #[tokio::test] async fn marker_preserving_reverse_proxy_cannot_reenter_application_routes() -> anyhow::Result<()> { + let _server_test_guard = serialize_embedded_servers().await; let server_address = Arc::new(Mutex::new(None::)); let fixture_server_address = server_address.clone(); let fixture_router = Router::new().route( @@ -369,6 +377,7 @@ async fn marker_preserving_reverse_proxy_cannot_reenter_application_routes() -> #[tokio::test] async fn managed_https_port_zero_reports_serves_and_blocks_the_exact_socket() -> anyhow::Result<()> { + let _server_test_guard = serialize_embedded_servers().await; let config = tempfile::tempdir()?; let cache = tempfile::tempdir()?; let config_dir = config.path().join("config"); @@ -431,6 +440,7 @@ async fn managed_https_port_zero_reports_serves_and_blocks_the_exact_socket() -> #[tokio::test] async fn failed_https_preparation_is_not_registered_and_http_remains_usable() -> anyhow::Result<()> { + let _server_test_guard = serialize_embedded_servers().await; let occupied_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; let occupied_address = occupied_listener.local_addr()?; let fixture = tokio::spawn(async move { @@ -491,6 +501,7 @@ async fn failed_https_preparation_is_not_registered_and_http_remains_usable() -> #[tokio::test] async fn malformed_https_pem_leaves_http_ready_without_a_stale_listener() -> anyhow::Result<()> { + let _server_test_guard = serialize_embedded_servers().await; let config = tempfile::tempdir()?; let cache = tempfile::tempdir()?; let config_dir = config.path().join("config"); @@ -523,6 +534,7 @@ async fn malformed_https_pem_leaves_http_ready_without_a_stale_listener() -> any #[tokio::test] async fn unreadable_https_pem_leaves_http_ready_without_a_stale_listener() -> anyhow::Result<()> { + let _server_test_guard = serialize_embedded_servers().await; let config = tempfile::tempdir()?; let cache = tempfile::tempdir()?; let config_dir = config.path().join("config"); @@ -560,6 +572,7 @@ async fn unreadable_https_pem_leaves_http_ready_without_a_stale_listener() -> an #[tokio::test] async fn normal_encoded_core_path_form_reaches_destination_policy() -> anyhow::Result<()> { + let _server_test_guard = serialize_embedded_servers().await; let config = tempfile::tempdir()?; let cache = tempfile::tempdir()?; let server_config = stream_server::ServerConfig { @@ -599,6 +612,7 @@ async fn normal_encoded_core_path_form_reaches_destination_policy() -> anyhow::R #[tokio::test] async fn default_deny_protected_opt_in_and_cancellation_work_end_to_end() -> anyhow::Result<()> { + let _server_test_guard = serialize_embedded_servers().await; let (fixture_addr, fixture_task) = start_fixture().await; let (tls_fixture_addr, tls_fixture_task) = start_tls_fixture().await?; let config = tempfile::tempdir()?;