websocket: collapse v4-mapped proxy peer so ws_proxyranges matches and X-Real-IP is honored

This commit is contained in:
Jean Chevronnet 2026-08-25 03:15:28 +00:00
parent 935f22f2f2
commit 5914a6d993
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
2 changed files with 70 additions and 1 deletions

View file

@ -43,7 +43,7 @@ const TLS_POLL: Duration = Duration::from_millis(100);
/// Collapse an IPv4-mapped IPv6 peer address (`::ffff:1.2.3.4`, which is how an IPv4
/// client shows up on a dual-stack `[::]` listener) back to a plain IPv4 `SocketAddr`,
/// so cloaking, bans, GeoIP, DNSBL and host display all see the real IPv4 address.
fn normalize_addr(a: SocketAddr) -> SocketAddr {
pub(crate) fn normalize_addr(a: SocketAddr) -> SocketAddr {
if let SocketAddr::V6(v6) = a {
if let Some(v4) = v6.ip().to_ipv4_mapped() {
return SocketAddr::new(IpAddr::V4(v4), a.port());

View file

@ -252,6 +252,12 @@ fn ws_session<S: WsStream>(
shutdown: TcpStream,
cfg: WsConfig,
) {
// An IPv4 client on the dual-stack [::] wss listener arrives v4-mapped
// (::ffff:1.2.3.4); collapse it so the proxy-range trust check and the fallback
// client IP match what the plaintext/TLS listeners already normalize to. Without
// this a reverse proxy on 127.0.0.1 shows up as ::ffff:127.0.0.1, fails the
// ws_proxyranges match, and every web user inherits the proxy's loopback IP.
let addr = crate::socketengine::normalize_addr(addr);
// --- HTTP Upgrade handshake (bounded by the handshake timeout) ---
let _ = stream.set_read_timeout(Some(cfg.handshake_timeout));
let hs = match do_handshake(&mut stream, &cfg, addr.ip()) {
@ -698,4 +704,67 @@ mod tests {
let f = encode(OP_TEXT, b"hi");
assert_eq!(f, vec![0x81, 0x02, b'h', b'i']);
}
// A minimal WsStream that replays a canned HTTP upgrade request and swallows writes.
struct MockStream {
data: Vec<u8>,
pos: usize,
}
impl WsStream for MockStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = (self.data.len() - self.pos).min(buf.len());
buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
self.pos += n;
Ok(n)
}
fn write_all(&mut self, _buf: &[u8]) -> io::Result<()> {
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
fn set_read_timeout(&self, _dur: Option<Duration>) -> io::Result<()> {
Ok(())
}
fn shutdown(&mut self) {}
}
#[test]
fn proxied_ws_extracts_real_ip_from_trusted_loopback() {
use std::net::{Ipv4Addr, SocketAddr};
// the fix: an IPv4 client on the [::] wss listener reaches the proxy as
// ::ffff:127.0.0.1 — it must collapse to 127.0.0.1 so ws_proxyranges matches.
let mapped: SocketAddr = "[::ffff:127.0.0.1]:9".parse().unwrap();
assert_eq!(
crate::socketengine::normalize_addr(mapped).ip(),
IpAddr::V4(Ipv4Addr::LOCALHOST),
"mapped loopback collapses to 127.0.0.1"
);
// with the normalized loopback peer, a trusted proxy's X-Real-IP wins over
// the proxy's own address.
let req = "GET /irc/ HTTP/1.1\r\nHost: orbit.devtronic.pro\r\nUpgrade: websocket\r\n\
Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\
Sec-WebSocket-Version: 13\r\nOrigin: https://orbit.devtronic.pro\r\n\
Sec-WebSocket-Protocol: text.ircv3.net\r\n\
X-Real-IP: 203.0.113.77\r\nX-Forwarded-Proto: https\r\n\r\n";
let mut s = MockStream { data: req.as_bytes().to_vec(), pos: 0 };
let cfg = WsConfig {
origins: vec!["https://orbit.devtronic.pro".into()],
handshake_timeout: Duration::from_secs(10),
ping_interval: Duration::from_secs(60),
idle_timeout: Duration::from_secs(120),
trust_proxy: false,
proxyranges: vec!["127.0.0.1".into()],
default_mode: DefaultMode::Text,
allow_missing_origin: false,
native_ping: true,
};
let hs = do_handshake(&mut s, &cfg, IpAddr::V4(Ipv4Addr::LOCALHOST)).expect("handshake");
assert_eq!(
hs.real_ip,
Some(IpAddr::V4(Ipv4Addr::new(203, 0, 113, 77))),
"trusted loopback proxy -> real client IP, not the proxy's loopback"
);
assert!(hs.secure, "x-forwarded-proto https marks the session secure");
}
}