Skip to content

Commit 2da2fc6

Browse files
authored
Merge pull request #7359 from Turbo87/xff-madness
Implement `X-Forwarded-For` header processing
2 parents e874a24 + 1bf0e2d commit 2da2fc6

File tree

3 files changed

+319
-4
lines changed

3 files changed

+319
-4
lines changed

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ pub mod auth;
5959
pub mod controllers;
6060
mod licenses;
6161
pub mod models;
62+
mod real_ip;
6263
mod router;
6364
pub mod sentry;
6465
pub mod storage;

src/middleware/log_request.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
use crate::controllers::util::RequestPartsExt;
55
use crate::headers::{XRealIp, XRequestId};
66
use crate::middleware::normalize_path::OriginalPath;
7+
use crate::real_ip::process_xff_headers;
78
use axum::headers::UserAgent;
89
use axum::middleware::Next;
910
use axum::response::IntoResponse;
1011
use axum::{Extension, TypedHeader};
1112
use http::{Method, Request, StatusCode, Uri};
1213
use parking_lot::Mutex;
1314
use std::fmt::{self, Display, Formatter};
15+
use std::net::IpAddr;
1416
use std::ops::Deref;
1517
use std::sync::Arc;
1618
use std::time::{Duration, Instant};
@@ -39,6 +41,7 @@ pub struct Metadata<'a> {
3941
cause: Option<&'a CauseField>,
4042
error: Option<&'a ErrorField>,
4143
duration: Duration,
44+
real_ip: Option<IpAddr>,
4245
custom_metadata: RequestLog,
4346
}
4447

@@ -71,10 +74,19 @@ impl Display for Metadata<'_> {
7174
};
7275
}
7376

74-
match &self.request.real_ip {
75-
Some(header) => line.add_quoted_field("fwd", header.as_str())?,
76-
None => line.add_quoted_field("fwd", "")?,
77-
};
77+
let real_ip = self.real_ip.map(|ip| ip.to_string()).unwrap_or_default();
78+
line.add_quoted_field("ip", &real_ip)?;
79+
80+
let x_real_ip = self.request.real_ip.as_ref();
81+
let x_real_ip = x_real_ip
82+
.map(|ip| ip.as_str().to_string())
83+
.unwrap_or_default();
84+
line.add_quoted_field("fwd", &x_real_ip)?;
85+
86+
// TODO: Remove this once production traffic has shown that `ip == fwd`
87+
if real_ip != x_real_ip {
88+
line.add_marker("ip!=fwd")?;
89+
}
7890

