Skip to content

Commit c9c7115

Browse files
Ludv1gLbfops
andauthored
fix(client-api): parse single-IP X-Forwarded-For values (#4839)
## Summary `XForwardedFor::decode` requires a comma and fails to decode `X-Forwarded-For: 1.2.3.4`. Under axum 0.8 this now surfaces as HTTP 400 on every single-hop-proxy request. ## Fix Accept comma-separated or single-IP values — take the first entry either way. ## Reproducer Any browser going through a proxy that does `.insert("x-forwarded-for", client_ip)` (standard single-hop pattern) sees 400 Bad Request on WebSocket subscribe. ## Version regression Bug latent since the axum migration in 2023-06 (commit b4dae74). axum 0.7 silently dropped the decoder error via `Option<TypedHeader<T>>`; axum 0.8 (#2713) promotes it to a 400 rejection. Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com>
1 parent 92bd4bb commit c9c7115

1 file changed

Lines changed: 42 additions & 2 deletions

File tree

crates/client-api/src/util.rs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,10 @@ impl headers::Header for XForwardedFor {
4949
fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, headers::Error> {
5050
let val = values.next().ok_or_else(headers::Error::invalid)?;
5151
let val = val.to_str().map_err(|_| headers::Error::invalid())?;
52-
let (first, _) = val.split_once(',').ok_or_else(headers::Error::invalid)?;
53-
let ip = first.trim().parse().map_err(|_| headers::Error::invalid())?;
52+
// X-Forwarded-For is a comma-separated chain. For a single-hop
53+
// proxy there is no comma; take the first IP either way.
54+
let first = val.split(',').next().unwrap_or(val).trim();
55+
let ip = first.parse().map_err(|_| headers::Error::invalid())?;
5456
Ok(XForwardedFor(ip))
5557
}
5658

@@ -185,3 +187,41 @@ impl<S> FromRequest<S> for EmptyBody {
185187
Ok(Self)
186188
}
187189
}
190+
191+
#[cfg(test)]
192+
mod tests {
193+
use super::*;
194+
use headers::Header;
195+
196+
fn decode_one(raw: &str) -> Result<XForwardedFor, headers::Error> {
197+
let val = HeaderValue::from_str(raw).unwrap();
198+
let values = [val];
199+
XForwardedFor::decode(&mut values.iter())
200+
}
201+
202+
#[test]
203+
fn decodes_single_ip() {
204+
// Single-hop proxies (e.g. nginx inserting the client IP) emit
205+
// a value with no comma. This must succeed.
206+
let got = decode_one("10.0.0.1").expect("single IP should decode");
207+
assert_eq!(got.0, "10.0.0.1".parse::<IpAddr>().unwrap());
208+
}
209+
210+
#[test]
211+
fn decodes_chain_takes_first() {
212+
let got = decode_one("10.0.0.1, 192.168.1.1, 172.16.0.1").expect("chain should decode");
213+
assert_eq!(got.0, "10.0.0.1".parse::<IpAddr>().unwrap());
214+
}
215+
216+
#[test]
217+
fn decodes_chain_trims_whitespace() {
218+
let got = decode_one(" 10.0.0.1 , 192.168.1.1").expect("chain should decode");
219+
assert_eq!(got.0, "10.0.0.1".parse::<IpAddr>().unwrap());
220+
}
221+
222+
#[test]
223+
fn rejects_non_ip() {
224+
assert!(decode_one("not-an-ip").is_err());
225+
assert!(decode_one("not-an-ip, 10.0.0.1").is_err());
226+
}
227+
}

0 commit comments

Comments
 (0)