diff --git a/src/channels.rs b/src/channels.rs index 8b98492..01eb7cd 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -1288,7 +1288,8 @@ pub fn normalize_ban_mask(m: &str) -> String { if b.len() >= 2 && b[1] == b':' && (b[0] as char).is_ascii_alphabetic() { // These extbans carry a name / spec / channel / server, not a host mask, // so they must not be host-normalised: g: (security group), y: (reputation - // score), r: (realname), j: (channel), s: (server name). + // score), r: (realname), j: (channel), s: (server name), G: (country), + // b: (banned-in-channel). if matches!(b[0], b'g' | b'y' | b'r' | b'j' | b's' | b'G' | b'b') { return m.to_string(); } diff --git a/src/http.rs b/src/http.rs index 9ceeb3c..40bcd80 100644 --- a/src/http.rs +++ b/src/http.rs @@ -84,19 +84,24 @@ pub fn post( /// Decode an HTTP/1.1 chunked body (best effort). Used for both outbound response /// bodies here and inbound request bodies in the RPC httpd. pub fn dechunk(body: &str) -> String { - let mut out = String::new(); - let mut rest = body; - while let Some((size_line, after)) = rest.split_once("\r\n") { - let size = usize::from_str_radix(size_line.trim().split(';').next().unwrap_or("0"), 16) + // Work on bytes, not the &str: the chunk size is an attacker-supplied byte + // count and may land mid-UTF-8-character, so str slicing would panic. + let mut out: Vec = Vec::new(); + let mut rest = body.as_bytes(); + while let Some(nl) = rest.windows(2).position(|w| w == b"\r\n") { + let size = std::str::from_utf8(&rest[..nl]) + .ok() + .and_then(|s| usize::from_str_radix(s.trim().split(';').next().unwrap_or("0"), 16).ok()) .unwrap_or(0); + let after = &rest[nl + 2..]; if size == 0 || after.len() < size { - out.push_str(&after[..after.len().min(size)]); + out.extend_from_slice(&after[..after.len().min(size)]); break; } - out.push_str(&after[..size]); - rest = after[size..].strip_prefix("\r\n").unwrap_or(&after[size..]); + out.extend_from_slice(&after[..size]); + rest = after[size..].strip_prefix(b"\r\n").unwrap_or(&after[size..]); } - out + String::from_utf8_lossy(&out).into_owned() } /// application/x-www-form-urlencoded escape of a single value. diff --git a/src/ircd.rs b/src/ircd.rs index 349de26..9f9aaac 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -160,6 +160,10 @@ impl Ircd { { // the default panic hook already logged the details to stderr eprintln!("[core] recovered from a panicking event handler; continuing"); + // a panic mid-handler can leave transient per-command state set; + // clear it so it doesn't corrupt the next command + self.server.label_capture.borrow_mut().take(); + self.server.mode_sudo = false; } busy.store(0, Ordering::Relaxed); let ms = start.elapsed().as_millis() as u64; diff --git a/src/link.rs b/src/link.rs index d79d43a..39c5607 100644 --- a/src/link.rs +++ b/src/link.rs @@ -898,6 +898,12 @@ impl Server { let Some(newnick) = msg.params.first().cloned() else { return; }; + // collision with a local user: kill the local holder (same policy as an + // incoming UID clash) so the network converges to one owner for the nick + if let Some(luid) = self.find_nick(&newnick) { + self.send(luid, "ERROR :Closing link: Nick collision".to_string()); + self.remove_user(luid, "Nick collision"); + } let old = match self.remote_users.get_mut(&uuid) { Some(ru) => { let old = ru.nick.clone(); @@ -1411,7 +1417,7 @@ impl Server { let chan = msg.params[0].clone(); let key = chan.to_ascii_lowercase(); let victim = msg.params[1].clone(); - let reason = msg.params.get(2).cloned().unwrap_or_else(|| victim.clone()); + let reason = msg.params.get(2).cloned().unwrap_or_default(); let prefix = self.uuid_prefix(&src).unwrap_or_default(); let mut removed = false; let vnick; @@ -1694,6 +1700,15 @@ impl Server { ch.modes.render(false), mem.join(" ") )); + // render(false) puts parametric mode letters in the FJOIN without their + // values; burst the access-controlling ones (key, limit) as timestamped + // FMODEs so they survive netburst (the receiver's FMODE path is param-aware) + if let Some(k) = &ch.modes.key { + lines.push(format!(":{} FMODE {} {} +k {}", self.sid, ch.name, ch.created, k)); + } + if let Some(l) = ch.modes.limit { + lines.push(format!(":{} FMODE {} {} +l {}", self.sid, ch.name, ch.created, l)); + } // burst the ban / except / invite-exception lists as timestamped mode // changes sourced from this server for (letter, list) in [('b', &ch.bans), ('e', &ch.excepts), ('I', &ch.invex)] { diff --git a/src/modules/blockamsg.rs b/src/modules/blockamsg.rs index 1aa0402..74a136a 100644 --- a/src/modules/blockamsg.rs +++ b/src/modules/blockamsg.rs @@ -28,6 +28,11 @@ impl Module for BlockAmsg { fn name(&self) -> &'static str { "blockamsg" } + fn on_user_quit(&mut self, s: &mut Server, uid: Uid, _reason: &str) { + if let Some(m) = s.ext.get_mut::() { + m.0.remove(&uid); + } + } fn on_pre_command( &mut self, diff --git a/src/modules/cloudflare_challenge.rs b/src/modules/cloudflare_challenge.rs index a79a7c9..e198c5b 100644 --- a/src/modules/cloudflare_challenge.rs +++ b/src/modules/cloudflare_challenge.rs @@ -62,6 +62,11 @@ impl Module for CloudflareChallenge { fn name(&self) -> &'static str { "cloudflare_challenge" } + fn on_user_quit(&mut self, s: &mut Server, uid: Uid, _reason: &str) { + if let Some(p) = s.ext.get_mut::() { + p.0.remove(&uid); + } + } fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { if !enabled(srv) || srv.is_oper(uid) { diff --git a/src/modules/recaptcha.rs b/src/modules/recaptcha.rs index d65a5f8..480df32 100644 --- a/src/modules/recaptcha.rs +++ b/src/modules/recaptcha.rs @@ -71,6 +71,11 @@ impl Module for ReCaptcha { fn name(&self) -> &'static str { "recaptcha" } + fn on_user_quit(&mut self, s: &mut Server, uid: Uid, _reason: &str) { + if let Some(v) = s.ext.get_mut::() { + v.0.remove(&uid); + } + } fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { if !enabled(srv) || srv.is_oper(uid) { diff --git a/src/modules/rpc/message.rs b/src/modules/rpc/message.rs index a5ed34b..7c8ecde 100644 --- a/src/modules/rpc/message.rs +++ b/src/modules/rpc/message.rs @@ -12,6 +12,9 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result String { v.chars().filter(|c| *c != '\r' && *c != '\n').collect() }; + let (target, text) = (strip(target), strip(text)); let src = s.name.clone(); if target == "*" || target == "$*" { let uids: Vec = s.users.keys().copied().collect(); diff --git a/src/modules/rpc/user.rs b/src/modules/rpc/user.rs index c881ddf..8ba2dde 100644 --- a/src/modules/rpc/user.rs +++ b/src/modules/rpc/user.rs @@ -107,6 +107,9 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result Result Option { let h: i64 = t.next()?.parse().ok()?; let mi: i64 = t.next()?.parse().ok()?; let se: i64 = t.next().unwrap_or("0").parse().ok()?; + // bound every field to a sane range: keeps the arithmetic below well inside i64 + // (a client-supplied huge year/day would otherwise overflow and panic) + if !(0..=9999).contains(&y) + || !(1..=12).contains(&mo) + || !(1..=31).contains(&da) + || !(0..=23).contains(&h) + || !(0..=59).contains(&mi) + || !(0..=60).contains(&se) + { + return None; + } // civil date -> days since 1970-01-01 let yy = y - i64::from(mo <= 2); let era = if yy >= 0 { yy } else { yy - 399 } / 400; @@ -255,6 +266,7 @@ impl Server { self.dnsbl_action = fresh.dnsbl_action; self.dnsbl_reason = fresh.dnsbl_reason; self.sasl_server = fresh.sasl_server; + self.webirc = fresh.webirc; self.raw_config = fresh.raw; } diff --git a/src/websocket.rs b/src/websocket.rs index 1fd918c..e356110 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -402,6 +402,12 @@ fn deliver(msg: &mut Vec, uid: Uid, core: &Sender) -> bool { let text = String::from_utf8_lossy(msg); for piece in text.split('\n') { let l = piece.trim_end_matches('\r'); + // a WS frame can be far larger than a legal IRC line; drop an over-long + // line so the transport can't bypass the recvq/max-line flood guard that + // every TCP/TLS client is held to (16 KiB is generous — tags included) + if l.len() > 16 * 1024 { + continue; + } if !l.is_empty() && core .send(Event::Line { diff --git a/src/xline.rs b/src/xline.rs index f5030d3..1e0b360 100644 --- a/src/xline.rs +++ b/src/xline.rs @@ -69,7 +69,9 @@ pub fn parse_duration(s: &str) -> Option { if last.is_ascii_digit() { return s.parse::().ok(); } - let n: u64 = s[..s.len() - 1].parse().ok()?; + // strip the suffix by its char length, not one byte — a multi-byte final char + // (e.g. "5€") would otherwise slice mid-codepoint and panic + let n: u64 = s[..s.len() - last.len_utf8()].parse().ok()?; let mul = match last { 's' => 1, 'm' => 60,