This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

crates/trusted-proxies: shared proxy allowlist w/ CIDR & chain travarsal

Lewis: May this revision serve well! <did:plc:3fwecdnvtcscjnrx2p4n7alz>

author did:plc:3fwecdnvtcscjnrx2p4n7a… committer
Tangled
date (Jul 31, 2026, 1:38 PM UTC) commit 7e3a9323 parent 7c7539ad change-id wlkzkvky
+330 -1
+8
Cargo.lock
··· 8374 8374 ] 8375 8375 8376 8376 [[package]] 8377 + name = "trusted-proxies" 8378 + version = "0.0.1" 8379 + dependencies = [ 8380 + "ipnet", 8381 + "thiserror 2.0.18", 8382 + ] 8383 + 8384 + [[package]] 8377 8385 name = "try-lock" 8378 8386 version = "0.2.5" 8379 8387 source = "registry+https://github.com/rust-lang/crates.io-index"
+4 -1
Cargo.toml
··· 1 1 [workspace] 2 2 resolver = "3" 3 - members = ["bobbin/crates/*", "shuttle", "knot2/crates/*"] 3 + members = ["bobbin/crates/*", "crates/*", "shuttle", "knot2/crates/*"] 4 4 exclude = ["sites"] 5 5 6 6 [workspace.package] ··· 28 28 print_stderr = "warn" 29 29 30 30 [workspace.dependencies] 31 + trusted-proxies = { path = "crates/trusted-proxies" } 32 + 31 33 bobbin-types = { path = "bobbin/crates/types" } 32 34 bobbin-edge-index = { path = "bobbin/crates/edge-index" } 33 35 bobbin-ingest = { path = "bobbin/crates/ingest" } ··· 124 126 http = "1" 125 127 http-body = "1" 126 128 httpdate = "1" 129 + ipnet = "2.10" 127 130 128 131 rustls = { version = "0.23", features = ["aws_lc_rs", "prefer-post-quantum"] } 129 132 tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12", "logging"] }
+10
crates/trusted-proxies/Cargo.toml
··· 1 + [package] 2 + name = "trusted-proxies" 3 + version.workspace = true 4 + edition.workspace = true 5 + license.workspace = true 6 + rust-version.workspace = true 7 + 8 + [dependencies] 9 + ipnet = { workspace = true } 10 + thiserror = { workspace = true }
+308
crates/trusted-proxies/src/lib.rs
··· 1 + use std::collections::BTreeSet; 2 + use std::convert::Infallible; 3 + use std::net::{IpAddr, Ipv6Addr, SocketAddr}; 4 + use std::str::FromStr; 5 + 6 + use ipnet::IpNet; 7 + 8 + pub const MAX_HOPS: usize = 32; 9 + 10 + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 11 + struct ProxyNet(IpNet); 12 + 13 + #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] 14 + pub enum ProxyNetError { 15 + #[error("`{0}` isn't an IP address or a CIDR block")] 16 + Unreadable(String), 17 + #[error("a blank entry isn't an IP address or a CIDR block, so drop it or fill it in")] 18 + Blank, 19 + } 20 + 21 + impl ProxyNet { 22 + fn contains(self, peer: IpAddr) -> bool { 23 + match peer.to_canonical() { 24 + IpAddr::V4(v4) => { 25 + self.0.contains(&IpAddr::V4(v4)) 26 + || self.0.contains(&IpAddr::V6(v4.to_ipv6_mapped())) 27 + } 28 + v6 => self.0.contains(&v6), 29 + } 30 + } 31 + } 32 + 33 + impl FromStr for ProxyNet { 34 + type Err = ProxyNetError; 35 + 36 + fn from_str(raw: &str) -> Result<Self, Self::Err> { 37 + match raw.trim() { 38 + "" => Err(ProxyNetError::Blank), 39 + entry => entry 40 + .parse::<IpNet>() 41 + .ok() 42 + .or_else(|| entry.parse::<IpAddr>().ok().map(IpNet::from)) 43 + .map(|net| Self(canonical(net))) 44 + .ok_or_else(|| ProxyNetError::Unreadable(entry.to_owned())), 45 + } 46 + } 47 + } 48 + 49 + fn canonical(net: IpNet) -> IpNet { 50 + match net { 51 + IpNet::V6(v6) => match (v6.network().to_canonical(), v6.prefix_len()) { 52 + (v4 @ IpAddr::V4(_), len @ 96..) => IpNet::new_assert(v4, len - 96), 53 + _ => net, 54 + }, 55 + IpNet::V4(_) => net, 56 + } 57 + } 58 + 59 + fn chain_address(entry: &str) -> Option<IpAddr> { 60 + entry 61 + .parse::<IpAddr>() 62 + .ok() 63 + .or_else(|| entry.parse::<SocketAddr>().ok().map(|hop| hop.ip())) 64 + .or_else(|| { 65 + entry 66 + .strip_prefix('[') 67 + .and_then(|rest| rest.strip_suffix(']')) 68 + .and_then(|inner| inner.parse::<Ipv6Addr>().ok()) 69 + .map(IpAddr::V6) 70 + }) 71 + } 72 + 73 + pub fn comma_separated(raw: &str) -> Result<Vec<String>, Infallible> { 74 + Ok(raw 75 + .split(',') 76 + .map(str::trim) 77 + .filter(|entry| !entry.is_empty()) 78 + .map(str::to_owned) 79 + .collect()) 80 + } 81 + 82 + #[derive(Debug, Clone, Default, PartialEq, Eq)] 83 + pub struct TrustedProxies(BTreeSet<ProxyNet>); 84 + 85 + impl TrustedProxies { 86 + pub fn parse<'a>(entries: impl IntoIterator<Item = &'a str>) -> Result<Self, ProxyNetError> { 87 + entries 88 + .into_iter() 89 + .map(ProxyNet::from_str) 90 + .collect::<Result<BTreeSet<_>, _>>() 91 + .map(Self) 92 + } 93 + 94 + pub fn is_empty(&self) -> bool { 95 + self.0.is_empty() 96 + } 97 + 98 + pub fn contains(&self, peer: IpAddr) -> bool { 99 + self.0.iter().any(|net| net.contains(peer)) 100 + } 101 + 102 + pub fn rightmost_untrusted<'a, I>(&self, chain: I) -> Option<IpAddr> 103 + where 104 + I: IntoIterator<Item = &'a str>, 105 + I::IntoIter: DoubleEndedIterator, 106 + { 107 + chain 108 + .into_iter() 109 + .rev() 110 + .map(str::trim) 111 + .filter(|entry| !entry.is_empty()) 112 + .take(MAX_HOPS) 113 + .find_map(|entry| match chain_address(entry) { 114 + Some(address) if self.contains(address) => None, 115 + parsed => Some(parsed), 116 + }) 117 + .flatten() 118 + .map(|address| address.to_canonical()) 119 + } 120 + } 121 + 122 + #[cfg(test)] 123 + mod tests { 124 + use super::*; 125 + 126 + fn ip(value: &str) -> IpAddr { 127 + value.parse().unwrap() 128 + } 129 + 130 + fn trusting<'a>(entries: impl IntoIterator<Item = &'a str>) -> TrustedProxies { 131 + TrustedProxies::parse(entries).unwrap() 132 + } 133 + 134 + fn client(proxies: &TrustedProxies, chain: &str) -> Option<IpAddr> { 135 + proxies.rightmost_untrusted(chain.split(',')) 136 + } 137 + 138 + #[test] 139 + fn rightmost_untrusted_takes_the_rightmost_entry_with_an_empty_list() { 140 + let anyone = TrustedProxies::default(); 141 + [ 142 + ("203.0.113.7, 198.51.100.4", Some("198.51.100.4")), 143 + (" 192.0.2.1 ", Some("192.0.2.1")), 144 + ("not-an-ip", None), 145 + ("", None), 146 + ] 147 + .iter() 148 + .for_each(|&(chain, expected)| { 149 + assert_eq!(client(&anyone, chain), expected.map(ip), "{chain:?}"); 150 + }); 151 + } 152 + 153 + #[test] 154 + fn rightmost_untrusted_steps_over_every_listed_hop_and_stops_where_it_cant_parse() { 155 + let proxies = trusting(["198.51.100.4"]); 156 + [ 157 + ( 158 + "203.0.113.7, 198.51.100.4", 159 + Some("203.0.113.7"), 160 + "the hop left of the listed proxy is the client", 161 + ), 162 + ( 163 + "203.0.113.7, 198.51.100.4, 198.51.100.4", 164 + Some("203.0.113.7"), 165 + "two listed hops in a row are both stepped over", 166 + ), 167 + ( 168 + "203.0.113.7, 192.0.2.1, 198.51.100.4", 169 + Some("192.0.2.1"), 170 + "rightmost_untrusted stops at the first unlisted hop, even with a client still further left", 171 + ), 172 + ( 173 + "198.51.100.4", 174 + None, 175 + "rightmost_untrusted won't find a client in a chain of listed hops alone", 176 + ), 177 + ( 178 + "203.0.113.7, not-an-ip, 198.51.100.4", 179 + None, 180 + "rightmost_untrusted stops at an entry it can't parse, because a client can write anything left of the proxy", 181 + ), 182 + ( 183 + "203.0.113.7, 198.51.100.4,", 184 + Some("203.0.113.7"), 185 + "a stray comma mustn't cost the header, since a client can't claim anything through a blank", 186 + ), 187 + ( 188 + ",203.0.113.7,, 198.51.100.4", 189 + Some("203.0.113.7"), 190 + "a blank anywhere else in the chain reads the same way", 191 + ), 192 + ( 193 + "203.0.113.7:4321, 198.51.100.4", 194 + Some("203.0.113.7"), 195 + "some proxies write the hop with the port it connected from", 196 + ), 197 + ( 198 + "[2001:db8::1]:443, 198.51.100.4", 199 + Some("2001:db8::1"), 200 + "and bracket an IPv6 hop when they do", 201 + ), 202 + ( 203 + "[2001:db8::1], 198.51.100.4", 204 + Some("2001:db8::1"), 205 + "brackets turn up without a port too", 206 + ), 207 + ( 208 + "2001:db8::1, 198.51.100.4", 209 + Some("2001:db8::1"), 210 + "a bare IPv6 hop doesn't need unwrapping", 211 + ), 212 + ] 213 + .iter() 214 + .for_each(|&(chain, expected, why)| { 215 + assert_eq!(client(&proxies, chain), expected.map(ip), "{why}: {chain:?}"); 216 + }); 217 + assert_eq!( 218 + client( 219 + &trusting(["198.51.100.0/24"]), 220 + "203.0.113.7, 198.51.100.9, 198.51.100.10" 221 + ), 222 + Some(ip("203.0.113.7")), 223 + "one CIDR entry covers every hop inside the block" 224 + ); 225 + } 226 + 227 + #[test] 228 + fn rightmost_untrusted_reads_only_the_last_max_hops_entries() { 229 + let proxies = trusting(["198.51.100.4"]); 230 + let padded = |hops| { 231 + std::iter::once("203.0.113.7") 232 + .chain(std::iter::repeat_n("198.51.100.4", hops)) 233 + .collect::<Vec<_>>() 234 + .join(",") 235 + }; 236 + assert_eq!( 237 + client(&proxies, &padded(MAX_HOPS - 1)), 238 + Some(ip("203.0.113.7")), 239 + "a chain within MAX_HOPS still reaches the client behind every listed hop" 240 + ); 241 + assert_eq!( 242 + client(&proxies, &padded(MAX_HOPS)), 243 + None, 244 + "the peer answers for the address it connected from when the last MAX_HOPS entries are all listed hops" 245 + ); 246 + } 247 + 248 + #[test] 249 + fn an_entry_covers_every_address_it_spans_in_either_spelling() { 250 + [ 251 + (&["198.51.100.0/24"][..], "198.51.100.4", true, "a CIDR entry covers the addresses inside it"), 252 + (&["198.51.100.0/24"], "198.51.100.255", true, "up to the last address in the block"), 253 + (&["198.51.100.0/24"], "198.51.101.1", false, "and stops at the block boundary"), 254 + (&["2001:db8::/32"], "2001:db8::dead:beef", true, "an IPv6 block reads the same way"), 255 + (&["2001:db8::/32"], "2001:db9::1", false, "and stops at its boundary too"), 256 + (&["2001:db8::/32"], "198.51.100.4", false, "an IPv6 block that isn't v4-mapped won't cover an IPv4 address"), 257 + (&["::ffff:198.51.100.0/120"], "198.51.100.4", true, "a v4-mapped block covers the plain v4 addresses inside it"), 258 + (&["::ffff:198.51.100.0/120"], "::ffff:198.51.100.4", true, "in either spelling"), 259 + (&["::ffff:198.51.100.0/120"], "198.51.101.4", false, "and stops at the folded block boundary"), 260 + (&["::ffff:0:0/96"], "203.0.113.7", true, "the whole v4-mapped range folds to every IPv4 address"), 261 + (&["::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"), 262 + (&["::/0"], "203.0.113.7", true, "an operator who lists every IPv6 address has listed every v4-mapped address with it"), 263 + (&["127.0.0.1", " ::1 "], "127.0.0.1", true, "a bare address is a single host, and its entry may be padded"), 264 + (&["127.0.0.1", " ::1 "], "::ffff:127.0.0.1", true, "which a dual-stack listener may report mapped"), 265 + (&["127.0.0.1", " ::1 "], "::1", true, "the IPv6 loopback is its own entry"), 266 + (&["127.0.0.1", " ::1 "], "127.0.0.2", false, "and the host next door is outside all of them"), 267 + (&[], "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"), 268 + ] 269 + .iter() 270 + .for_each(|&(entries, peer, expected, why)| { 271 + assert_eq!( 272 + trusting(entries.iter().copied()).contains(ip(peer)), 273 + expected, 274 + "{why}: {entries:?} against {peer}" 275 + ); 276 + }); 277 + assert!(TrustedProxies::default().is_empty()); 278 + } 279 + 280 + #[test] 281 + fn parse_refuses_an_entry_it_cant_read_and_quotes_it() { 282 + assert_eq!( 283 + TrustedProxies::parse(["127.0.0.1:5555"]) 284 + .unwrap_err() 285 + .to_string(), 286 + "`127.0.0.1:5555` isn't an IP address or a CIDR block", 287 + ); 288 + [vec![""], vec![" "], vec!["127.0.0.1", "\t"]] 289 + .into_iter() 290 + .for_each(|entries| { 291 + assert_eq!( 292 + TrustedProxies::parse(entries.iter().copied()), 293 + Err(ProxyNetError::Blank), 294 + "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:?}" 295 + ); 296 + }); 297 + } 298 + 299 + #[test] 300 + fn comma_separated_discards_the_gaps_a_separator_leaves_behind() { 301 + assert_eq!( 302 + comma_separated("127.0.0.1, 173.245.48.0/20,").unwrap(), 303 + vec!["127.0.0.1".to_owned(), "173.245.48.0/20".to_owned()], 304 + "comma_separated mustn't leave a blank for parse to refuse, since a trailing separator belongs to the format" 305 + ); 306 + assert!(comma_separated("").unwrap().is_empty()); 307 + } 308 + }