From 4e12531815eed1f13fdcf429d8e22595a4408628 Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 23:08:30 +0000 Subject: [PATCH] DebugServ: professional, sourced auth feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the AUTH feed for an admin log channel. Drop the check/cross icons; each line now reads "account login ok|FAILED - mechanism - from source [- reason]". The important fix is the source: mid-SASL the client isn't a connected user yet, so the old lines showed "?" and a cloaked host — capture the real host+IP the ircd hands us in the SASL H message instead, and fall back to the live user's nick@host for a NickServ IDENTIFY. Failures now always name the account (which is attacker-supplied on a bad attempt, so it's stripped of control codes and capped before it goes in the channel message). Also emit an echo::auth info log so auth events land on disk, not only in the channel. No secret is ever included. --- src/engine/mod.rs | 62 ++++++++++++++++++++++++++++++++++++++---- src/engine/register.rs | 6 ++-- src/engine/sasl.rs | 14 +++++++--- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/src/engine/mod.rs b/src/engine/mod.rs index ec05374..b9e7298 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -92,6 +92,7 @@ pub struct Engine { network: Network, db: Db, sasl_sessions: HashMap, // client uid -> in-progress exchange + sasl_source: HashMap, // client uid -> real host/IP from the SASL H message reg_limiter: RegLimiter, chan_service: Option, // uid to source channel modes from (ChanServ) nick_service: Option, // uid of the account service (NickServ), for its notices @@ -226,6 +227,7 @@ impl Engine { network, db, sasl_sessions: HashMap::new(), + sasl_source: HashMap::new(), reg_limiter: RegLimiter::new(), chan_service, nick_service, @@ -448,22 +450,52 @@ impl Engine { } // Finish a SASL login, unless the account is suspended. + // Where a login attempt came from, for the auth feed: the client's real + // host/IP from the SASL H message while mid-SASL (the client isn't a + // connected user yet), or the live user's nick@host for a NickServ IDENTIFY. + fn auth_source(&self, client: &str) -> String { + if let Some((_, src)) = self.sasl_source.get(client) { + return safe(src); + } + match (self.network.nick_of(client), self.network.host_of(client)) { + (Some(nick), Some(host)) => format!("{}@{}", safe(nick), safe(host)), + (Some(nick), None) => safe(nick), + _ => "unknown".to_string(), + } + } + + // One login attempt -> a clean admin feed line + an on-disk audit record. + // Covers every mechanism (SASL PLAIN/SCRAM/EXTERNAL, NickServ IDENTIFY). + // `account` is who they authenticated as (or tried to) — it can be attacker- + // supplied on a failure, so it is sanitised; no secret is ever included. + fn auth_report(&self, ok: bool, account: Option<&str>, mech: &str, client: &str, reason: Option<&str>) -> Option { + let source = self.auth_source(client); + let acct = safe(account.unwrap_or("*")); + tracing::info!(target: "echo::auth", ok, account = %acct, mech, source = %source, reason = reason.unwrap_or("-"), "login attempt"); + let body = if ok { + format!("\x02{acct}\x02 login ok · {mech} · from {source}") + } else { + format!("\x02{acct}\x02 login \x02FAILED\x02 · {mech} · from {source} · {}", reason.unwrap_or("denied")) + }; + self.feed("AUTH", body) + } + fn sasl_login(&self, mech: &str, agent: &str, client: &str, account: String) -> Vec { if self.db.is_suspended(&account) { let mut out = sasl_fail(agent, client); - out.extend(self.feed("AUTH", format!("\x0304✗\x03 {} — {mech} rejected for \x02{account}\x02 (account suspended)", self.who(client)))); + out.extend(self.auth_report(false, Some(&account), mech, client, Some("account suspended"))); return out; } let mut out = sasl_success(agent, client, account.clone()); - out.extend(self.feed("AUTH", format!("\x0303✓\x03 {} authenticated as \x02{account}\x02 via {mech}", self.who(client)))); + out.extend(self.auth_report(true, Some(&account), mech, client, None)); out } - // A SASL rejection plus a DebugServ line naming why — for the credential + // A SASL rejection plus an auth-feed line naming why — for the credential // failures worth surfacing (bad password, bad/unknown cert, unknown account). - fn sasl_deny(&self, mech: &str, agent: &str, client: &str, reason: &str) -> Vec { + fn sasl_deny(&self, mech: &str, agent: &str, client: &str, account: Option<&str>, reason: &str) -> Vec { let mut out = sasl_fail(agent, client); - out.extend(self.feed("AUTH", format!("\x0304✗\x03 {} — {mech} failed ({reason})", self.who(client)))); + out.extend(self.auth_report(false, account, mech, client, Some(reason))); out } @@ -849,11 +881,24 @@ impl Engine { self.sasl_sessions.insert(client, TimedSession { touched: Instant::now(), session }); } + // Remember a client's real host/IP from the SASL H message so the auth feed + // can show where the login came from (the client isn't a connected user yet, + // so the network map can't resolve it). Host and IP arrive as two fields. + fn remember_sasl_source(&mut self, client: &str, data: &[String]) { + let src = match (data.first(), data.get(1)) { + (Some(host), Some(ip)) if host != ip => format!("{host} [{ip}]"), + (Some(only), _) => only.clone(), + _ => return, + }; + self.sasl_source.insert(client.to_string(), (Instant::now(), src)); + } + // Evict exchanges idle past the TTL. Run before starting a new one so a // dropped-mid-auth client the ircd never reported cannot pile up. fn sweep_sasl(&mut self) { let now = Instant::now(); self.sasl_sessions.retain(|_, t| now.duration_since(t.touched) < SASL_SESSION_TTL); + self.sasl_source.retain(|_, (t, _)| now.duration_since(*t) < SASL_SESSION_TTL); } // Sent right after the SERVER line: burst, introduce every service, endburst. @@ -1538,6 +1583,13 @@ fn decode_plain(b64: &str) -> Option<(String, String)> { } // Report SASL failure to the ircd (drives 904). +// Strip control codes (colour/bold, CR/LF, NUL) and cap the length before a +// string goes into a feed line. An account name on a failed attempt is +// attacker-supplied, so it must never inject into the channel message. +fn safe(s: &str) -> String { + s.chars().filter(|c| !c.is_control()).take(48).collect() +} + fn sasl_fail(agent: &str, client: &str) -> Vec { vec![NetAction::Sasl { agent: agent.to_string(), client: client.to_string(), mode: "D".to_string(), data: vec!["F".to_string()] }] } diff --git a/src/engine/register.rs b/src/engine/register.rs index 202649f..0e4783a 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -305,9 +305,9 @@ impl Engine { self.bump(&key); } let feed = if ok { - self.feed("AUTH", format!("\x0303✓\x03 {} identified to \x02{account}\x02 via IDENTIFY", self.who(&uid))) + self.auth_report(true, Some(&account), "NickServ IDENTIFY", &uid, None) } else { - self.feed("AUTH", format!("\x0304✗\x03 {} — IDENTIFY failed for \x02{name}\x02 (bad password)", self.who(&uid))) + self.auth_report(false, Some(&name), "NickServ IDENTIFY", &uid, Some("bad password")) }; let mut actions = ctx.actions; actions.extend(feed); @@ -317,7 +317,7 @@ impl Engine { if ok { self.sasl_login("SASL PLAIN", &agent, &client, account) } else { - self.sasl_deny("SASL PLAIN", &agent, &client, "bad password") + self.sasl_deny("SASL PLAIN", &agent, &client, Some(&account), "bad password") } } } diff --git a/src/engine/sasl.rs b/src/engine/sasl.rs index b27e989..addfd30 100644 --- a/src/engine/sasl.rs +++ b/src/engine/sasl.rs @@ -14,7 +14,12 @@ impl Engine { vec![NetAction::Sasl { agent: agent.clone(), client: client.clone(), mode: mode.to_string(), data: d }] }; match mode.as_str() { - "H" => Vec::new(), // host info + "H" => { + // The ircd sends the client's real host + IP before the exchange; + // keep it for the auth feed (the client isn't connected yet). + self.remember_sasl_source(&client, &data); + Vec::new() + } "S" => { self.sweep_sasl(); if self.sasl_sessions.len() >= MAX_SASL_SESSIONS { @@ -80,7 +85,7 @@ impl Engine { }], None => { let mut out = mk("D", vec!["F".to_string()]); - out.extend(self.feed("AUTH", format!("\x0304✗\x03 {} — SASL PLAIN failed for \x02{authcid}\x02 (unknown account)", self.who(&client)))); + out.extend(self.auth_report(false, Some(&authcid), "SASL PLAIN", &client, Some("no such account"))); out } }, @@ -93,6 +98,7 @@ impl Engine { } "D" => { self.sasl_sessions.remove(&client); + self.sasl_source.remove(&client); Vec::new() } _ => Vec::new(), @@ -161,7 +167,7 @@ impl Engine { } else { match STANDARD.decode(chunk).ok().and_then(|b| String::from_utf8(b).ok()) { Some(a) => a, - None => return self.sasl_deny("SASL EXTERNAL", agent, client, "malformed authzid"), + None => return self.sasl_deny("SASL EXTERNAL", agent, client, None, "malformed authzid"), } }; match fingerprints.iter().find_map(|fp| self.db.certfp_owner(fp)) { @@ -169,7 +175,7 @@ impl Engine { let account = account.to_string(); self.sasl_login("SASL EXTERNAL", agent, client, account) } - _ => self.sasl_deny("SASL EXTERNAL", agent, client, "unknown certificate"), + _ => self.sasl_deny("SASL EXTERNAL", agent, client, None, "unknown certificate"), } } }