From d833dc28f0450e598188f75c92609c192bfd204a Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 13:24:46 +0000 Subject: [PATCH] Fix duration overflow, expiry-warned resets, DICT control-char injection, and HostServ TAKE forbid recheck --- api/src/lib.rs | 4 +++- modules/hostserv/src/take.rs | 6 ++++++ src/dict.rs | 5 ++++- src/engine/db/account.rs | 2 ++ src/engine/db/mod.rs | 5 +++++ 5 files changed, 20 insertions(+), 2 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 3c6ae17..b075c67 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -2354,7 +2354,9 @@ pub fn parse_duration(s: &str) -> Option { c if c.is_ascii_digit() => (s, 1), _ => return None, }; - num.parse::().ok().map(|n| n * mult) + // Checked so an out-of-range duration is rejected (None) rather than wrapping to + // a garbage/near-instant expiry in a release build. + num.parse::().ok().and_then(|n| n.checked_mul(mult)) } // Case-insensitive hostmask glob: `*` (any run) and `?` (one char). diff --git a/modules/hostserv/src/take.rs b/modules/hostserv/src/take.rs index f0929f2..0373088 100644 --- a/modules/hostserv/src/take.rs +++ b/modules/hostserv/src/take.rs @@ -21,6 +21,12 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; } }; + // A menu offer can outlive a later FORBID; re-check at grant time like the + // request/approve/default paths, or a stale offer bypasses the impersonation guard. + if db.vhost_is_forbidden(&host) { + ctx.notice(me, from.uid, format!("\x02{host}\x02 isn't allowed here. Please choose another.")); + return; + } match db.set_vhost(account, &host, "offer", None) { Ok(()) => { ctx.apply_vhost(from.uid, &host); diff --git a/src/dict.rs b/src/dict.rs index c179e16..f9fe4c0 100644 --- a/src/dict.rs +++ b/src/dict.rs @@ -96,7 +96,10 @@ fn parse_definition(raw: &str) -> Option { break; } } - let body: String = body.split_whitespace().collect::>().join(" "); + // Drop control characters an untrusted/MITM'd DICT server could embed (CTCP + // \x01, color/format \x02\x03, NUL truncation) before this is spoken into a + // channel. split_whitespace already removed CR/LF/TAB. + let body: String = body.split_whitespace().collect::>().join(" ").chars().filter(|c| !c.is_control()).collect(); if body.is_empty() { return None; } diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 9e624a4..7f61bf9 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -734,6 +734,7 @@ impl Db { let _ = self.log.append(Event::AccountSeen { account: account.to_string(), ts: now }); if let Some(a) = self.accounts.get_mut(&k) { a.last_seen = a.last_seen.max(now); + a.expiry_warned = false; // mirror the AccountSeen fold, or a re-active account never re-warns } } } @@ -747,6 +748,7 @@ impl Db { let _ = self.log.append(Event::ChannelUsed { channel: channel.to_string(), ts: now }); if let Some(c) = self.channels.get_mut(&k) { c.last_used = c.last_used.max(now); + c.expiry_warned = false; // mirror the ChannelUsed fold, or a re-active channel never re-warns } } } diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index dbe6316..1ccf66d 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -1258,6 +1258,11 @@ impl Db { if c.noexpire { snapshot.push(Event::ChannelNoExpire { channel: c.name.clone(), on: true }); } + if c.expiry_warned { + // Preserve the "warning already sent" flag, or compaction re-arms it + // and the idle channel is warned again on the next sweep. + snapshot.push(Event::ChannelExpiryWarned { channel: c.name.clone() }); + } if c.settings != ChanSettings::default() { snapshot.push(Event::ChannelSettingsSet { channel: c.name.clone(), settings: c.settings }); }