websocket: collapse v4-mapped proxy peer so ws_proxyranges matches and X-Real-IP is honored
This commit is contained in:
parent
935f22f2f2
commit
5914a6d993
2 changed files with 70 additions and 1 deletions
|
|
@ -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
|
/// 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`,
|
/// 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.
|
/// 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 SocketAddr::V6(v6) = a {
|
||||||
if let Some(v4) = v6.ip().to_ipv4_mapped() {
|
if let Some(v4) = v6.ip().to_ipv4_mapped() {
|
||||||
return SocketAddr::new(IpAddr::V4(v4), a.port());
|
return SocketAddr::new(IpAddr::V4(v4), a.port());
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,12 @@ fn ws_session<S: WsStream>(
|
||||||
shutdown: TcpStream,
|
shutdown: TcpStream,
|
||||||
cfg: WsConfig,
|
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) ---
|
// --- HTTP Upgrade handshake (bounded by the handshake timeout) ---
|
||||||
let _ = stream.set_read_timeout(Some(cfg.handshake_timeout));
|
let _ = stream.set_read_timeout(Some(cfg.handshake_timeout));
|
||||||
let hs = match do_handshake(&mut stream, &cfg, addr.ip()) {
|
let hs = match do_handshake(&mut stream, &cfg, addr.ip()) {
|
||||||
|
|
@ -698,4 +704,67 @@ mod tests {
|
||||||
let f = encode(OP_TEXT, b"hi");
|
let f = encode(OP_TEXT, b"hi");
|
||||||
assert_eq!(f, vec![0x81, 0x02, b'h', b'i']);
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue