DebugServ: professional, sourced auth feed
All checks were successful
CI / check (push) Successful in 3m54s
All checks were successful
CI / check (push) Successful in 3m54s
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.
This commit is contained in:
parent
f2fd80694d
commit
4e12531815
3 changed files with 70 additions and 12 deletions
|
|
@ -92,6 +92,7 @@ pub struct Engine {
|
|||
network: Network,
|
||||
db: Db,
|
||||
sasl_sessions: HashMap<String, TimedSession>, // client uid -> in-progress exchange
|
||||
sasl_source: HashMap<String, (Instant, String)>, // client uid -> real host/IP from the SASL H message
|
||||
reg_limiter: RegLimiter,
|
||||
chan_service: Option<String>, // uid to source channel modes from (ChanServ)
|
||||
nick_service: Option<String>, // 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<NetAction> {
|
||||
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<NetAction> {
|
||||
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<NetAction> {
|
||||
fn sasl_deny(&self, mech: &str, agent: &str, client: &str, account: Option<&str>, reason: &str) -> Vec<NetAction> {
|
||||
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<NetAction> {
|
||||
vec![NetAction::Sasl { agent: agent.to_string(), client: client.to_string(), mode: "D".to_string(), data: vec!["F".to_string()] }]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue