This repository has no description
12 kB
308 lines
1use std::collections::BTreeSet;
2use std::convert::Infallible;
3use std::net::{IpAddr, Ipv6Addr, SocketAddr};
4use std::str::FromStr;
5
6use ipnet::IpNet;
7
8pub const MAX_HOPS: usize = 32;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11struct ProxyNet(IpNet);
12
13#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14pub 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
21impl 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
33impl 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
49fn 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
59fn 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
73pub 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)]
83pub struct TrustedProxies(BTreeSet<ProxyNet>);
84
85impl 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)]
123mod 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}