harden: fix reachable panics (parse_duration/parse_iso/dechunk char-boundary+overflow), s2s netburst key/limit loss, rpc set_nick/set_vhost/notice injection, webirc rehash reload, panic-state reset, ws line cap, remote nick collision, per-conn state leaks
This commit is contained in:
parent
3fa9737ccb
commit
e935ee7002
12 changed files with 87 additions and 11 deletions
|
|
@ -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() {
|
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,
|
// These extbans carry a name / spec / channel / server, not a host mask,
|
||||||
// so they must not be host-normalised: g: (security group), y: (reputation
|
// 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') {
|
if matches!(b[0], b'g' | b'y' | b'r' | b'j' | b's' | b'G' | b'b') {
|
||||||
return m.to_string();
|
return m.to_string();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
src/http.rs
21
src/http.rs
|
|
@ -84,19 +84,24 @@ pub fn post(
|
||||||
/// Decode an HTTP/1.1 chunked body (best effort). Used for both outbound response
|
/// Decode an HTTP/1.1 chunked body (best effort). Used for both outbound response
|
||||||
/// bodies here and inbound request bodies in the RPC httpd.
|
/// bodies here and inbound request bodies in the RPC httpd.
|
||||||
pub fn dechunk(body: &str) -> String {
|
pub fn dechunk(body: &str) -> String {
|
||||||
let mut out = String::new();
|
// Work on bytes, not the &str: the chunk size is an attacker-supplied byte
|
||||||
let mut rest = body;
|
// count and may land mid-UTF-8-character, so str slicing would panic.
|
||||||
while let Some((size_line, after)) = rest.split_once("\r\n") {
|
let mut out: Vec<u8> = Vec::new();
|
||||||
let size = usize::from_str_radix(size_line.trim().split(';').next().unwrap_or("0"), 16)
|
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);
|
.unwrap_or(0);
|
||||||
|
let after = &rest[nl + 2..];
|
||||||
if size == 0 || after.len() < size {
|
if size == 0 || after.len() < size {
|
||||||
out.push_str(&after[..after.len().min(size)]);
|
out.extend_from_slice(&after[..after.len().min(size)]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
out.push_str(&after[..size]);
|
out.extend_from_slice(&after[..size]);
|
||||||
rest = after[size..].strip_prefix("\r\n").unwrap_or(&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.
|
/// application/x-www-form-urlencoded escape of a single value.
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,10 @@ impl Ircd {
|
||||||
{
|
{
|
||||||
// the default panic hook already logged the details to stderr
|
// the default panic hook already logged the details to stderr
|
||||||
eprintln!("[core] recovered from a panicking event handler; continuing");
|
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);
|
busy.store(0, Ordering::Relaxed);
|
||||||
let ms = start.elapsed().as_millis() as u64;
|
let ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
|
||||||
17
src/link.rs
17
src/link.rs
|
|
@ -898,6 +898,12 @@ impl Server {
|
||||||
let Some(newnick) = msg.params.first().cloned() else {
|
let Some(newnick) = msg.params.first().cloned() else {
|
||||||
return;
|
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) {
|
let old = match self.remote_users.get_mut(&uuid) {
|
||||||
Some(ru) => {
|
Some(ru) => {
|
||||||
let old = ru.nick.clone();
|
let old = ru.nick.clone();
|
||||||
|
|
@ -1411,7 +1417,7 @@ impl Server {
|
||||||
let chan = msg.params[0].clone();
|
let chan = msg.params[0].clone();
|
||||||
let key = chan.to_ascii_lowercase();
|
let key = chan.to_ascii_lowercase();
|
||||||
let victim = msg.params[1].clone();
|
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 prefix = self.uuid_prefix(&src).unwrap_or_default();
|
||||||
let mut removed = false;
|
let mut removed = false;
|
||||||
let vnick;
|
let vnick;
|
||||||
|
|
@ -1694,6 +1700,15 @@ impl Server {
|
||||||
ch.modes.render(false),
|
ch.modes.render(false),
|
||||||
mem.join(" ")
|
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
|
// burst the ban / except / invite-exception lists as timestamped mode
|
||||||
// changes sourced from this server
|
// changes sourced from this server
|
||||||
for (letter, list) in [('b', &ch.bans), ('e', &ch.excepts), ('I', &ch.invex)] {
|
for (letter, list) in [('b', &ch.bans), ('e', &ch.excepts), ('I', &ch.invex)] {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,11 @@ impl Module for BlockAmsg {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"blockamsg"
|
"blockamsg"
|
||||||
}
|
}
|
||||||
|
fn on_user_quit(&mut self, s: &mut Server, uid: Uid, _reason: &str) {
|
||||||
|
if let Some(m) = s.ext.get_mut::<LastMsg>() {
|
||||||
|
m.0.remove(&uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn on_pre_command(
|
fn on_pre_command(
|
||||||
&mut self,
|
&mut self,
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,11 @@ impl Module for CloudflareChallenge {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"cloudflare_challenge"
|
"cloudflare_challenge"
|
||||||
}
|
}
|
||||||
|
fn on_user_quit(&mut self, s: &mut Server, uid: Uid, _reason: &str) {
|
||||||
|
if let Some(p) = s.ext.get_mut::<Passed>() {
|
||||||
|
p.0.remove(&uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult {
|
fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult {
|
||||||
if !enabled(srv) || srv.is_oper(uid) {
|
if !enabled(srv) || srv.is_oper(uid) {
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,11 @@ impl Module for ReCaptcha {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
"recaptcha"
|
"recaptcha"
|
||||||
}
|
}
|
||||||
|
fn on_user_quit(&mut self, s: &mut Server, uid: Uid, _reason: &str) {
|
||||||
|
if let Some(v) = s.ext.get_mut::<Verified>() {
|
||||||
|
v.0.remove(&uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult {
|
fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult {
|
||||||
if !enabled(srv) || srv.is_oper(uid) {
|
if !enabled(srv) || srv.is_oper(uid) {
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
|
||||||
.ok_or_else(|| RpcError::invalid_params("missing 'target'"))?;
|
.ok_or_else(|| RpcError::invalid_params("missing 'target'"))?;
|
||||||
let text = json::get_str(params, "message")
|
let text = json::get_str(params, "message")
|
||||||
.ok_or_else(|| RpcError::invalid_params("missing 'message'"))?;
|
.ok_or_else(|| RpcError::invalid_params("missing 'message'"))?;
|
||||||
|
// strip CR/LF so a crafted message/target can't inject extra IRC lines
|
||||||
|
let strip = |v: String| -> String { v.chars().filter(|c| *c != '\r' && *c != '\n').collect() };
|
||||||
|
let (target, text) = (strip(target), strip(text));
|
||||||
let src = s.name.clone();
|
let src = s.name.clone();
|
||||||
if target == "*" || target == "$*" {
|
if target == "*" || target == "$*" {
|
||||||
let uids: Vec<crate::Uid> = s.users.keys().copied().collect();
|
let uids: Vec<crate::Uid> = s.users.keys().copied().collect();
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,9 @@ pub fn handle(s: &mut Server, action: &str, params: &str) -> Result<String, RpcE
|
||||||
let host = json::get_str(params, "vhost")
|
let host = json::get_str(params, "vhost")
|
||||||
.or_else(|| json::get_str(params, "host"))
|
.or_else(|| json::get_str(params, "host"))
|
||||||
.ok_or_else(|| RpcError::invalid_params("missing 'vhost'"))?;
|
.ok_or_else(|| RpcError::invalid_params("missing 'vhost'"))?;
|
||||||
|
if !crate::users::valid_host(&host) {
|
||||||
|
return Err(RpcError::invalid_params("invalid vhost"));
|
||||||
|
}
|
||||||
s.change_host_ident(uid, None, Some(&host));
|
s.change_host_ident(uid, None, Some(&host));
|
||||||
Ok(obj(&[("result", "true".into())]))
|
Ok(obj(&[("result", "true".into())]))
|
||||||
}
|
}
|
||||||
|
|
@ -114,6 +117,16 @@ 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 newnick = json::get_str(params, "newnick")
|
let newnick = json::get_str(params, "newnick")
|
||||||
.ok_or_else(|| RpcError::invalid_params("missing 'newnick'"))?;
|
.ok_or_else(|| RpcError::invalid_params("missing 'newnick'"))?;
|
||||||
|
// validate + collision-check like the NICK / SVSNICK paths: an invalid or
|
||||||
|
// taken nick would otherwise inject into the wire or hijack the nick index
|
||||||
|
if !crate::users::valid_nick(&newnick, s.conf_num("maxnick", 30usize)) {
|
||||||
|
return Err(RpcError::invalid_params("invalid nick"));
|
||||||
|
}
|
||||||
|
if s.find_nick(&newnick).is_some_and(|o| o != uid)
|
||||||
|
|| s.remote_nick.contains_key(&newnick.to_ascii_lowercase())
|
||||||
|
{
|
||||||
|
return Err(RpcError::invalid_params("nick in use"));
|
||||||
|
}
|
||||||
s.set_nick(uid, &newnick);
|
s.set_nick(uid, &newnick);
|
||||||
Ok(obj(&[("result", "true".into())]))
|
Ok(obj(&[("result", "true".into())]))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,17 @@ pub fn parse_iso(s: &str) -> Option<u64> {
|
||||||
let h: i64 = t.next()?.parse().ok()?;
|
let h: i64 = t.next()?.parse().ok()?;
|
||||||
let mi: i64 = t.next()?.parse().ok()?;
|
let mi: i64 = t.next()?.parse().ok()?;
|
||||||
let se: i64 = t.next().unwrap_or("0").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
|
// civil date -> days since 1970-01-01
|
||||||
let yy = y - i64::from(mo <= 2);
|
let yy = y - i64::from(mo <= 2);
|
||||||
let era = if yy >= 0 { yy } else { yy - 399 } / 400;
|
let era = if yy >= 0 { yy } else { yy - 399 } / 400;
|
||||||
|
|
@ -255,6 +266,7 @@ impl Server {
|
||||||
self.dnsbl_action = fresh.dnsbl_action;
|
self.dnsbl_action = fresh.dnsbl_action;
|
||||||
self.dnsbl_reason = fresh.dnsbl_reason;
|
self.dnsbl_reason = fresh.dnsbl_reason;
|
||||||
self.sasl_server = fresh.sasl_server;
|
self.sasl_server = fresh.sasl_server;
|
||||||
|
self.webirc = fresh.webirc;
|
||||||
self.raw_config = fresh.raw;
|
self.raw_config = fresh.raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,12 @@ fn deliver(msg: &mut Vec<u8>, uid: Uid, core: &Sender<Event>) -> bool {
|
||||||
let text = String::from_utf8_lossy(msg);
|
let text = String::from_utf8_lossy(msg);
|
||||||
for piece in text.split('\n') {
|
for piece in text.split('\n') {
|
||||||
let l = piece.trim_end_matches('\r');
|
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()
|
if !l.is_empty()
|
||||||
&& core
|
&& core
|
||||||
.send(Event::Line {
|
.send(Event::Line {
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,9 @@ pub fn parse_duration(s: &str) -> Option<u64> {
|
||||||
if last.is_ascii_digit() {
|
if last.is_ascii_digit() {
|
||||||
return s.parse::<u64>().ok();
|
return s.parse::<u64>().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 {
|
let mul = match last {
|
||||||
's' => 1,
|
's' => 1,
|
||||||
'm' => 60,
|
'm' => 60,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue