Harden deferred audit LOWs: provisioned-verifier cost cap, BOT-nick validation, gameserv board-delivery account guard, empty KICK/PART/QUIT reason parse, and relink SASL/enforce cleanup
All checks were successful
CI / check (push) Successful in 5m4s

This commit is contained in:
Jean Chevronnet 2026-07-20 04:40:42 +00:00
parent 1b569fdfa3
commit 4027242c1e
No known key found for this signature in database
13 changed files with 144 additions and 19 deletions

View file

@ -13,6 +13,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, "Syntax: BOT ADD <nick> <user> <host> [gecos]");
return;
};
if !echo_api::valid_nick(nick) {
ctx.notice(me, from.uid, t!(ctx, "\x02{nick}\x02 isn't a valid nickname.", nick = nick));
return;
}
let gecos = if args.len() > 5 { args[5..].join(" ") } else { "Service Bot".to_string() };
match db.bot_add(nick, user, host, &gecos) {
Ok(()) => ctx.notice(me, from.uid, t!(ctx, "Bot \x02{nick}\x02 (\x02{user}@{host}\x02) added.", nick = nick, user = user, host = host)),
@ -24,6 +28,10 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db:
ctx.notice(me, from.uid, "Syntax: BOT CHANGE <oldnick> <newnick> [user [host [gecos]]]");
return;
};
if !echo_api::valid_nick(newnick) {
ctx.notice(me, from.uid, t!(ctx, "\x02{nick}\x02 isn't a valid nickname.", nick = newnick));
return;
}
// Omitted fields keep the bot's current values.
let Some(cur) = db.bots().into_iter().find(|b| b.nick.eq_ignore_ascii_case(old)) else {
ctx.notice(me, from.uid, t!(ctx, "There's no bot named \x02{nick}\x02.", nick = old));

View file

@ -390,7 +390,7 @@ impl GameServ {
}
let mut all: Vec<(String, Stats)> = map.into_iter().filter(|(_, s)| s.points() > 0).collect();
all.sort_by_key(|(_, s)| std::cmp::Reverse(s.points()));
push_tag(me, net, from.nick, &format!("GS$TOPSTART {}", gt.wire()), ctx);
push_tag(me, net, from.nick, from.account.unwrap_or(""), from.account.is_some(), &format!("GS$TOPSTART {}", gt.wire()), ctx);
if all.is_empty() {
ctx.notice(me, from.uid, t!(ctx, "Classement \x02{game}\x02 vide. Sois le premier à gagner !", game = gt.label()));
return;
@ -401,7 +401,7 @@ impl GameServ {
let pos = format!("{rank:2}");
let name = format!("{acc:<16}");
ctx.notice(me, from.uid, t!(ctx, "{pos}. {name} {tier} {pts} pts", pos = pos, name = name, tier = s.rank(), pts = s.points()));
push_tag(me, net, from.nick, &format!("GS$TOPROW {} {rank} {acc} {} {}", gt.wire(), s.rank(), s.points()), ctx);
push_tag(me, net, from.nick, from.account.unwrap_or(""), from.account.is_some(), &format!("GS$TOPROW {} {rank} {acc} {} {}", gt.wire(), s.rank(), s.points()), ctx);
}
}
}
@ -409,18 +409,24 @@ impl GameServ {
// ── web-sync pushes (free functions so they never borrow the GameServ state) ──
// Client-only tag on a TAGMSG: invisible in chat, read only by the web client.
fn push_tag(me: &str, net: &dyn NetView, nick: &str, payload: &str, ctx: &mut ServiceCtx) {
if let Some(uid) = net.uid_by_nick(nick) {
ctx.actions.push(NetAction::Raw(format!("@+tchatou.fr/gs={} :{} TAGMSG {}", board::escape_tag(payload), me, uid)));
fn push_tag(me: &str, net: &dyn NetView, nick: &str, account: &str, registered: bool, payload: &str, ctx: &mut ServiceCtx) {
let Some(uid) = net.uid_by_nick(nick) else { return };
// A registered player's board goes only to their own account: if they renamed
// mid-game and a stranger grabbed the old nick, deliver to nobody rather than
// leak the position to the nick-thief. Guests are keyed by a mutable nick
// already (casual games), so best-effort by nick is all we can do for them.
if registered && net.account_of(uid) != Some(account) {
return;
}
ctx.actions.push(NetAction::Raw(format!("@+tchatou.fr/gs={} :{} TAGMSG {}", board::escape_tag(payload), me, uid)));
}
// The GS# state line for one side. `status_override` lets a challenge show as
// "pending" to the challenger and "offer" to the target.
fn push_one(g: &Game, viewer: Side, status_override: &str, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) {
let (nick, opp) = match viewer {
Side::A => (&g.nick_a, &g.nick_b),
Side::B => (&g.nick_b, &g.nick_a),
let (nick, opp, account, registered) = match viewer {
Side::A => (&g.nick_a, &g.nick_b, &g.acc_a, g.a_reg),
Side::B => (&g.nick_b, &g.nick_a, &g.acc_b, g.b_reg),
};
let status = if status_override.is_empty() { g.status.wire() } else { status_override };
let res = match (g.status, g.result) {
@ -432,7 +438,7 @@ fn push_one(g: &Game, viewer: Side, status_override: &str, me: &str, net: &dyn N
"GS# {} g={} s={} t={} me={} opp={} st={} r={}",
g.id, g.gtype().wire(), g.encode(), g.glyph(g.turn), g.glyph(viewer), opp, status, res
);
push_tag(me, net, nick, &payload, ctx);
push_tag(me, net, nick, account, registered, &payload, ctx);
}
fn push_state(g: &Game, me: &str, net: &dyn NetView, ctx: &mut ServiceCtx) {
@ -450,7 +456,9 @@ fn push_stats(counters: &[(String, u64)], account: &str, to_nick: &str, me: &str
_ => read_stats(counters, gt, account),
};
let payload = format!("GS$ {} {} r={} p={} w={} l={} d={}", account, gt.wire(), s.rank(), s.points(), s.wins, s.losses, s.draws);
push_tag(me, net, to_nick, &payload, ctx);
// push_stats is only called for a registered side (a_reg/b_reg), so the
// account guard in push_tag always applies here.
push_tag(me, net, to_nick, account, true, &payload, ctx);
}
}

View file

@ -195,7 +195,7 @@ impl Protocol for InspIrcd {
// :<uid> PART <chan> [:reason] — a user leaving a channel.
"PART" => match (source.as_deref(), tokens.next()) {
(Some(uid), Some(chan)) if chan.starts_with('#') => {
vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string(), reason: trailing(rest) }]
vec![NetEvent::Part { uid: uid.to_string(), channel: chan.to_string(), reason: reason(rest) }]
}
_ => vec![],
},
@ -204,7 +204,7 @@ impl Protocol for InspIrcd {
let chan = tokens.next().unwrap_or("");
match tokens.next() {
Some(uid) if chan.starts_with('#') => {
vec![NetEvent::Kicked { channel: chan.to_string(), uid: uid.to_string(), by: source.unwrap_or_default(), reason: trailing(rest) }]
vec![NetEvent::Kicked { channel: chan.to_string(), uid: uid.to_string(), by: source.unwrap_or_default(), reason: reason(rest) }]
}
_ => vec![],
}
@ -291,7 +291,7 @@ impl Protocol for InspIrcd {
_ => vec![],
}
}
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default(), reason: trailing(rest) }],
"QUIT" => vec![NetEvent::Quit { uid: source.unwrap_or_default(), reason: reason(rest) }],
// :<src> KILL <uid> :<reason> — a user forcibly removed. We forget
// them (or reintroduce, if it was one of our bots).
"KILL" => match tokens.next() {
@ -683,6 +683,17 @@ fn trailing(rest: &str) -> String {
}
}
// A free-text reason param (PART/KICK/QUIT), which the ircd always sends as a
// `:`-prefixed trailing. Unlike `trailing()`, a MISSING reason yields "" rather
// than the last preceding word — so `KICK #c target` (no reason) doesn't record
// the target uid as the reason.
fn reason(rest: &str) -> String {
match rest.find(" :") {
Some(i) => rest[i + 2..].to_string(),
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -691,6 +702,24 @@ mod tests {
InspIrcd::new("services.test".into(), "Federated Services".into(), "42S".into(), "pw".into(), 1206, 1, "iHkBT".into())
}
// A KICK/PART/QUIT with no explicit `:reason` must record an EMPTY reason,
// not the last preceding word (a channel/target token).
#[test]
fn kick_without_reason_is_empty_not_the_target() {
let ev = proto().parse(":42SAAAAAB KICK #chan 0IRAAAAAB");
assert!(
matches!(ev.as_slice(), [NetEvent::Kicked { reason, .. }] if reason.is_empty()),
"{ev:?}"
);
let ev = proto().parse(":42SAAAAAB KICK #chan 0IRAAAAAB :flooding");
assert!(
matches!(ev.as_slice(), [NetEvent::Kicked { reason, .. }] if reason == "flooding"),
"{ev:?}"
);
let ev = proto().parse(":0IRAAAAAB QUIT");
assert!(matches!(ev.as_slice(), [NetEvent::Quit { reason, .. }] if reason.is_empty()), "{ev:?}");
}
// A remote nick change must surface as a NickChange for the source uid, or
// nick-based commands act on a stale nick.
#[test]