From 060ca9df6f58229568235d0c8ac4cb92c8e8bc6c Mon Sep 17 00:00:00 2001 From: Ludv1g Date: Fri, 17 Apr 2026 23:20:38 +0200 Subject: [PATCH] fix(client-api): parse single-IP X-Forwarded-For values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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: ` with no comma — failed to decode. Previously this was silent: `Option>` 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. --- crates/client-api/src/util.rs | 44 +++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/client-api/src/util.rs b/crates/client-api/src/util.rs index 901a79f7d39..5b69fb0c713 100644 --- a/crates/client-api/src/util.rs +++ b/crates/client-api/src/util.rs @@ -48,8 +48,10 @@ impl headers::Header for XForwardedFor { fn decode<'i, I: Iterator>(values: &mut I) -> Result { let val = values.next().ok_or_else(headers::Error::invalid)?; let val = val.to_str().map_err(|_| headers::Error::invalid())?; - let (first, _) = val.split_once(',').ok_or_else(headers::Error::invalid)?; - let ip = first.trim().parse().map_err(|_| headers::Error::invalid())?; + // X-Forwarded-For is a comma-separated chain. For a single-hop + // proxy there is no comma; take the first IP either way. + let first = val.split(',').next().unwrap_or(val).trim(); + let ip = first.parse().map_err(|_| headers::Error::invalid())?; Ok(XForwardedFor(ip)) } @@ -183,3 +185,41 @@ impl FromRequest for EmptyBody { Ok(Self) } } + +#[cfg(test)] +mod tests { + use super::*; + use headers::Header; + + fn decode_one(raw: &str) -> Result { + let val = HeaderValue::from_str(raw).unwrap(); + let values = [val]; + XForwardedFor::decode(&mut values.iter()) + } + + #[test] + fn decodes_single_ip() { + // Single-hop proxies (e.g. nginx inserting the client IP) emit + // a value with no comma. This must succeed. + let got = decode_one("10.0.0.1").expect("single IP should decode"); + assert_eq!(got.0, "10.0.0.1".parse::().unwrap()); + } + + #[test] + fn decodes_chain_takes_first() { + let got = decode_one("10.0.0.1, 192.168.1.1, 172.16.0.1").expect("chain should decode"); + assert_eq!(got.0, "10.0.0.1".parse::().unwrap()); + } + + #[test] + fn decodes_chain_trims_whitespace() { + let got = decode_one(" 10.0.0.1 , 192.168.1.1").expect("chain should decode"); + assert_eq!(got.0, "10.0.0.1".parse::().unwrap()); + } + + #[test] + fn rejects_non_ip() { + assert!(decode_one("not-an-ip").is_err()); + assert!(decode_one("not-an-ip, 10.0.0.1").is_err()); + } +}