Skip to content

Commit 060ca9d

Browse files
committed
fix(client-api): parse single-IP X-Forwarded-For values
`XForwardedFor::decode` required a comma in the header value via `split_once(',').ok_or_else(headers::Error::invalid)?`, so any client coming through a single-hop proxy — which emits `X-Forwarded-For: <ip>` with no comma — failed to decode. Previously this was silent: `Option<TypedHeader<XForwardedFor>>` in `crates/client-api/src/routes/subscribe.rs` resolved to `None` on decode error under axum 0.7. axum 0.8 (landed in #2713) changed the behavior: the same decoder `Err` now surfaces as a 400 Bad Request, which breaks all WebSocket subscribe requests from single-hop proxy clients — reproducible with: ``` curl -v -H 'Connection: Upgrade' -H 'Upgrade: websocket' \ -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \ -H 'Sec-WebSocket-Protocol: v2.bsatn.spacetimedb' \ -H 'x-forwarded-for: 1.2.3.4' \ "http://127.0.0.1:3000/v1/database/test/subscribe?token=..." # => HTTP/1.1 400 Bad Request # => invalid HTTP header (x-forwarded-for) ``` Accept both comma-separated and single-IP forms: take the first entry (trimmed) regardless of whether a comma is present. This matches how Nginx, Apache, HAProxy, etc. read the same header.
1 parent b1d5c59 commit 060ca9d

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
@@ -48,8 +48,10 @@ impl headers::Header for XForwardedFor {
4848
fn decode<'i, I: Iterator<Item = &'i HeaderValue>>(values: &mut I) -> Result<Self, headers::Error> {
4949
let val = values.next().ok_or_else(headers::Error::invalid)?;
5050
let val = val.to_str().map_err(|_| headers::Error::invalid())?;
51-
let (first, _) = val.split_once(',').ok_or_else(headers::Error::invalid)?;
52-
let ip = first.trim().parse().map_err(|_| headers::Error::invalid())?;
51+
// X-Forwarded-For is a comma-separated chain. For a single-hop
52+
// proxy there is no comma; take the first IP either way.
53+
let first = val.split(',').next().unwrap_or(val).trim();
54+
let ip = first.parse().map_err(|_| headers::Error::invalid())?;
5355
Ok(XForwardedFor(ip))
5456
}
5557

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

0 commit comments

Comments
 (0)