diff --git a/Cargo.lock b/Cargo.lock index 6e1f68b9..b3225dfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8373,6 +8373,14 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "trusted-proxies" +version = "0.0.1" +dependencies = [ + "ipnet", + "thiserror 2.0.18", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/Cargo.toml b/Cargo.toml index 16156f51..7f4c63fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "3" -members = ["bobbin/crates/*", "shuttle", "knot2/crates/*"] +members = ["bobbin/crates/*", "crates/*", "shuttle", "knot2/crates/*"] exclude = ["sites"] [workspace.package] @@ -28,6 +28,8 @@ print_stdout = "deny" print_stderr = "warn" [workspace.dependencies] +trusted-proxies = { path = "crates/trusted-proxies" } + bobbin-types = { path = "bobbin/crates/types" } bobbin-edge-index = { path = "bobbin/crates/edge-index" } bobbin-ingest = { path = "bobbin/crates/ingest" } @@ -124,6 +126,7 @@ governor = "0.10" http = "1" http-body = "1" httpdate = "1" +ipnet = "2.10" rustls = { version = "0.23", features = ["aws_lc_rs", "prefer-post-quantum"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12", "logging"] } diff --git a/crates/trusted-proxies/Cargo.toml b/crates/trusted-proxies/Cargo.toml new file mode 100644 index 00000000..9a510a2a --- /dev/null +++ b/crates/trusted-proxies/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "trusted-proxies" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +ipnet = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/trusted-proxies/src/lib.rs b/crates/trusted-proxies/src/lib.rs new file mode 100644 index 00000000..1b305c4f --- /dev/null +++ b/crates/trusted-proxies/src/lib.rs @@ -0,0 +1,308 @@ +use std::collections::BTreeSet; +use std::convert::Infallible; +use std::net::{IpAddr, Ipv6Addr, SocketAddr}; +use std::str::FromStr; + +use ipnet::IpNet; + +pub const MAX_HOPS: usize = 32; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +struct ProxyNet(IpNet); + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ProxyNetError { + #[error("`{0}` isn't an IP address or a CIDR block")] + Unreadable(String), + #[error("a blank entry isn't an IP address or a CIDR block, so drop it or fill it in")] + Blank, +} + +impl ProxyNet { + fn contains(self, peer: IpAddr) -> bool { + match peer.to_canonical() { + IpAddr::V4(v4) => { + self.0.contains(&IpAddr::V4(v4)) + || self.0.contains(&IpAddr::V6(v4.to_ipv6_mapped())) + } + v6 => self.0.contains(&v6), + } + } +} + +impl FromStr for ProxyNet { + type Err = ProxyNetError; + + fn from_str(raw: &str) -> Result { + match raw.trim() { + "" => Err(ProxyNetError::Blank), + entry => entry + .parse::() + .ok() + .or_else(|| entry.parse::().ok().map(IpNet::from)) + .map(|net| Self(canonical(net))) + .ok_or_else(|| ProxyNetError::Unreadable(entry.to_owned())), + } + } +} + +fn canonical(net: IpNet) -> IpNet { + match net { + IpNet::V6(v6) => match (v6.network().to_canonical(), v6.prefix_len()) { + (v4 @ IpAddr::V4(_), len @ 96..) => IpNet::new_assert(v4, len - 96), + _ => net, + }, + IpNet::V4(_) => net, + } +} + +fn chain_address(entry: &str) -> Option { + entry + .parse::() + .ok() + .or_else(|| entry.parse::().ok().map(|hop| hop.ip())) + .or_else(|| { + entry + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .and_then(|inner| inner.parse::().ok()) + .map(IpAddr::V6) + }) +} + +pub fn comma_separated(raw: &str) -> Result, Infallible> { + Ok(raw + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_owned) + .collect()) +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct TrustedProxies(BTreeSet); + +impl TrustedProxies { + pub fn parse<'a>(entries: impl IntoIterator) -> Result { + entries + .into_iter() + .map(ProxyNet::from_str) + .collect::, _>>() + .map(Self) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn contains(&self, peer: IpAddr) -> bool { + self.0.iter().any(|net| net.contains(peer)) + } + + pub fn rightmost_untrusted<'a, I>(&self, chain: I) -> Option + where + I: IntoIterator, + I::IntoIter: DoubleEndedIterator, + { + chain + .into_iter() + .rev() + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .take(MAX_HOPS) + .find_map(|entry| match chain_address(entry) { + Some(address) if self.contains(address) => None, + parsed => Some(parsed), + }) + .flatten() + .map(|address| address.to_canonical()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(value: &str) -> IpAddr { + value.parse().unwrap() + } + + fn trusting<'a>(entries: impl IntoIterator) -> TrustedProxies { + TrustedProxies::parse(entries).unwrap() + } + + fn client(proxies: &TrustedProxies, chain: &str) -> Option { + proxies.rightmost_untrusted(chain.split(',')) + } + + #[test] + fn rightmost_untrusted_takes_the_rightmost_entry_with_an_empty_list() { + let anyone = TrustedProxies::default(); + [ + ("203.0.113.7, 198.51.100.4", Some("198.51.100.4")), + (" 192.0.2.1 ", Some("192.0.2.1")), + ("not-an-ip", None), + ("", None), + ] + .iter() + .for_each(|&(chain, expected)| { + assert_eq!(client(&anyone, chain), expected.map(ip), "{chain:?}"); + }); + } + + #[test] + fn rightmost_untrusted_steps_over_every_listed_hop_and_stops_where_it_cant_parse() { + let proxies = trusting(["198.51.100.4"]); + [ + ( + "203.0.113.7, 198.51.100.4", + Some("203.0.113.7"), + "the hop left of the listed proxy is the client", + ), + ( + "203.0.113.7, 198.51.100.4, 198.51.100.4", + Some("203.0.113.7"), + "two listed hops in a row are both stepped over", + ), + ( + "203.0.113.7, 192.0.2.1, 198.51.100.4", + Some("192.0.2.1"), + "rightmost_untrusted stops at the first unlisted hop, even with a client still further left", + ), + ( + "198.51.100.4", + None, + "rightmost_untrusted won't find a client in a chain of listed hops alone", + ), + ( + "203.0.113.7, not-an-ip, 198.51.100.4", + None, + "rightmost_untrusted stops at an entry it can't parse, because a client can write anything left of the proxy", + ), + ( + "203.0.113.7, 198.51.100.4,", + Some("203.0.113.7"), + "a stray comma mustn't cost the header, since a client can't claim anything through a blank", + ), + ( + ",203.0.113.7,, 198.51.100.4", + Some("203.0.113.7"), + "a blank anywhere else in the chain reads the same way", + ), + ( + "203.0.113.7:4321, 198.51.100.4", + Some("203.0.113.7"), + "some proxies write the hop with the port it connected from", + ), + ( + "[2001:db8::1]:443, 198.51.100.4", + Some("2001:db8::1"), + "and bracket an IPv6 hop when they do", + ), + ( + "[2001:db8::1], 198.51.100.4", + Some("2001:db8::1"), + "brackets turn up without a port too", + ), + ( + "2001:db8::1, 198.51.100.4", + Some("2001:db8::1"), + "a bare IPv6 hop doesn't need unwrapping", + ), + ] + .iter() + .for_each(|&(chain, expected, why)| { + assert_eq!(client(&proxies, chain), expected.map(ip), "{why}: {chain:?}"); + }); + assert_eq!( + client( + &trusting(["198.51.100.0/24"]), + "203.0.113.7, 198.51.100.9, 198.51.100.10" + ), + Some(ip("203.0.113.7")), + "one CIDR entry covers every hop inside the block" + ); + } + + #[test] + fn rightmost_untrusted_reads_only_the_last_max_hops_entries() { + let proxies = trusting(["198.51.100.4"]); + let padded = |hops| { + std::iter::once("203.0.113.7") + .chain(std::iter::repeat_n("198.51.100.4", hops)) + .collect::>() + .join(",") + }; + assert_eq!( + client(&proxies, &padded(MAX_HOPS - 1)), + Some(ip("203.0.113.7")), + "a chain within MAX_HOPS still reaches the client behind every listed hop" + ); + assert_eq!( + client(&proxies, &padded(MAX_HOPS)), + None, + "the peer answers for the address it connected from when the last MAX_HOPS entries are all listed hops" + ); + } + + #[test] + fn an_entry_covers_every_address_it_spans_in_either_spelling() { + [ + (&["198.51.100.0/24"][..], "198.51.100.4", true, "a CIDR entry covers the addresses inside it"), + (&["198.51.100.0/24"], "198.51.100.255", true, "up to the last address in the block"), + (&["198.51.100.0/24"], "198.51.101.1", false, "and stops at the block boundary"), + (&["2001:db8::/32"], "2001:db8::dead:beef", true, "an IPv6 block reads the same way"), + (&["2001:db8::/32"], "2001:db9::1", false, "and stops at its boundary too"), + (&["2001:db8::/32"], "198.51.100.4", false, "an IPv6 block that isn't v4-mapped won't cover an IPv4 address"), + (&["::ffff:198.51.100.0/120"], "198.51.100.4", true, "a v4-mapped block covers the plain v4 addresses inside it"), + (&["::ffff:198.51.100.0/120"], "::ffff:198.51.100.4", true, "in either spelling"), + (&["::ffff:198.51.100.0/120"], "198.51.101.4", false, "and stops at the folded block boundary"), + (&["::ffff:0:0/96"], "203.0.113.7", true, "the whole v4-mapped range folds to every IPv4 address"), + (&["::ffff:0:0/95"], "203.0.113.7", true, "the match has to try the mapped spelling too, because a prefix under 96 keeps the block in IPv6, where the plain v4 spelling of a peer would miss it"), + (&["::/0"], "203.0.113.7", true, "an operator who lists every IPv6 address has listed every v4-mapped address with it"), + (&["127.0.0.1", " ::1 "], "127.0.0.1", true, "a bare address is a single host, and its entry may be padded"), + (&["127.0.0.1", " ::1 "], "::ffff:127.0.0.1", true, "which a dual-stack listener may report mapped"), + (&["127.0.0.1", " ::1 "], "::1", true, "the IPv6 loopback is its own entry"), + (&["127.0.0.1", " ::1 "], "127.0.0.2", false, "and the host next door is outside all of them"), + (&[], "203.0.113.7", false, "a caller that reads an empty list as trusting every peer has to say so itself, since rightmost_untrusted would step over every entry and never find a client"), + ] + .iter() + .for_each(|&(entries, peer, expected, why)| { + assert_eq!( + trusting(entries.iter().copied()).contains(ip(peer)), + expected, + "{why}: {entries:?} against {peer}" + ); + }); + assert!(TrustedProxies::default().is_empty()); + } + + #[test] + fn parse_refuses_an_entry_it_cant_read_and_quotes_it() { + assert_eq!( + TrustedProxies::parse(["127.0.0.1:5555"]) + .unwrap_err() + .to_string(), + "`127.0.0.1:5555` isn't an IP address or a CIDR block", + ); + [vec![""], vec![" "], vec!["127.0.0.1", "\t"]] + .into_iter() + .for_each(|entries| { + assert_eq!( + TrustedProxies::parse(entries.iter().copied()), + Err(ProxyNetError::Blank), + "discarding the blank would leave a list the operator filled in reading as empty. A caller is free to read an empty list as trusting every peer: {entries:?}" + ); + }); + } + + #[test] + fn comma_separated_discards_the_gaps_a_separator_leaves_behind() { + assert_eq!( + comma_separated("127.0.0.1, 173.245.48.0/20,").unwrap(), + vec!["127.0.0.1".to_owned(), "173.245.48.0/20".to_owned()], + "comma_separated mustn't leave a blank for parse to refuse, since a trailing separator belongs to the format" + ); + assert!(comma_separated("").unwrap().is_empty()); + } +}