websocket: add ws_defaultmode (text/binary/reject), ws_proxyranges (glob/CIDR X-Real-IP/XFF trust), ws_allowmissingorigin, ws_nativeping

This commit is contained in:
Jean Chevronnet 2026-08-11 16:10:09 +00:00
parent 87a683dbd2
commit 4f6c0ded48
4 changed files with 116 additions and 37 deletions

View file

@ -137,11 +137,24 @@ amu_target = both
# syslog_tag = echoircd # syslog_tag = echoircd
# --- PROXY protocol: trust the HAProxy/nginx PROXY header (v1 or v2) from these # --- 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 # sources (glob or CIDR, repeatable), so the real client IP is used instead of
# proxy's. A connection from a trusted proxy MUST lead with a PROXY header. # 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). # Applies to the plaintext and TLS client listeners (WebSocket uses XFF instead).
# proxy = 127.0.0.1 # 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 = <name> [criteria...] # --- security groups: securitygroup = <name> [criteria...]
# criteria: public tls insecure account unregistered oper exclude-oper # criteria: public tls insecure account unregistered oper exclude-oper

View file

@ -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 /// 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 /// a `/` is a CIDR range tested against the IP; otherwise it's a glob tested against
/// both the IP text and the host. /// both the IP text and the host.

View file

