From 35af95fd0fd9909f56700125e0a90794829c860f Mon Sep 17 00:00:00 2001 From: reverse Date: Sat, 15 Aug 2026 16:27:11 +0000 Subject: [PATCH] harden: connclass clone-cap at register, ws control-frame limits, uuid recycle-skip, json-escape extjwt/filehost claims, metadata value/key caps, cloak numeric-dotted leak, relaymsg remote-nick, rpc set_oper block validation, isupport 13-token split, multi-hop privmsg routing, connectdelay=0 --- src/link.rs | 32 +++++++++++++++++++++----------- src/modules/cloak.rs | 8 +++++++- src/modules/connclass.rs | 12 ++++++++++++ src/modules/extjwt.rs | 29 +++++++++++++++++++++++++---- src/modules/filehost.rs | 22 ++++++++++++++++++++-- src/modules/metadata.rs | 12 ++++++++++++ src/modules/relaymsg.rs | 2 +- src/modules/restrictcommands.rs | 5 +++-- src/modules/rpc/user.rs | 12 +++++++++++- src/server.rs | 15 ++++++++++----- src/websocket.rs | 7 +++++++ 11 files changed, 129 insertions(+), 27 deletions(-) diff --git a/src/link.rs b/src/link.rs index 39c5607..e4eb4db 100644 --- a/src/link.rs +++ b/src/link.rs @@ -79,18 +79,25 @@ impl Server { /// Mint the next network-wide UID for a local user: our SID + 6 base-26 chars /// (e.g. `0AAAAAAAB`). pub fn next_uuid(&mut self) -> String { - let mut x = self.uuid_counter; - self.uuid_counter += 1; - let mut suffix = [b'A'; 6]; - for c in suffix.iter_mut().rev() { - *c = b'A' + (x % 26) as u8; - x /= 26; + loop { + let mut x = self.uuid_counter; + self.uuid_counter += 1; + let mut suffix = [b'A'; 6]; + for c in suffix.iter_mut().rev() { + *c = b'A' + (x % 26) as u8; + x /= 26; + } + let uuid = format!( + "{}{}", + self.sid, + std::str::from_utf8(&suffix).unwrap_or("AAAAAA") + ); + // after 26^6 mints the counter wraps and could re-issue a still-live id; + // skip any that's in use so uuids stay unique + if !self.uuid_local.contains_key(&uuid) && !self.remote_users.contains_key(&uuid) { + return uuid; + } } - format!( - "{}{}", - self.sid, - std::str::from_utf8(&suffix).unwrap_or("AAAAAA") - ) } /// Register a new server-link connection. An **outbound** link introduces @@ -966,6 +973,9 @@ impl Server { .map(|u| u.nick.clone()) .unwrap_or_default(); self.send(dst, format!(":{prefix} {cmd} {nick} :{text}")); + } else { + // a remote target reached via another link (multi-hop) — forward onward + self.forward_to_target(&target, msg, via); } } diff --git a/src/modules/cloak.rs b/src/modules/cloak.rs index a7dcade..59ec91f 100644 --- a/src/modules/cloak.rs +++ b/src/modules/cloak.rs @@ -99,7 +99,13 @@ pub fn cloak_host(key: &str, host: &str) -> String { format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}") } else { let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect(); - if parts.len() >= 3 { + // reveal the registered domain suffix only for a real hostname (its TLD has a + // letter); a numeric dotted string that slipped past the IP parsers is fully + // cloaked so no octets leak in cleartext + let real_host = parts + .last() + .is_some_and(|t| t.bytes().any(|b| b.is_ascii_alphabetic())); + if parts.len() >= 3 && real_host { let suffix = parts[parts.len() - 2..].join("."); format!("{}.{suffix}", label(key, host, 8)) } else { diff --git a/src/modules/connclass.rs b/src/modules/connclass.rs index 249529a..4cd197e 100644 --- a/src/modules/connclass.rs +++ b/src/modules/connclass.rs @@ -386,6 +386,18 @@ pub fn on_register(s: &mut Server, uid: Uid) -> AuthOutcome { else { return AuthOutcome::Proceed; }; + // enforce per-IP clone caps here too: a class matched only by a host mask isn't + // picked at connect, so `assign` never got to check them + if let Some(max) = class.localmax { + if local_clones(s, &ip, &class.name, uid) >= max { + return AuthOutcome::Reject("Too many connections from your address".into()); + } + } + if let Some(max) = class.globalmax { + if global_clones(s, &ip, uid) >= max { + return AuthOutcome::Reject("Too many connections from your address".into()); + } + } // cheap cert check before the (possibly slow) password verify if class.ssl_trusted && !has_cert { return AuthOutcome::Reject("Your connection class requires a client certificate".into()); diff --git a/src/modules/extjwt.rs b/src/modules/extjwt.rs index 8aac442..10f2e69 100644 --- a/src/modules/extjwt.rs +++ b/src/modules/extjwt.rs @@ -42,6 +42,23 @@ fn json_arr(chars: impl IntoIterator) -> String { format!("[{}]", items.join(",")) } +/// Escape a string for embedding in a JSON string literal. +fn json_esc(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)), + c => o.push(c), + } + } + o +} + pub fn commands() -> Vec> { vec![Box::new(ExtJwt)] } @@ -106,15 +123,19 @@ impl Command for ExtJwt { v }) .unwrap_or_default(); - chan_claims = format!(",\"channel\":\"{target}\",\"cmodes\":{}", json_arr(cmodes)); + chan_claims = format!( + ",\"channel\":\"{}\",\"cmodes\":{}", + json_esc(&target), + json_arr(cmodes) + ); } let claims = format!( "{{\"exp\":{},\"iss\":\"{}\",\"sub\":\"{}\",\"account\":\"{}\",\"umodes\":{}{}}}", now() + duration, - s.name, - nick, - account, + json_esc(&s.name), + json_esc(&nick), + json_esc(&account), umodes, chan_claims ); diff --git a/src/modules/filehost.rs b/src/modules/filehost.rs index 5a9c796..ebad29a 100644 --- a/src/modules/filehost.rs +++ b/src/modules/filehost.rs @@ -50,6 +50,24 @@ fn file_type(filename: &str) -> &'static str { } } +/// Escape a string for embedding in a JSON string literal (the tag carries JSON, +/// so a `"`/`\` in the url or filename would otherwise break it). +fn json_esc(v: &str) -> String { + let mut o = String::with_capacity(v.len()); + for c in v.chars() { + match c { + '"' => o.push_str("\\\""), + '\\' => o.push_str("\\\\"), + '\n' => o.push_str("\\n"), + '\r' => o.push_str("\\r"), + '\t' => o.push_str("\\t"), + c if (c as u32) < 0x20 => o.push_str(&format!("\\u{:04x}", c as u32)), + c => o.push(c), + } + } + o +} + /// IRCv3 message-tag value escape (JSON is full of spaces, which would split the line). fn escape_tag(v: &str) -> String { let mut out = String::with_capacity(v.len()); @@ -114,8 +132,8 @@ impl Module for FileHost { let filename = &url[files_prefix.len().min(url.len())..]; let meta = format!( "{{\"url\":\"{}\",\"filename\":\"{}\",\"type\":\"{}\"}}", - url, - filename, + json_esc(url), + json_esc(filename), file_type(filename) ); // fold the metadata onto this message's relayed tag block (message-tags diff --git a/src/modules/metadata.rs b/src/modules/metadata.rs index 826efef..720169d 100644 --- a/src/modules/metadata.rs +++ b/src/modules/metadata.rs @@ -144,10 +144,22 @@ impl Command for MetadataCmd { return CmdResult::Fail; }; let value = params.get(3).cloned(); // no value => delete the key + let maxval = s.conf_num("metadata_maxvalue", 512usize); + let maxkeys = s.conf_num("metadata_maxkeys", 32usize); { let st = s.ext.get_or_insert_with::(MetaStore::default); match &value { Some(v) => { + // bound value length and per-target key count so a client + // can't grow the store without limit + let cur = st.0.get(&key); + let overlong = v.len() > maxval; + let too_many = cur.map(|m| m.len() >= maxkeys && !m.contains_key(&mkey)) + .unwrap_or(false); + if overlong || too_many { + s.fail(uid, "METADATA", "KEY_INVALID", "value too long or too many keys"); + return CmdResult::Fail; + } st.0.entry(key.clone()) .or_default() .insert(mkey.clone(), v.clone()); diff --git a/src/modules/relaymsg.rs b/src/modules/relaymsg.rs index 10140a0..88762f6 100644 --- a/src/modules/relaymsg.rs +++ b/src/modules/relaymsg.rs @@ -56,7 +56,7 @@ impl Command for RelayMsg { ); return CmdResult::Fail; } - if s.find_nick(nick).is_some() { + if s.find_nick(nick).is_some() || s.remote_nick.contains_key(&nick.to_ascii_lowercase()) { return bad(s, "RELAYMSG spoofed nick is already in use"); } if nick.chars().any(|c| FORBIDDEN.contains(c)) { diff --git a/src/modules/restrictcommands.rs b/src/modules/restrictcommands.rs index 2e8827c..520493b 100644 --- a/src/modules/restrictcommands.rs +++ b/src/modules/restrictcommands.rs @@ -147,8 +147,9 @@ impl Module for RestrictCommands { return ModResult::Passthru; } } - // connect-delay: allowed once connected long enough - if r.connectdelay > 0 && now().saturating_sub(signon) >= r.connectdelay { + // connect-delay: allowed once connected long enough (0 = no delay, so the + // command is allowed immediately) + if now().saturating_sub(signon) >= r.connectdelay { return ModResult::Passthru; } diff --git a/src/modules/rpc/user.rs b/src/modules/rpc/user.rs index 8ba2dde..b69ec4b 100644 --- a/src/modules/rpc/user.rs +++ b/src/modules/rpc/user.rs @@ -134,7 +134,17 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result s.oper_up(uid), + Some(name) if !name.is_empty() => { + // apply the named oper block's level; reject an unknown name rather + // than silently opering with defaults + match s.opers.iter().find(|o| o.0 == name).map(|o| o.2) { + Some(level) => { + s.oper_up(uid); + crate::modules::operlevels::set(s, uid, level); + } + None => return Err(RpcError::invalid_params("no such oper block")), + } + } _ => svs_set_user_modes(s, uid, "-o"), // de-oper } Ok(obj(&[("result", "true".into())])) diff --git a/src/server.rs b/src/server.rs index bc225ac..015ffba 100644 --- a/src/server.rs +++ b/src/server.rs @@ -716,17 +716,22 @@ impl Server { // config-overridable (see modules::customprefix) let include_oper = self.conf_bool("operprefix", false) || self.conf_bool("ojoin", false); let prefix = crate::modules::customprefix::isupport(include_oper); - let mut lines = vec![format!( + let mut tokens: Vec = format!( "CHANTYPES=# PREFIX={prefix} CHANMODES=beIgXw,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnprstuz EXTBAN=,Gbcgjmnrsy WATCH={maxwatch} MONITOR={maxmon} SILENCE={maxsil} CALLERID=g WHOX CHATHISTORY={chathist} MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN={maxnick} CHANNELLEN={maxchan} NETWORK={}", self.network - )]; + ) + .split(' ') + .map(String::from) + .collect(); if let Some(tok) = crate::modules::network_icon::isupport(self) { - lines.push(tok); + tokens.push(tok); } if let Some(tok) = crate::modules::filehost::isupport(self) { - lines.push(tok); + tokens.push(tok); } - lines + // at most 13 tokens per 005 line (the RFC-suggested cap) so strict clients + // don't truncate trailing tokens + tokens.chunks(13).map(|c| c.join(" ")).collect() } /// Emit the ISUPPORT numerics to `uid`. When `batched` (the client negotiated diff --git a/src/websocket.rs b/src/websocket.rs index e356110..e3cf450 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -326,6 +326,13 @@ fn io_loop( Ok(Some((frame, consumed))) => { acc.drain(..consumed); match frame.opcode { + // RFC 6455 §5.5: control frames must be ≤125 bytes and + // never fragmented — drop the connection otherwise + OP_CLOSE | OP_PING | OP_PONG + if !frame.fin || frame.payload.len() > 125 => + { + return + } OP_CLOSE => return, OP_PING => { let _ = stream.write_all(&encode(OP_PONG, &frame.payload));