This repository has no description
1use std::convert::Infallible;
2use std::net::{IpAddr, SocketAddr};
3use std::sync::OnceLock;
4
5use axum::extract::{ConnectInfo, FromRequestParts};
6use axum::http::request::Parts;
7use axum::http::{HeaderMap, HeaderName, HeaderValue};
8use trusted_proxies::TrustedProxies;
9
10pub(crate) static X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for");
11
12#[derive(Default)]
13pub struct ClientAddress {
14 proxies: TrustedProxies,
15 ignored_header: OnceLock<IpAddr>,
16 no_socket: OnceLock<()>,
17}
18
19impl ClientAddress {
20 pub fn new(proxies: TrustedProxies) -> Self {
21 Self {
22 proxies,
23 ..Self::default()
24 }
25 }
26
27 pub(crate) fn of(&self, headers: &HeaderMap, socket: SocketPeer) -> Option<HeaderValue> {
28 match socket.0 {
29 None => {
30 if self.no_socket.set(()).is_ok() {
31 tracing::warn!(
32 "bobbin won't forward a client address to the knot for this request, and every client will share one rate-limit bucket there, because bobbin doesn't have a socket address for it. Serve the listener with `into_make_service_with_connect_info`. This warning reports the first such request only."
33 );
34 }
35 None
36 }
37 Some(peer) => {
38 let relays = self.proxies.contains(peer);
39 if headers.contains_key(&X_FORWARDED_FOR)
40 && !relays
41 && self.ignored_header.set(peer).is_ok()
42 {
43 tracing::warn!(
44 %peer,
45 "bobbin ignored x-forwarded-for and will forward the address this peer connected from, because the peer is outside server.trusted_proxies. Add this address to server.trusted_proxies if it's the reverse proxy, or every client it serves will share one rate-limit bucket on each knot. This warning reports the first such peer only."
46 );
47 }
48 let client = relays
49 .then(|| {
50 self.proxies.rightmost_untrusted(
51 headers
52 .get_all(&X_FORWARDED_FOR)
53 .iter()
54 .filter_map(|value| value.to_str().ok())
55 .flat_map(|value| value.split(',')),
56 )
57 })
58 .flatten()
59 .unwrap_or_else(|| peer.to_canonical());
60 HeaderValue::try_from(client.to_string()).ok()
61 }
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy)]
67pub struct SocketPeer(Option<IpAddr>);
68
69impl<S: Send + Sync> FromRequestParts<S> for SocketPeer {
70 type Rejection = Infallible;
71
72 async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
73 Ok(Self(
74 parts
75 .extensions
76 .get::<ConnectInfo<SocketAddr>>()
77 .map(|info| info.0.ip()),
78 ))
79 }
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85
86 fn ip(value: &str) -> IpAddr {
87 value.parse().unwrap()
88 }
89
90 fn relaying<'a>(entries: impl IntoIterator<Item = &'a str>) -> ClientAddress {
91 ClientAddress::new(TrustedProxies::parse(entries).unwrap())
92 }
93
94 fn chain(value: Option<&str>) -> HeaderMap {
95 value
96 .map(|value| {
97 let mut map = HeaderMap::new();
98 map.insert(&X_FORWARDED_FOR, value.parse().unwrap());
99 map
100 })
101 .unwrap_or_default()
102 }
103
104 fn forwarded(proxies: &[&str], socket: Option<&str>, claimed: Option<&str>) -> Option<String> {
105 relaying(proxies.iter().copied())
106 .of(&chain(claimed), SocketPeer(socket.map(ip)))
107 .map(|value| value.to_str().unwrap().to_owned())
108 }
109
110 #[test]
111 fn bobbin_forwards_the_socket_unless_a_listed_proxy_relayed_the_request() {
112 let listed: &[&str] = &["127.0.0.1", "173.245.48.0/20"];
113 [
114 (&["127.0.0.1"][..], Some("203.0.113.7"), Some("198.51.100.4"), Some("203.0.113.7"),
115 "bobbin must answer for the socket, since a client reaching it directly wrote that header itself"),
116 (&[], Some("203.0.113.7"), Some("198.51.100.4"), Some("203.0.113.7"),
117 "an operator who hasn't configured a proxy will get the socket, since honoring the header by default would hand every client its own rate-limit bucket on every knot downstream. The knot reads its own empty list the opposite way, as trusting every peer, so don't carry either default across"),
118 (listed, Some("127.0.0.1"), Some("198.51.100.4"), Some("198.51.100.4"),
119 "a listed proxy hands over the address it recorded"),
120 (listed, Some("127.0.0.1"), Some("198.51.100.4, 173.245.48.9"), Some("198.51.100.4"),
121 "and a second listed hop is stepped over with it"),
122 (listed, Some("127.0.0.1"), Some(" 198.51.100.4 "), Some("198.51.100.4"),
123 "padding around an entry won't hide it"),
124 (listed, Some("127.0.0.1"), Some("203.0.113.7, 198.51.100.4"), Some("198.51.100.4"),
125 "bobbin takes the rightmost unlisted hop"),
126 (listed, Some("127.0.0.1"), None, Some("127.0.0.1"),
127 "a listed proxy that didn't send the header leaves its own socket to forward"),
128 (listed, Some("127.0.0.1"), Some("not-an-ip"), Some("127.0.0.1"),
129 "bobbin stops at an entry it can't parse and forwards the socket, because a client can write anything left of the proxy"),
130 (listed, Some("127.0.0.1"), Some("127.0.0.1"), Some("127.0.0.1"),
131 "a chain of listed hops alone leaves the proxy's socket too"),
132 (&["173.245.48.0/20"], Some("173.245.48.9"), Some("198.51.100.4, 203.0.113.7"), Some("203.0.113.7"),
133 "bobbin stops before reaching anything a client wrote, since the proxy appends the address it saw to the right of all of it"),
134 (&["127.0.0.1"], Some("::ffff:203.0.113.7"), None, Some("203.0.113.7"),
135 "a v4-mapped socket and the plain address are one client"),
136 (&["127.0.0.1"], Some("::ffff:127.0.0.1"), Some("::ffff:198.51.100.4"), Some("198.51.100.4"),
137 "both spellings must key to one bucket, since a dual-stack listener reports the mapped form on both sides"),
138 (&["127.0.0.1"], None, Some("198.51.100.4"), None,
139 "bobbin won't identify a client it hasn't seen connect, since it can't check the header against anything"),
140 ]
141 .iter()
142 .for_each(|&(proxies, socket, claimed, expected, why)| {
143 assert_eq!(
144 forwarded(proxies, socket, claimed).as_deref(),
145 expected,
146 "{why}: {proxies:?} saw {socket:?} claiming {claimed:?}"
147 );
148 });
149 }
150}