1use std::net::IpAddr;
54use std::str::FromStr;
55
56use crate::ip_gate::{canonical, parse_network_entry};
57
58pub const UNKNOWN_CLIENT_IDENTITY: &str = "unknown";
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ClientIpSource {
66 Connecting,
68 ForwardedChain,
70 Unknown,
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
78pub struct ChainWarnings {
79 pub chain_too_short: bool,
82 pub depth_overcounts: Option<usize>,
86 pub selected_entry_trusted: bool,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct ClientIpExtraction {
95 pub client_ip: String,
98 pub source: ClientIpSource,
100 pub untrusted_forwarded_header: bool,
104 pub warnings: ChainWarnings,
107}
108
109#[must_use]
111fn strip_ip_brackets(value: &str) -> &str {
112 value
113 .strip_prefix('[')
114 .and_then(|rest| rest.strip_suffix(']'))
115 .unwrap_or(value)
116}
117
118#[must_use]
121pub fn strip_forwarded_entry_port(value: &str) -> &str {
122 if let Some(rest) = value.strip_prefix('[') {
123 let Some(closing) = rest.find(']') else {
124 return value;
125 };
126 let remainder = &rest[closing + 1..];
127 let port_form = remainder.len() > 1
128 && remainder.starts_with(':')
129 && remainder[1..].chars().all(|c| c.is_ascii_digit());
130 if remainder.is_empty() || port_form {
131 return &rest[..closing];
132 }
133 return value;
134 }
135 match value.split_once(':') {
136 Some((host, port))
137 if !host.contains(':')
138 && !port.is_empty()
139 && port.chars().all(|c| c.is_ascii_digit()) =>
140 {
141 host
142 }
143 _ => value,
144 }
145}
146
147#[must_use]
150pub fn canonicalize_ip(value: &str) -> String {
151 IpAddr::from_str(strip_ip_brackets(value)).map_or_else(|_| value.to_owned(), canonical_ip_text)
152}
153
154#[must_use]
156pub fn canonical_ip_text(addr: IpAddr) -> String {
157 match addr {
158 IpAddr::V6(v6) => v6
159 .to_ipv4_mapped()
160 .map_or_else(|| v6.to_string(), |mapped| mapped.to_string()),
161 IpAddr::V4(v4) => v4.to_string(),
162 }
163}
164
165#[must_use]
167pub fn candidate_has_metachar(candidate: &str) -> bool {
168 candidate
169 .chars()
170 .any(|c| matches!(c, '*' | '?' | '[' | ']' | '\\'))
171}
172
173#[must_use]
175pub fn forwarded_header_ips(forwarded_for: &str) -> Vec<String> {
176 forwarded_for
177 .split(',')
178 .map(|entry| strip_forwarded_entry_port(entry.trim()).to_owned())
179 .collect()
180}
181
182#[must_use]
186pub fn is_trusted_proxy(connecting: &str, trusted_proxies: &[String]) -> bool {
187 trusted_proxies.iter().any(|proxy| {
188 parse_network_entry(proxy).map_or_else(
189 || connecting == proxy,
190 |network| {
191 IpAddr::from_str(connecting)
192 .is_ok_and(|addr| network.contains(canonical(addr)) || network.contains(addr))
193 },
194 )
195 })
196}
197
198#[must_use]
200fn candidate_addr(candidate: &str) -> Option<IpAddr> {
201 IpAddr::from_str(candidate).ok()
202}
203
204#[must_use]
206fn right_side_unlisted_count(
207 ips: &[String],
208 proxy_depth: u32,
209 trusted_proxies: &[String],
210) -> usize {
211 let depth = usize::try_from(proxy_depth).unwrap_or(ips.len());
212 let start = ips.len().saturating_sub(depth.saturating_sub(1));
213 ips[start..]
214 .iter()
215 .filter(|entry| !is_trusted_proxy(&canonicalize_ip(entry), trusted_proxies))
216 .count()
217}
218
219#[must_use]
223fn resolve_chain_right_to_left(ips: &[String], trusted_proxies: &[String]) -> Option<String> {
224 ips.iter()
225 .rev()
226 .find(|entry| !is_trusted_proxy(&canonicalize_ip(entry), trusted_proxies))
227 .and_then(|entry| {
228 if candidate_has_metachar(entry) {
233 return None;
234 }
235 candidate_addr(entry).map(|_| canonicalize_ip(entry))
236 })
237}
238
239#[must_use]
243pub fn resolve_client_ip_from_forwarded_chain(
244 canonical_connecting: &str,
245 forwarded_for: Option<&str>,
246 proxy_depth: u32,
247 trusted_proxies: &[String],
248) -> ClientIpExtraction {
249 let mut warnings = ChainWarnings::default();
250 let Some(forwarded_for) = forwarded_for.filter(|value| !value.is_empty()) else {
251 return ClientIpExtraction {
252 client_ip: canonical_connecting.to_owned(),
253 source: ClientIpSource::Connecting,
254 untrusted_forwarded_header: false,
255 warnings,
256 };
257 };
258
259 let ips = forwarded_header_ips(forwarded_for);
260 let chain_length = ips.len();
261
262 let depth = usize::try_from(proxy_depth).unwrap_or(0);
263 if depth == 0 || chain_length < depth {
264 warnings.chain_too_short = true;
265 return ClientIpExtraction {
266 client_ip: canonical_connecting.to_owned(),
267 source: ClientIpSource::Connecting,
268 untrusted_forwarded_header: false,
269 warnings,
270 };
271 }
272
273 if !trusted_proxies.is_empty() {
274 let unlisted = right_side_unlisted_count(&ips, proxy_depth, trusted_proxies);
275 if unlisted > 0 {
276 warnings.depth_overcounts = Some(unlisted);
277 let Some(resolved) = resolve_chain_right_to_left(&ips, trusted_proxies) else {
278 return ClientIpExtraction {
279 client_ip: canonical_connecting.to_owned(),
280 source: ClientIpSource::Connecting,
281 untrusted_forwarded_header: false,
282 warnings,
283 };
284 };
285 return ClientIpExtraction {
286 client_ip: resolved,
287 source: ClientIpSource::ForwardedChain,
288 untrusted_forwarded_header: false,
289 warnings,
290 };
291 }
292 }
293
294 let candidate = ips[chain_length - depth].as_str();
296 if candidate_has_metachar(candidate) || candidate_addr(candidate).is_none() {
299 return ClientIpExtraction {
300 client_ip: canonical_connecting.to_owned(),
301 source: ClientIpSource::Connecting,
302 untrusted_forwarded_header: false,
303 warnings,
304 };
305 }
306
307 let client_ip = canonicalize_ip(candidate);
308 warnings.selected_entry_trusted = is_trusted_proxy(&client_ip, trusted_proxies);
309 ClientIpExtraction {
310 client_ip,
311 source: ClientIpSource::ForwardedChain,
312 untrusted_forwarded_header: false,
313 warnings,
314 }
315}
316
317#[must_use]
321pub fn extract_client_ip(
322 client_host: Option<&str>,
323 forwarded_for: Option<&str>,
324 trusted_proxies: &[String],
325 proxy_depth: u32,
326) -> ClientIpExtraction {
327 let Some(client_host) = client_host else {
328 if trusted_proxies.iter().any(|proxy| proxy == "unix") {
331 let mut extraction = resolve_client_ip_from_forwarded_chain(
332 UNKNOWN_CLIENT_IDENTITY,
333 forwarded_for,
334 proxy_depth,
335 trusted_proxies,
336 );
337 if extraction.source == ClientIpSource::Connecting {
338 UNKNOWN_CLIENT_IDENTITY.clone_into(&mut extraction.client_ip);
339 extraction.source = ClientIpSource::Unknown;
340 }
341 return extraction;
342 }
343 return ClientIpExtraction {
344 client_ip: UNKNOWN_CLIENT_IDENTITY.to_owned(),
345 source: ClientIpSource::Unknown,
346 untrusted_forwarded_header: false,
347 warnings: ChainWarnings::default(),
348 };
349 };
350
351 let canonical_connecting = canonicalize_ip(client_host);
352
353 if trusted_proxies.is_empty() {
354 return ClientIpExtraction {
356 client_ip: canonical_connecting,
357 source: ClientIpSource::Connecting,
358 untrusted_forwarded_header: forwarded_for.is_some_and(|value| !value.is_empty()),
359 warnings: ChainWarnings::default(),
360 };
361 }
362
363 if !is_trusted_proxy(&canonical_connecting, trusted_proxies) {
364 return ClientIpExtraction {
365 client_ip: canonical_connecting,
366 source: ClientIpSource::Connecting,
367 untrusted_forwarded_header: forwarded_for.is_some_and(|value| !value.is_empty()),
368 warnings: ChainWarnings::default(),
369 };
370 }
371
372 resolve_client_ip_from_forwarded_chain(
373 &canonical_connecting,
374 forwarded_for,
375 proxy_depth,
376 trusted_proxies,
377 )
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 fn proxies(entries: &[&str]) -> Vec<String> {
385 entries.iter().map(ToString::to_string).collect()
386 }
387
388 #[test]
389 fn the_depth_selects_the_entry_from_the_right() {
390 let extraction = extract_client_ip(
392 Some("10.0.0.1"),
393 Some("203.0.113.7"),
394 &proxies(&["10.0.0.0/8"]),
395 1,
396 );
397 assert_eq!(extraction.client_ip, "203.0.113.7");
398 assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
399 assert!(!extraction.warnings.selected_entry_trusted);
400
401 let extraction = extract_client_ip(
404 Some("10.0.0.1"),
405 Some("203.0.113.7, 198.51.100.4, 10.0.0.1"),
406 &proxies(&["10.0.0.0/8", "198.51.100.0/24"]),
407 3,
408 );
409 assert_eq!(extraction.client_ip, "203.0.113.7");
410
411 let extraction = extract_client_ip(
415 Some("10.0.0.1"),
416 Some("203.0.113.7, 10.0.0.1"),
417 &proxies(&["10.0.0.0/8"]),
418 1,
419 );
420 assert_eq!(extraction.client_ip, "10.0.0.1");
421 assert!(extraction.warnings.selected_entry_trusted);
422 }
423
424 #[test]
425 fn a_short_chain_falls_back_to_the_connecting_peer() {
426 let extraction = extract_client_ip(
427 Some("10.0.0.1"),
428 Some("203.0.113.7"),
429 &proxies(&["10.0.0.0/8"]),
430 3,
431 );
432 assert_eq!(extraction.client_ip, "10.0.0.1");
433 assert_eq!(extraction.source, ClientIpSource::Connecting);
434 assert!(extraction.warnings.chain_too_short);
435 }
436
437 #[test]
438 fn an_over_counting_depth_walks_right_to_left() {
439 let extraction = extract_client_ip(
443 Some("10.0.0.1"),
444 Some("203.0.113.7, 198.51.100.4, 10.0.0.1"),
445 &proxies(&["10.0.0.0/8"]),
446 3,
447 );
448 assert_eq!(extraction.client_ip, "198.51.100.4");
449 assert_eq!(extraction.warnings.depth_overcounts, Some(1));
450
451 let extraction = extract_client_ip(
453 Some("10.0.0.1"),
454 Some("10.0.0.2, 10.0.0.1"),
455 &proxies(&["10.0.0.0/8"]),
456 1,
457 );
458 assert_eq!(extraction.client_ip, "10.0.0.1");
459 assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
460 assert_eq!(extraction.warnings.depth_overcounts, None);
461 }
462
463 #[test]
464 fn unparseable_and_metachar_candidates_degrade_to_connecting() {
465 let extraction = extract_client_ip(
467 Some("10.0.0.1"),
468 Some("not-an-ip"),
469 &proxies(&["10.0.0.0/8"]),
470 1,
471 );
472 assert_eq!(extraction.client_ip, "10.0.0.1");
473
474 let extraction = extract_client_ip(
476 Some("10.0.0.1"),
477 Some("*.evil.example"),
478 &proxies(&["10.0.0.0/8"]),
479 1,
480 );
481 assert_eq!(extraction.client_ip, "10.0.0.1");
482
483 let extraction = extract_client_ip(
487 Some("10.0.0.1"),
488 Some("203.0.113.7, *.evil.example, 10.0.0.1"),
489 &proxies(&["10.0.0.0/8"]),
490 3,
491 );
492 assert_eq!(extraction.client_ip, "10.0.0.1");
493 assert_eq!(extraction.warnings.depth_overcounts, Some(1));
494 }
495
496 #[test]
497 fn a_selected_trusted_entry_warns() {
498 let extraction = extract_client_ip(
499 Some("10.0.0.1"),
500 Some("10.0.0.9, 10.0.0.1"),
501 &proxies(&["10.0.0.0/8"]),
502 2,
503 );
504 assert_eq!(extraction.client_ip, "10.0.0.9");
505 assert!(extraction.warnings.selected_entry_trusted);
506 }
507
508 #[test]
509 fn untrusted_peers_and_absent_proxies_ignore_the_header() {
510 let extraction = extract_client_ip(
511 Some("192.0.2.9"),
512 Some("203.0.113.7"),
513 &proxies(&["10.0.0.0/8"]),
514 1,
515 );
516 assert_eq!(extraction.client_ip, "192.0.2.9");
517 assert_eq!(extraction.source, ClientIpSource::Connecting);
518 assert!(extraction.untrusted_forwarded_header);
519
520 let extraction = extract_client_ip(Some("192.0.2.9"), Some("203.0.113.7"), &[], 1);
521 assert_eq!(extraction.client_ip, "192.0.2.9");
522 assert!(extraction.untrusted_forwarded_header);
523
524 let extraction = extract_client_ip(Some("192.0.2.9"), None, &proxies(&["10.0.0.0/8"]), 1);
526 assert!(!extraction.untrusted_forwarded_header);
527 }
528
529 #[test]
530 fn canonicalization_strips_brackets_ports_and_v4_mapping() {
531 assert_eq!(
532 strip_forwarded_entry_port("[2001:db8::1]:443"),
533 "2001:db8::1"
534 );
535 assert_eq!(strip_forwarded_entry_port("2001:db8::1"), "2001:db8::1");
536 assert_eq!(
537 strip_forwarded_entry_port("203.0.113.7:8443"),
538 "203.0.113.7"
539 );
540 assert_eq!(strip_forwarded_entry_port("203.0.113.7"), "203.0.113.7");
541 assert_eq!(strip_forwarded_entry_port("2001:db8::1"), "2001:db8::1");
543 assert_eq!(canonicalize_ip("[2001:db8::1]"), "2001:db8::1");
544 assert_eq!(canonicalize_ip("::ffff:203.0.113.7"), "203.0.113.7");
545 assert_eq!(canonicalize_ip("not-an-ip"), "not-an-ip");
546 }
547
548 #[test]
549 fn the_unknown_arm_walks_only_for_unix_proxies() {
550 let extraction = extract_client_ip(None, Some("203.0.113.7"), &proxies(&["unix"]), 1);
551 assert_eq!(extraction.client_ip, "203.0.113.7");
552 assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
553
554 let extraction = extract_client_ip(None, Some("203.0.113.7"), &[], 1);
555 assert_eq!(extraction.client_ip, UNKNOWN_CLIENT_IDENTITY);
556 assert_eq!(extraction.source, ClientIpSource::Unknown);
557
558 let extraction = extract_client_ip(None, None, &proxies(&["unix"]), 1);
560 assert_eq!(extraction.client_ip, UNKNOWN_CLIENT_IDENTITY);
561 assert_eq!(extraction.source, ClientIpSource::Unknown);
562 }
563
564 #[test]
565 fn malformed_bracket_entries_pass_through_untouched() {
566 assert_eq!(strip_forwarded_entry_port("[2001:db8::1"), "[2001:db8::1");
568 assert_eq!(
571 strip_forwarded_entry_port("[2001:db8::1]junk"),
572 "[2001:db8::1]junk"
573 );
574 assert_eq!(strip_forwarded_entry_port("[2001:db8::1]"), "2001:db8::1");
576 assert_eq!(
577 strip_forwarded_entry_port("[2001:db8::1]:443"),
578 "2001:db8::1"
579 );
580 }
581
582 #[test]
583 fn an_empty_trusted_list_still_walks_at_the_declared_depth() {
584 let extraction =
587 resolve_client_ip_from_forwarded_chain("10.0.0.1", Some("203.0.113.7"), 1, &[]);
588 assert_eq!(extraction.client_ip, "203.0.113.7");
589 assert_eq!(extraction.source, ClientIpSource::ForwardedChain);
590 assert_eq!(extraction.warnings, ChainWarnings::default());
591 }
592
593 #[test]
594 fn an_unparseable_rightmost_untrusted_entry_ends_the_walk() {
595 let extraction = extract_client_ip(
598 Some("10.0.0.1"),
599 Some("203.0.113.7, not-an-ip, 10.0.0.1"),
600 &proxies(&["10.0.0.0/8"]),
601 3,
602 );
603 assert_eq!(extraction.client_ip, "10.0.0.1");
604 assert_eq!(extraction.warnings.depth_overcounts, Some(1));
605 }
606
607 #[test]
608 fn the_unix_token_never_matches_a_real_peer() {
609 assert!(!is_trusted_proxy("127.0.0.1", &proxies(&["unix"])));
610 assert!(is_trusted_proxy(
611 "10.1.2.3",
612 &proxies(&["unix", "10.0.0.0/8"])
613 ));
614 assert!(is_trusted_proxy("10.1.2.3", &proxies(&["10.1.2.3"])));
615 }
616}