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

This commit is contained in:
Jean Chevronnet 2026-08-15 16:27:11 +00:00
parent e935ee7002
commit 35af95fd0f
11 changed files with 129 additions and 27 deletions

View file

@ -79,6 +79,7 @@ impl Server {
/// Mint the next network-wide UID for a local user: our SID + 6 base-26 chars /// Mint the next network-wide UID for a local user: our SID + 6 base-26 chars
/// (e.g. `0AAAAAAAB`). /// (e.g. `0AAAAAAAB`).
pub fn next_uuid(&mut self) -> String { pub fn next_uuid(&mut self) -> String {
loop {
let mut x = self.uuid_counter; let mut x = self.uuid_counter;
self.uuid_counter += 1; self.uuid_counter += 1;
let mut suffix = [b'A'; 6]; let mut suffix = [b'A'; 6];
@ -86,11 +87,17 @@ impl Server {
*c = b'A' + (x % 26) as u8; *c = b'A' + (x % 26) as u8;
x /= 26; x /= 26;
} }
format!( let uuid = format!(
"{}{}", "{}{}",
self.sid, self.sid,
std::str::from_utf8(&suffix).unwrap_or("AAAAAA") 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;
}
}
} }
/// Register a new server-link connection. An **outbound** link introduces /// Register a new server-link connection. An **outbound** link introduces
@ -966,6 +973,9 @@ impl Server {
.map(|u| u.nick.clone()) .map(|u| u.nick.clone())
.unwrap_or_default(); .unwrap_or_default();
self.send(dst, format!(":{prefix} {cmd} {nick} :{text}")); 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);
} }
} }

View file

@ -99,7 +99,13 @@ pub fn cloak_host(key: &str, host: &str) -> String {
format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}") format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}")
} else { } else {
let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect(); 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("."); let suffix = parts[parts.len() - 2..].join(".");
format!("{}.{suffix}", label(key, host, 8)) format!("{}.{suffix}", label(key, host, 8))
} else { } else {

View file

@ -386,6 +386,18 @@ pub fn on_register(s: &mut Server, uid: Uid) -> AuthOutcome {
else { else {
return AuthOutcome::Proceed; 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 // cheap cert check before the (possibly slow) password verify
if class.ssl_trusted && !has_cert { if class.ssl_trusted && !has_cert {
return AuthOutcome::Reject("Your connection class requires a client certificate".into()); return AuthOutcome::Reject("Your connection class requires a client certificate".into());

View file

@ -42,6 +42,23 @@ fn json_arr(chars: impl IntoIterator<Item = char>) -> String {
format!("[{}]", items.join(",")) 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<Box<dyn Command>> { pub fn commands() -> Vec<Box<dyn Command>> {
vec![Box::new(ExtJwt)] vec![Box::new(ExtJwt)]
} }
@ -106,15 +123,19 @@ impl Command for ExtJwt {
v v
}) })
.unwrap_or_default(); .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!( let claims = format!(
"{{\"exp\":{},\"iss\":\"{}\",\"sub\":\"{}\",\"account\":\"{}\",\"umodes\":{}{}}}", "{{\"exp\":{},\"iss\":\"{}\",\"sub\":\"{}\",\"account\":\"{}\",\"umodes\":{}{}}}",
now() + duration, now() + duration,
s.name, json_esc(&s.name),
nick, json_esc(&nick),
account, json_esc(&account),
umodes, umodes,
chan_claims chan_claims
); );

View file

@ -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). /// IRCv3 message-tag value escape (JSON is full of spaces, which would split the line).
fn escape_tag(v: &str) -> String { fn escape_tag(v: &str) -> String {
let mut out = String::with_capacity(v.len()); 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 filename = &url[files_prefix.len().min(url.len())..];
let meta = format!( let meta = format!(
"{{\"url\":\"{}\",\"filename\":\"{}\",\"type\":\"{}\"}}", "{{\"url\":\"{}\",\"filename\":\"{}\",\"type\":\"{}\"}}",
url, json_esc(url),
filename, json_esc(filename),
file_type(filename) file_type(filename)
); );
// fold the metadata onto this message's relayed tag block (message-tags // fold the metadata onto this message's relayed tag block (message-tags

View file

@ -144,10 +144,22 @@ impl Command for MetadataCmd {
return CmdResult::Fail; return CmdResult::Fail;
}; };
let value = params.get(3).cloned(); // no value => delete the key 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>(MetaStore::default); let st = s.ext.get_or_insert_with::<MetaStore>(MetaStore::default);
match &value { match &value {
Some(v) => { 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()) st.0.entry(key.clone())
.or_default() .or_default()
.insert(mkey.clone(), v.clone()); .insert(mkey.clone(), v.clone());

View file

@ -56,7 +56,7 @@ impl Command for RelayMsg {
); );
return CmdResult::Fail; 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"); return bad(s, "RELAYMSG spoofed nick is already in use");
} }
if nick.chars().any(|c| FORBIDDEN.contains(c)) { if nick.chars().any(|c| FORBIDDEN.contains(c)) {

View file

@ -147,8 +147,9 @@ impl Module for RestrictCommands {
return ModResult::Passthru; return ModResult::Passthru;
} }
} }
// connect-delay: allowed once connected long enough // connect-delay: allowed once connected long enough (0 = no delay, so the
if r.connectdelay > 0 && now().saturating_sub(signon) >= r.connectdelay { // command is allowed immediately)
if now().saturating_sub(signon) >= r.connectdelay {
return ModResult::Passthru; return ModResult::Passthru;
} }

View file

@ -134,7 +134,17 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?; let uid = resolve(s, params).ok_or_else(|| RpcError::not_found("no such user"))?;
let oper = json::get_str(params, "oper").or_else(|| json::get_str(params, "type")); let oper = json::get_str(params, "oper").or_else(|| json::get_str(params, "type"));
match oper { match oper {
Some(name) if !name.is_empty() => 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 _ => svs_set_user_modes(s, uid, "-o"), // de-oper
} }
Ok(obj(&[("result", "true".into())])) Ok(obj(&[("result", "true".into())]))

View file

@ -716,17 +716,22 @@ impl Server {
// config-overridable (see modules::customprefix) // config-overridable (see modules::customprefix)
let include_oper = self.conf_bool("operprefix", false) || self.conf_bool("ojoin", false); let include_oper = self.conf_bool("operprefix", false) || self.conf_bool("ojoin", false);
let prefix = crate::modules::customprefix::isupport(include_oper); let prefix = crate::modules::customprefix::isupport(include_oper);
let mut lines = vec![format!( let mut tokens: Vec<String> = 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={}", "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 self.network
)]; )
.split(' ')
.map(String::from)
.collect();
if let Some(tok) = crate::modules::network_icon::isupport(self) { 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) { 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 /// Emit the ISUPPORT numerics to `uid`. When `batched` (the client negotiated

View file

@ -326,6 +326,13 @@ fn io_loop<S: WsStream>(
Ok(Some((frame, consumed))) => { Ok(Some((frame, consumed))) => {
acc.drain(..consumed); acc.drain(..consumed);
match frame.opcode { 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_CLOSE => return,
OP_PING => { OP_PING => {
let _ = stream.write_all(&encode(OP_PONG, &frame.payload)); let _ = stream.write_all(&encode(OP_PONG, &frame.payload));