From 4f6c0ded488814f0ac6b9dd60f5f5117ee9943fd Mon Sep 17 00:00:00 2001 From: reverse Date: Tue, 11 Aug 2026 16:10:09 +0000 Subject: [PATCH] websocket: add ws_defaultmode (text/binary/reject), ws_proxyranges (glob/CIDR X-Real-IP/XFF trust), ws_allowmissingorigin, ws_nativeping --- echoircd.conf.example | 19 +++++- src/modules/connclass.rs | 7 +++ src/socketengine.rs | 4 +- src/websocket.rs | 123 +++++++++++++++++++++++++++++---------- 4 files changed, 116 insertions(+), 37 deletions(-) diff --git a/echoircd.conf.example b/echoircd.conf.example index c6d21b3..8b77fb1 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -137,11 +137,24 @@ amu_target = both # syslog_tag = echoircd # --- PROXY protocol: trust the HAProxy/nginx PROXY header (v1 or v2) from these -# source IPs (glob, repeatable), so the real client IP is used instead of the -# proxy's. A connection from a trusted proxy MUST lead with a PROXY header. +# sources (glob or CIDR, repeatable), so the real client IP is used instead of +# the proxy's. A connection from a trusted proxy MUST lead with a PROXY header. # Applies to the plaintext and TLS client listeners (WebSocket uses XFF instead). # proxy = 127.0.0.1 -# proxy = 10.0.0.* +# proxy = 10.0.0.0/8 + +# --- WebSocket transport (browser IRC clients connect straight to echoIRCd) --- +# bind_ws = 127.0.0.1:8097 # ws:// listener +# bind_wss = 0.0.0.0:7799 # wss:// listener (uses tls_cert/tls_key) +# ws_origin = https://x.example # (repeatable) allowed Origin globs; empty = any +# ws_defaultmode = text # frame mode with no subprotocol: text|binary|reject +# ws_proxyranges = 127.0.0.1 # (repeatable) glob/CIDR of proxies whose +# # X-Real-IP / X-Forwarded-For we trust +# ws_allowmissingorigin = yes # allow clients that send no Origin header +# ws_nativeping = yes # liveness via WebSocket pings (no = IRC PING) +# ws_handshake_timeout = 10 # seconds to complete the HTTP Upgrade +# ws_ping_interval = 60 # seconds between WebSocket keepalive pings +# ws_timeout = 120 # drop after this many seconds of silence # --- security groups: securitygroup = [criteria...] # criteria: public tls insecure account unregistered oper exclude-oper diff --git a/src/modules/connclass.rs b/src/modules/connclass.rs index ce2e0ef..bbddfd7 100644 --- a/src/modules/connclass.rs +++ b/src/modules/connclass.rs @@ -196,6 +196,13 @@ fn cidr_contains(base: IpAddr, bits: u8, target: IpAddr) -> bool { } } +/// Whether `ip` matches `mask`, where `mask` is a CIDR range or an IP glob. Shared +/// with the WebSocket `proxyranges` and PROXY-protocol trust checks so they accept +/// the same glob-or-CIDR syntax. +pub fn ip_matches(mask: &str, ip: &str) -> bool { + mask_match(mask, ip, "") +} + /// Match one mask against a client's IP and (once known) resolved host. A mask with /// a `/` is a CIDR range tested against the IP; otherwise it's a glob tested against /// both the IP text and the host. diff --git a/src/socketengine.rs b/src/socketengine.rs index b1ed52e..fa18599 100644 --- a/src/socketengine.rs +++ b/src/socketengine.rs @@ -236,7 +236,7 @@ pub fn run_reactor( // add_conn sees the real client IP. let via_proxy = proxy_trust .iter() - .any(|g| crate::channels::glob_match(g, &addr.ip().to_string())); + .any(|g| crate::modules::connclass::ip_matches(g, &addr.ip().to_string())); let out = OutSink::Reactor { token, tx: out_tx.clone(), @@ -687,7 +687,7 @@ fn tls_conn( // TLS handshake); consume it and rewrite the client address. let addr = if proxy_trust .iter() - .any(|g| crate::channels::glob_match(g, &addr.ip().to_string())) + .any(|g| crate::modules::connclass::ip_matches(g, &addr.ip().to_string())) { let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); let real = match crate::proxy::read_header(&mut stream) { diff --git a/src/websocket.rs b/src/websocket.rs index 258f2a1..1fd918c 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -12,7 +12,12 @@ //! ws_handshake_timeout = 10 seconds to finish the Upgrade //! ws_ping_interval = 60 seconds between server keepalive pings (0 = off) //! ws_timeout = 120 seconds with no traffic before we drop it -//! ws_trust_proxy = no read X-Forwarded-For / -Proto (behind nginx) +//! ws_defaultmode = text frame mode with no subprotocol: text|binary|reject +//! ws_proxyranges = 127.0.0.1 (repeatable) glob/CIDR of proxies to trust +//! X-Real-IP / X-Forwarded-For from +//! ws_allowmissingorigin = yes allow clients that send no Origin header +//! ws_nativeping = yes liveness via WebSocket pings (no ⇒ IRC PING) +//! ws_trust_proxy = no legacy: trust proxy headers from any peer use std::io::{self, Read, Write}; use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream}; @@ -46,6 +51,14 @@ const OP_CLOSE: u8 = 0x8; const OP_PING: u8 = 0x9; const OP_PONG: u8 = 0xA; +/// The frame mode used when a client negotiates no IRCv3 subprotocol. +#[derive(Clone, Copy, PartialEq)] +enum DefaultMode { + Text, + Binary, + Reject, +} + /// Tunables read once from the config. #[derive(Clone)] pub struct WsConfig { @@ -53,8 +66,11 @@ pub struct WsConfig { handshake_timeout: Duration, ping_interval: Duration, idle_timeout: Duration, - trust_proxy: bool, - binary_ok: bool, + trust_proxy: bool, // legacy: trust proxy headers from any peer + proxyranges: Vec, // glob/CIDR of proxies whose headers we trust + default_mode: DefaultMode, // frame mode when no subprotocol is negotiated + allow_missing_origin: bool, // accept clients that send no Origin header + native_ping: bool, // ping via WebSocket frames (else rely on IRC PING) } /// A stream the WS session can drive — implemented for a plaintext `TcpStream` @@ -123,6 +139,11 @@ pub fn maybe_start(cfg: &Config, core: Sender, counter: Arc) { .map(Duration::from_secs) .unwrap_or(Duration::from_secs(d)) }; + let default_mode = match get("ws_defaultmode").map(|s| s.to_ascii_lowercase()).as_deref() { + Some("binary") => DefaultMode::Binary, + Some("reject") => DefaultMode::Reject, + _ => DefaultMode::Text, + }; let wscfg = WsConfig { origins: cfg.raw.get("ws_origin").cloned().unwrap_or_default(), handshake_timeout: dur("ws_handshake_timeout", 10), @@ -131,7 +152,12 @@ pub fn maybe_start(cfg: &Config, core: Sender, counter: Arc) { trust_proxy: get("ws_trust_proxy") .map(crate::config::yesish) .unwrap_or(false), - binary_ok: true, + proxyranges: cfg.raw.get("ws_proxyranges").cloned().unwrap_or_default(), + default_mode, + allow_missing_origin: get("ws_allowmissingorigin") + .map(crate::config::yesish) + .unwrap_or(true), + native_ping: get("ws_nativeping").map(crate::config::yesish).unwrap_or(true), }; if let Some(bind) = get("bind_ws").map(str::to_string) { @@ -228,7 +254,7 @@ fn ws_session( ) { // --- 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) { + let hs = match do_handshake(&mut stream, &cfg, addr.ip()) { Ok(h) => h, Err(_) => { let _ = shutdown.shutdown(Shutdown::Both); @@ -353,15 +379,19 @@ fn io_loop( } let _ = stream.flush(); - // 3) keepalive + idle timeout - if cfg.ping_interval > Duration::ZERO && last_ping.elapsed() >= cfg.ping_interval { - last_ping = Instant::now(); - if stream.write_all(&encode(OP_PING, b"echo")).is_err() { - return; + // 3) keepalive + idle timeout — WS-native pinging. With ws_nativeping=no the + // IRC core's PING / ping-timeout drives liveness instead, so the WS layer + // neither pings nor idle-drops. + if cfg.native_ping { + if cfg.ping_interval > Duration::ZERO && last_ping.elapsed() >= cfg.ping_interval { + last_ping = Instant::now(); + if stream.write_all(&encode(OP_PING, b"echo")).is_err() { + return; + } + } + if last_rx.elapsed() >= cfg.idle_timeout { + return; // dead connection } - } - if last_rx.elapsed() >= cfg.idle_timeout { - return; // dead connection } } } @@ -472,8 +502,10 @@ fn encode(opcode: u8, payload: &[u8]) -> Vec { out } -/// Read and validate the HTTP Upgrade request, then write the 101 response. -fn do_handshake(stream: &mut S, cfg: &WsConfig) -> io::Result { +/// Read and validate the HTTP Upgrade request, then write the 101 response. `peer` +/// is the socket's remote IP, matched against `proxyranges` to decide whether the +/// X-Real-IP / X-Forwarded-* headers may be trusted. +fn do_handshake(stream: &mut S, cfg: &WsConfig, peer: IpAddr) -> io::Result { // read headers (bounded) let mut buf = Vec::new(); let mut chunk = [0u8; 2048]; @@ -504,35 +536,62 @@ fn do_handshake(stream: &mut S, cfg: &WsConfig) -> io::Result { + if !cfg.origins.is_empty() && !cfg.origins.iter().any(|g| glob_match(g, &origin)) { + let _ = stream.write_all(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + return Err(io::Error::other("origin rejected")); + } + } + None => { + if !cfg.allow_missing_origin { + let _ = stream.write_all(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); + return Err(io::Error::other("missing origin")); + } } } - // subprotocol: prefer text.ircv3.net; accept binary.ircv3.net + // subprotocol: prefer text.ircv3.net, accept binary.ircv3.net; with neither, + // fall back to ws_defaultmode (and reject the handshake if that is "reject"). let offered = hdr("sec-websocket-protocol") .unwrap_or_default() .to_ascii_lowercase(); let (chosen, binary) = if offered.split(',').any(|p| p.trim() == "text.ircv3.net") { (Some("text.ircv3.net"), false) - } else if cfg.binary_ok && offered.split(',').any(|p| p.trim() == "binary.ircv3.net") { + } else if offered.split(',').any(|p| p.trim() == "binary.ircv3.net") { (Some("binary.ircv3.net"), true) } else { - (None, false) - }; - - // real IP / scheme from a trusted reverse proxy - let (mut real_ip, mut secure) = (None, false); - if cfg.trust_proxy { - if let Some(xff) = hdr("x-forwarded-for") { - if let Some(ip) = xff.split(',').next().and_then(|s| s.trim().parse().ok()) { - real_ip = Some(ip); + match cfg.default_mode { + DefaultMode::Text => (None, false), + DefaultMode::Binary => (None, true), + DefaultMode::Reject => { + let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + return Err(io::Error::other("no subprotocol (reject mode)")); } } + }; + + // real IP / scheme from a trusted reverse proxy: trust the headers only when the + // peer matches a configured proxyrange (glob/CIDR), or the legacy trust_proxy is on + let trusted = if cfg.proxyranges.is_empty() { + cfg.trust_proxy + } else { + let ip = peer.to_string(); + cfg.proxyranges + .iter() + .any(|r| crate::modules::connclass::ip_matches(r, &ip)) + }; + let (mut real_ip, mut secure) = (None, false); + if trusted { + // X-Real-IP wins; else the first hop of X-Forwarded-For + real_ip = hdr("x-real-ip") + .and_then(|v| v.trim().parse().ok()) + .or_else(|| { + hdr("x-forwarded-for") + .and_then(|xff| xff.split(',').next().and_then(|s| s.trim().parse().ok())) + }); secure = hdr("x-forwarded-proto").is_some_and(|v| v.eq_ignore_ascii_case("https")); }