7991
let response_time_in_ms = self.duration.as_millis();
8092
if !is_download_redirect || response_time_in_ms > 0 {
@@ -122,6 +134,8 @@ pub async fn log_requests<B>(
122134
let custom_metadata = RequestLog::default();
123135
req.extensions_mut().insert(custom_metadata.clone());
124136

137+
let real_ip = process_xff_headers(req.headers());
138+
125139
let response = next.run(req).await;
126140

127141
let metadata = Metadata {
@@ -130,6 +144,7 @@ pub async fn log_requests<B>(
130144
cause: response.extensions().get(),
131145
error: response.extensions().get(),
132146
duration: start_instant.elapsed(),
147+
real_ip,
133148
custom_metadata,
134149
};
135150

src/real_ip.rs

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
use http::{HeaderMap, HeaderValue};
2+
use ipnetwork::IpNetwork;
3+
use once_cell::sync::Lazy;
4+
use std::iter::Iterator;
5+
use std::net::IpAddr;
6+
use std::str::from_utf8;
7+
8+
const X_FORWARDED_FOR: &str = "X-Forwarded-For";
9+
10+
const CLOUD_FRONT_STRS: &[&str] = &[
11+
// CloudFront IP addresses from http://d7uri8nf7uskq.cloudfront.net/tools/list-cloudfront-ips
12+
// Last updated: 2022-03-26
13+
"3.10.17.128/25",
14+
"3.11.53.0/24",
15+
"3.35.130.128/25",
16+
"3.101.158.0/23",
17+
"3.128.93.0/24",
18+
"3.134.215.0/24",
19+
"3.231.2.0/25",
20+
"3.234.232.224/27",
21+
"3.236.48.0/23",
22+
"3.236.169.192/26",
23+
"13.32.0.0/15",
24+
"13.35.0.0/16",
25+
"13.48.32.0/24",
26+
"13.54.63.128/26",
27+
"13.59.250.0/26",
28+
"13.113.196.64/26",
29+
"13.113.203.0/24",
30+
"13.124.199.0/24",
31+
"13.210.67.128/26",
32+
"13.224.0.0/14",
33+
"13.228.69.0/24",
34+
"13.233.177.192/26",
35+
"13.249.0.0/16",
36+
"15.158.0.0/16",
37+
"15.188.184.0/24",
38+
"15.207.13.128/25",
39+
"15.207.213.128/25",
40+
"18.64.0.0/14",
41+
"18.154.0.0/15",
42+
"18.160.0.0/15",
43+
"18.164.0.0/15",
44+
"18.172.0.0/15",
45+
"18.192.142.0/23",
46+
"18.200.212.0/23",
47+
"18.216.170.128/25",
48+
"18.229.220.192/26",
49+
"18.238.0.0/15",
50+
"18.244.0.0/15",
51+
"34.195.252.0/24",
52+
"34.216.51.0/25",
53+
"34.223.12.224/27",
54+
"34.223.80.192/26",
55+
"34.226.14.0/24",
56+
"35.158.136.0/24",
57+
"35.162.63.192/26",
58+
"35.167.191.128/26",
59+
"36.103.232.0/25",
60+
"36.103.232.128/26",
61+
"44.227.178.0/24",
62+
"44.234.90.252/30",
63+
"44.234.108.128/25",
64+
"52.15.127.128/26",
65+
"52.46.0.0/18",
66+
"52.47.139.0/24",
67+
"52.52.191.128/26",
68+
"52.56.127.0/25",
69+
"52.57.254.0/24",
70+
"52.66.194.128/26",
71+
"52.78.247.128/26",
72+
"52.82.128.0/19",
73+
"52.84.0.0/15",
74+
"52.124.128.0/17",
75+
"52.199.127.192/26",
76+
"52.212.248.0/26",
77+
"52.220.191.0/26",
78+
"52.222.128.0/17",
79+
"54.182.0.0/16",
80+
"54.192.0.0/16",
81+
"54.230.0.0/17",
82+
"54.230.128.0/18",
83+
"54.230.200.0/21",
84+
"54.230.208.0/20",
85+
"54.230.224.0/19",
86+
"54.233.255.128/26",
87+
"54.239.128.0/18",
88+
"54.239.192.0/19",
89+
"54.240.128.0/18",
90+
"58.254.138.0/25",
91+
"58.254.138.128/26",
92+
"64.252.64.0/18",
93+
"64.252.128.0/18",
94+
"65.8.0.0/16",
95+
"65.9.0.0/17",
96+
"65.9.128.0/18",
97+
"70.132.0.0/18",
98+
"71.152.0.0/17",
99+
"99.79.169.0/24",
100+
"99.84.0.0/16",
101+
"99.86.0.0/16",
102+
"108.138.0.0/15",
103+
"108.156.0.0/14",
104+
"116.129.226.0/25",
105+
"116.129.226.128/26",
106+
"118.193.97.64/26",
107+
"118.193.97.128/25",
108+
"119.147.182.0/25",
109+
"119.147.182.128/26",
110+
"120.52.12.64/26",
111+
"120.52.22.96/27",
112+
"120.52.39.128/27",
113+
"120.52.153.192/26",
114+
"120.232.236.0/25",
115+
"120.232.236.128/26",
116+
"120.253.240.192/26",
117+
"120.253.241.160/27",
118+
"120.253.245.128/26",
119+
"120.253.245.192/27",
120+
"130.176.0.0/17",
121+
"130.176.128.0/18",
122+
"130.176.192.0/19",
123+
"130.176.224.0/20",
124+
"143.204.0.0/16",
125+
"144.220.0.0/16",
126+
"180.163.57.0/25",
127+
"180.163.57.128/26",
128+
"204.246.164.0/22",
129+
"204.246.168.0/22",
130+
"204.246.172.0/24",
131+
"204.246.173.0/24",
132+
"204.246.174.0/23",
133+
"204.246.176.0/20",
134+
"205.251.200.0/21",
135+
"205.251.208.0/20",
136+
"205.251.249.0/24",
137+
"205.251.250.0/23",
138+
"205.251.252.0/23",
139+
"205.251.254.0/24",
140+
"216.137.32.0/19",
141+
"223.71.11.0/27",
142+
"223.71.71.96/27",
143+
"223.71.71.128/25",
144+
];
145+
146+
static CLOUD_FRONT_NETWORKS: Lazy<Vec<IpNetwork>> = Lazy::new(|| {
147+
CLOUD_FRONT_STRS
148+
.iter()
149+
.map(|s| s.parse().unwrap())
150+
.collect()
151+
});
152+
153+
fn is_cloud_front_ip(ip: &IpAddr) -> bool {
154+
CLOUD_FRONT_NETWORKS
155+
.iter()
156+
.any(|trusted_proxy| trusted_proxy.contains(*ip))
157+
}
158+
159+
pub fn process_xff_headers(headers: &HeaderMap) -> Option<IpAddr> {
160+
let mut xff_iter = headers.get_all(X_FORWARDED_FOR).iter();
161+
let first_header = xff_iter.next()?;
162+
163+
let has_more_headers = xff_iter.next().is_some();
164+
return if has_more_headers {
165+
// This only happens for requests going directly to crates-io.herokuapp.com,
166+
// since AWS CloudFront automatically merges these headers into one.
167+
//
168+
// The Heroku router has a bug where it currently (2023-10-25) appends
169+
// the connecting IP to the **first** header instead of the last.
170+
//
171+
// In this specific scenario we will read the IP from the first header,
172+
// instead of the last, to work around the Heroku bug. We also don't
173+
// have to care about the trusted proxies, since the request was
174+
// apparently sent to Heroku directly.
175+
176+
parse_xff_header(first_header)
177+
.into_iter()
178+
.filter_map(|r| r.ok())
179+
.next_back()
180+
} else {
181+
// If the request came in through CloudFront we only get a single,
182+
// merged header.
183+
//
184+
// If the request came in through Heroku and only had a single header
185+
// originally, then we also only get a single header.
186+
//
187+
// In this case return the right-most IP address that is not in the list
188+
// of IPs from trusted proxies (i.e. CloudFront).
189+
190+
parse_xff_header(first_header)
191+
.into_iter()
192+
.filter_map(|r| r.ok())
193+
.filter(|ip| !is_cloud_front_ip(ip))
194+
.next_back()
195+
};
196+
}
197+
198+
/// Parses the content of an `X-Forwarded-For` header into a
199+
/// `Vec<Result<IpAddr, &[u8]>>`.
200+
fn parse_xff_header(header: &HeaderValue) -> Vec<Result<IpAddr, &[u8]>> {
201+
let bytes = header.as_bytes();
202+
if bytes.is_empty() {
203+
return vec![];
204+
}
205+
206+
bytes
207+
.split(|&byte| byte == b',')
208+
.map(|bytes| parse_ip_addr(bytes))
209+
.collect()
210+
}
211+
212+
fn parse_ip_addr(bytes: &[u8]) -> Result<IpAddr, &[u8]> {
213+
from_utf8(bytes)
214+
.map_err(|_| bytes)?
215+
.trim()
216+
.parse()
217+
.map_err(|_| bytes)
218+
}
219+
220+
#[cfg(test)]
221+
mod tests {
222+
use super::*;
223+
use http::HeaderValue;
224+
225+
#[test]
226+
fn test_process_xff_headers() {
227+
#[track_caller]
228+
fn test(input: Vec<&[u8]>, expectation: Option<&str>) {
229+
let mut headers = HeaderMap::new();
230+
for value in input {
231+
let value = HeaderValue::from_bytes(value).unwrap();
232+
headers.append(X_FORWARDED_FOR, value);
233+
}
234+
235+
let expectation: Option<IpAddr> = expectation.map(|ip| ip.parse().unwrap());
236+
237+
assert_eq!(process_xff_headers(&headers), expectation)
238+
}
239+
240+
// Generic behavior
241+
test(vec![], None);
242+
test(vec![b""], None);
243+
test(vec![b"1.1.1.1"], Some("1.1.1.1"));
244+
test(vec![b"1.1.1.1, 2.2.2.2"], Some("2.2.2.2"));
245+
test(vec![b"1.1.1.1, 2.2.2.2, 3.3.3.3"], Some("3.3.3.3"));
246+
test(
247+
vec![b"oh, hi,,127.0.0.1,,,,, 12.34.56.78 "],
248+
Some("12.34.56.78"),
249+
);
250+
251+
// CloudFront behavior
252+
test(vec![b"130.176.118.147"], None);
253+
test(vec![b"1.1.1.1, 130.176.118.147"], Some("1.1.1.1"));
254+
test(vec![b"1.1.1.1, 2.2.2.2, 130.176.118.147"], Some("2.2.2.2"));
255+
256+
// Heroku workaround
257+
test(vec![b"1.1.1.1, 2.2.2.2", b"3.3.3.3"], Some("2.2.2.2"));
258+
test(
259+
vec![b"1.1.1.1, 130.176.118.147", b"3.3.3.3"],
260+
Some("130.176.118.147"),
261+
);
262+
}
263+
264+
#[test]
265+
fn test_parse_xff_header() {
266+
#[track_caller]
267+
fn test(input: &'static [u8], expectation: Vec<Result<&str, &[u8]>>) {
268+
let header = HeaderValue::from_bytes(input).unwrap();
269+
270+
let expectation: Vec<Result<IpAddr, &[u8]>> = expectation
271+
.into_iter()
272+
.map(|ip| ip.map(|ip| ip.parse().unwrap()))
273+
.collect();
274+
275+
assert_eq!(parse_xff_header(&header), expectation)
276+
}
277+
278+
test(b"", vec![]);
279+
test(b"1.2.3.4", vec![Ok("1.2.3.4")]);
280+
test(
281+
b"1.2.3.4, 11.22.33.44",
282+
vec![Ok("1.2.3.4"), Ok("11.22.33.44")],
283+
);
284+
test(
285+
b"oh, hi,,127.0.0.1,,,,, 12.34.56.78 ",
286+
vec![
287+
Err(b"oh"),
288+
Err(b" hi"),
289+
Err(b""),
290+
Ok("127.0.0.1"),
291+
Err(b""),
292+
Err(b""),
293+
Err(b""),
294+
Err(b""),
295+
Ok("12.34.56.78"),
296+
],
297+
);
298+
}
299+
}

0 commit comments

Comments
 (0)