@ -236,7 +236,7 @@ pub fn run_reactor(
// add_conn sees the real client IP. // add_conn sees the real client IP.
let via_proxy = proxy_trust let via_proxy = proxy_trust
.iter() .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 { let out = OutSink::Reactor {
token, token,
tx: out_tx.clone(), tx: out_tx.clone(),
@ -687,7 +687,7 @@ fn tls_conn(
// TLS handshake); consume it and rewrite the client address. // TLS handshake); consume it and rewrite the client address.
let addr = if proxy_trust let addr = if proxy_trust
.iter() .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 _ = stream.set_read_timeout(Some(Duration::from_secs(5)));
let real = match crate::proxy::read_header(&mut stream) { let real = match crate::proxy::read_header(&mut stream) {

View file

@ -12,7 +12,12 @@
//! ws_handshake_timeout = 10 seconds to finish the Upgrade //! ws_handshake_timeout = 10 seconds to finish the Upgrade
//! ws_ping_interval = 60 seconds between server keepalive pings (0 = off) //! ws_ping_interval = 60 seconds between server keepalive pings (0 = off)
//! ws_timeout = 120 seconds with no traffic before we drop it //! 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::io::{self, Read, Write};
use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream}; use std::net::{IpAddr, Shutdown, SocketAddr, TcpListener, TcpStream};
@ -46,6 +51,14 @@ const OP_CLOSE: u8 = 0x8;
const OP_PING: u8 = 0x9; const OP_PING: u8 = 0x9;
const OP_PONG: u8 = 0xA; 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. /// Tunables read once from the config.
#[derive(Clone)] #[derive(Clone)]
pub struct WsConfig { pub struct WsConfig {
@ -53,8 +66,11 @@ pub struct WsConfig {
handshake_timeout: Duration, handshake_timeout: Duration,
ping_interval: Duration, ping_interval: Duration,
idle_timeout: Duration, idle_timeout: Duration,
trust_proxy: bool, trust_proxy: bool, // legacy: trust proxy headers from any peer
binary_ok: bool, proxyranges: Vec<String>, // 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` /// A stream the WS session can drive — implemented for a plaintext `TcpStream`
@ -123,6 +139,11 @@ pub fn maybe_start(cfg: &Config, core: Sender<Event>, counter: Arc<AtomicU64>) {
.map(Duration::from_secs) .map(Duration::from_secs)
.unwrap_or(Duration::from_secs(d)) .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 { let wscfg = WsConfig {
origins: cfg.raw.get("ws_origin").cloned().unwrap_or_default(), origins: cfg.raw.get("ws_origin").cloned().unwrap_or_default(),
handshake_timeout: dur("ws_handshake_timeout", 10), handshake_timeout: dur("ws_handshake_timeout", 10),
@ -131,7 +152,12 @@ pub fn maybe_start(cfg: &Config, core: Sender<Event>, counter: Arc<AtomicU64>) {
trust_proxy: get("ws_trust_proxy") trust_proxy: get("ws_trust_proxy")
.map(crate::config::yesish) .map(crate::config::yesish)
.unwrap_or(false), .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) { if let Some(bind) = get("bind_ws").map(str::to_string) {
@ -228,7 +254,7 @@ fn ws_session<S: WsStream>(
) { ) {
// --- 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) { let hs = match do_handshake(&mut stream, &cfg, addr.ip()) {
Ok(h) => h, Ok(h) => h,
Err(_) => { Err(_) => {
let _ = shutdown.shutdown(Shutdown::Both); let _ = shutdown.shutdown(Shutdown::Both);
@ -353,15 +379,19 @@ fn io_loop<S: WsStream>(
} }
let _ = stream.flush(); let _ = stream.flush();
// 3) keepalive + idle timeout // 3) keepalive + idle timeout — WS-native pinging. With ws_nativeping=no the
if cfg.ping_interval > Duration::ZERO && last_ping.elapsed() >= cfg.ping_interval { // IRC core's PING / ping-timeout drives liveness instead, so the WS layer
last_ping = Instant::now(); // neither pings nor idle-drops.
if stream.write_all(&encode(OP_PING, b"echo")).is_err() { if cfg.native_ping {
return; 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<u8> {
out out
} }
/// Read and validate the HTTP Upgrade request, then write the 101 response. /// Read and validate the HTTP Upgrade request, then write the 101 response. `peer`
fn do_handshake<S: WsStream>(stream: &mut S, cfg: &WsConfig) -> io::Result<Handshake> { /// 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<S: WsStream>(stream: &mut S, cfg: &WsConfig, peer: IpAddr) -> io::Result<Handshake> {
// read headers (bounded) // read headers (bounded)
let mut buf = Vec::new(); let mut buf = Vec::new();
let mut chunk = [0u8; 2048]; let mut chunk = [0u8; 2048];
@ -504,35 +536,62 @@ fn do_handshake<S: WsStream>(stream: &mut S, cfg: &WsConfig) -> io::Result<Hands
} }
let key = hdr("sec-websocket-key").ok_or_else(|| io::Error::other("no key"))?; let key = hdr("sec-websocket-key").ok_or_else(|| io::Error::other("no key"))?;
// origin check (CSWSH guard): if any configured, the Origin must match one // origin check (CSWSH guard): a present Origin must match a configured glob (if
if !cfg.origins.is_empty() { // any); a missing Origin is allowed unless ws_allowmissingorigin = no.
let origin = hdr("origin").unwrap_or_default(); match hdr("origin") {
if !cfg.origins.iter().any(|g| glob_match(g, &origin)) { Some(origin) => {
let _ = stream.write_all(b"HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n"); if !cfg.origins.is_empty() && !cfg.origins.iter().any(|g| glob_match(g, &origin)) {
return Err(io::Error::other("origin rejected")); 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") let offered = hdr("sec-websocket-protocol")
.unwrap_or_default() .unwrap_or_default()
.to_ascii_lowercase(); .to_ascii_lowercase();
let (chosen, binary) = if offered.split(',').any(|p| p.trim() == "text.ircv3.net") { let (chosen, binary) = if offered.split(',').any(|p| p.trim() == "text.ircv3.net") {
(Some("text.ircv3.net"), false) (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) (Some("binary.ircv3.net"), true)
} else { } else {
(None, false) match cfg.default_mode {
}; DefaultMode::Text => (None, false),
DefaultMode::Binary => (None, true),
// real IP / scheme from a trusted reverse proxy DefaultMode::Reject => {
let (mut real_ip, mut secure) = (None, false); let _ = stream.write_all(b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
if cfg.trust_proxy { return Err(io::Error::other("no subprotocol (reject mode)"));
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);
} }
} }
};
// 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")); secure = hdr("x-forwarded-proto").is_some_and(|v| v.eq_ignore_ascii_case("https"));
} }