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
/// (e.g. `0AAAAAAAB`).
pub fn next_uuid(&mut self) -> String {
loop {
let mut x = self.uuid_counter;
self.uuid_counter += 1;
let mut suffix = [b'A'; 6];
@ -86,11 +87,17 @@ impl Server {
*c = b'A' + (x % 26) as u8;
x /= 26;
}
format!(
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;
}
}
}
/// 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);
}
}

View file

@ -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 {

View file

@ -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());

View file

@ -42,6 +42,23 @@ fn json_arr(chars: impl IntoIterator<Item = char>) -> 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<Box<dyn Command>> {
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
);

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).
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

View file

@ -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>(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());

View file

@ -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)) {

View file

@ -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;
}

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 oper = json::get_str(params, "oper").or_else(|| json::get_str(params, "type"));
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
}
Ok(obj(&[("result", "true".into())]))

View file

@ -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<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={}",
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

View file

@ -326,6 +326,13 @@ fn io_loop<S: WsStream>(
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));