From 89ac01d0b07d75fc8e4ee8dbdb44c3b2e68922e9 Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 19 Jul 2026 00:15:08 +0000 Subject: [PATCH] nickserv/chanserv: catch styled/fullwidth look-alike names too, and add a [register] confusable_check toggle (REHASH-reloadable) --- api/src/lib.rs | 16 ++++++++++++++++ config.example.toml | 9 +++++++++ modules/chanserv/src/lib.rs | 10 ++++++---- modules/nickserv/src/lib.rs | 2 +- modules/nickserv/src/register.rs | 15 +++++++++------ src/config.rs | 19 +++++++++++++++++++ src/engine/db/mod.rs | 3 ++- src/engine/db/network.rs | 5 +++++ src/engine/db/store.rs | 3 +++ src/engine/mod.rs | 1 + src/main.rs | 1 + 11 files changed, 72 insertions(+), 12 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 0075b3b..61d1c30 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -1935,6 +1935,11 @@ pub trait Store { // Whether account identity is owned externally (website); when true, IRC // can't register or change credentials — it only authenticates. fn external_accounts(&self) -> bool; + // Whether REGISTER should reject look-alike / mixed-script names ([register] + // confusable_check). Defaults on; a mock store need not override it. + fn confusable_check_enabled(&self) -> bool { + true + } // Staff notes on accounts/channels (oper-only), shown in INFO to operators. fn set_account_note(&mut self, account: &str, note: Option) -> bool; fn account_note(&self, account: &str) -> Option; @@ -2175,6 +2180,12 @@ pub fn confusable_reason(name: &str) -> Option<&'static str> { fn is_invisible(cp: u32) -> bool { matches!(cp, 0xAD | 0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060 | 0x2066..=0x2069 | 0xFEFF) } + // "Styled" Latin that renders as normal-looking ASCII: fullwidth (Admin), + // mathematical alphanumerics (𝐚𝐝𝐦𝐢𝐧), and enclosed/circled/squared letters. + // Nobody types a whole name this way except to imitate one. + fn is_fancy_latin(cp: u32) -> bool { + matches!(cp, 0xFF21..=0xFF5A | 0x1D400..=0x1D7FF | 0x1F130..=0x1F189 | 0x24B6..=0x24E9 | 0x2460..=0x24FF) + } // Non-Latin letters that look like an ASCII Latin letter — the homoglyphs a // spoofer swaps in. Catches an all-look-alike name that pure script-mixing // would miss (e.g. an entirely-Cyrillic name that reads as Latin). @@ -2198,6 +2209,9 @@ pub fn confusable_reason(name: &str) -> Option<&'static str> { if is_invisible(cp) { return Some("That name uses invisible or text-direction characters. Please choose a plain name."); } + if is_fancy_latin(cp) { + return Some("That name uses styled look-alike letters (fullwidth or symbol fonts). Please choose a plain name."); + } if let Some((script, id)) = script_id(cp) { letters += 1; if script == Script::Latin { @@ -2349,6 +2363,8 @@ mod tests { "аеосх", // all-Cyrillic Latin look-alikes — homoglyph "ad\u{200b}min", // zero-width space "\u{202e}nimda", // right-to-left override + "Admin", // fullwidth Latin + "\u{1D41A}\u{1D41D}\u{1D426}\u{1D422}\u{1D427}", // 𝐚𝐝𝐦𝐢𝐧 mathematical bold ] { assert!(confusable_reason(bad).is_some(), "should be rejected: {bad}"); } diff --git a/config.example.toml b/config.example.toml index d7cc507..d73bf7b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -103,3 +103,12 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # channel is unaffected; keep this off if you don't want echo talking to dict.org. # [dictserv] # server = "dict.org:2628" + +# Registration policy. The look-alike guard refuses REGISTER of a nick or channel +# that mixes alphabets (Cyrillic "аdmin"), is built from homoglyphs or styled +# (fullwidth/math) letters imitating Latin, or hides invisible/bidi characters — +# while genuine monolingual text (including accented French) passes. It's on by +# default; turn it off for a community that legitimately uses mixed/non-Latin +# names. Reloadable with OperServ REHASH. +# [register] +# confusable_check = false diff --git a/modules/chanserv/src/lib.rs b/modules/chanserv/src/lib.rs index af7ec43..5401e7c 100644 --- a/modules/chanserv/src/lib.rs +++ b/modules/chanserv/src/lib.rs @@ -132,10 +132,12 @@ impl Service for ChanServ { return; } // Refuse a look-alike / mixed-script channel name (e.g. a Cyrillic - // homoglyph of a real channel). - if let Some(reason) = echo_api::confusable_reason(chan) { - ctx.notice(me, from.uid, reason); - return; + // homoglyph of a real channel), unless the guard is turned off. + if db.confusable_check_enabled() { + if let Some(reason) = echo_api::confusable_reason(chan) { + ctx.notice(me, from.uid, reason); + return; + } } match db.register_channel(chan, account) { Ok(()) => { diff --git a/modules/nickserv/src/lib.rs b/modules/nickserv/src/lib.rs index b33f03d..e7ed0ef 100644 --- a/modules/nickserv/src/lib.rs +++ b/modules/nickserv/src/lib.rs @@ -110,7 +110,7 @@ impl Service for NickServ { return; } match cmd.as_deref() { - Some("REGISTER") => register::handle(me, from, args, ctx), + Some("REGISTER") => register::handle(me, from, args, ctx, db), Some("IDENTIFY") | Some("ID") => identify::handle(me, from, args, ctx, db), Some("LOGOUT") | Some("LOGOFF") => logout::handle(me, &self.guest_nick, &mut self.guest_seq, from, ctx), Some("CERT") => cert::handle(me, from, args, ctx, db), diff --git a/modules/nickserv/src/register.rs b/modules/nickserv/src/register.rs index 1ca2fbd..cc13322 100644 --- a/modules/nickserv/src/register.rs +++ b/modules/nickserv/src/register.rs @@ -1,9 +1,9 @@ -use echo_api::{Sender, ServiceCtx}; +use echo_api::{Sender, ServiceCtx, Store}; use echo_api::RegReply; // REGISTER [email]: register the sender's current nick. The engine // derives the password off-thread, commits, and answers. -pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) { +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &dyn Store) { let Some(password) = args.get(1) else { ctx.notice(me, from.uid, "Syntax: REGISTER [email]"); return; @@ -12,10 +12,13 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx) { ctx.notice(me, from.uid, reason); return; } - // Refuse a look-alike / mixed-script nick before it can be used to impersonate. - if let Some(reason) = echo_api::confusable_reason(from.nick) { - ctx.notice(me, from.uid, reason); - return; + // Refuse a look-alike / mixed-script nick before it can be used to impersonate, + // unless the guard is turned off. + if db.confusable_check_enabled() { + if let Some(reason) = echo_api::confusable_reason(from.nick) { + ctx.notice(me, from.uid, reason); + return; + } } let email = args.get(2).map(|s| s.to_string()); ctx.defer_register(from.nick, *password, email, RegReply::NickServ { diff --git a/src/config.rs b/src/config.rs index 296c419..8f28b20 100644 --- a/src/config.rs +++ b/src/config.rs @@ -49,6 +49,9 @@ pub struct Config { // not load and echo makes no outbound lookup requests. Opt-in on purpose. #[serde(default)] pub dictserv: Option, + // Registration policy. Absent = defaults (the look-alike guard is on). + #[serde(default)] + pub register: Register, // Which InspIRCd matching-extbans AKICK may use. Absent = every extban echo // knows (full compatibility). List `enabled` to restrict it — e.g. drop the // ones your ircd doesn't provide. @@ -85,6 +88,22 @@ fn default_dict_server() -> String { "dict.org:2628".to_string() } +// Registration policy. +#[derive(Debug, Deserialize, Clone)] +#[serde(default)] +pub struct Register { + // Reject look-alike / mixed-script / invisible registration names. On by + // default; turn it off for a community that legitimately uses mixed or + // non-Latin names. Reloadable with REHASH. + pub confusable_check: bool, +} + +impl Default for Register { + fn default() -> Self { + Self { confusable_check: true } + } +} + // Account-authority configuration. #[derive(Debug, Deserialize, Clone)] pub struct Auth { diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 0728da0..5346f4c 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -1056,6 +1056,7 @@ pub struct Db { // website): IRC can't register or change credentials, only authenticate // against accounts pushed in (via the gRPC Accounts API). Node-local config. external_accounts: bool, + confusable_check: bool, // reject look-alike / mixed-script REGISTER names } // A juped server: the held name, the fake server id we allocated for it, and why. @@ -1124,7 +1125,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false } + Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true } } /// Fold an entry authored by another node into the store — the services-side diff --git a/src/engine/db/network.rs b/src/engine/db/network.rs index b24ba3f..398d862 100644 --- a/src/engine/db/network.rs +++ b/src/engine/db/network.rs @@ -338,6 +338,11 @@ impl Db { self.external_accounts = on; } + /// Toggle the look-alike / mixed-script REGISTER guard (from `[register]`). + pub fn set_confusable_check(&mut self, on: bool) { + self.confusable_check = on; + } + /// The network defence level (5 = normal, 1 = full lockdown). pub fn defcon(&self) -> u8 { self.defcon diff --git a/src/engine/db/store.rs b/src/engine/db/store.rs index 310fe35..7d07803 100644 --- a/src/engine/db/store.rs +++ b/src/engine/db/store.rs @@ -320,6 +320,9 @@ impl Store for Db { fn external_accounts(&self) -> bool { Db::external_accounts(self) } + fn confusable_check_enabled(&self) -> bool { + self.confusable_check + } fn set_account_note(&mut self, account: &str, note: Option) -> bool { Db::set_account_note(self, account, note) } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 305eb2b..3e39528 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -897,6 +897,7 @@ impl Engine { self.set_standard_replies(cfg.server.standard_replies); self.set_log_channel(cfg.log.as_ref().map(|l| l.channel.clone())); self.db.set_notify_exclude(cfg.log.as_ref().map(|l| l.notify_exclude.clone()).unwrap_or_default()); + self.db.set_confusable_check(cfg.register.confusable_check); self.set_guest_nick(&cfg.server.guest_nick); if let Some(expire) = &cfg.expire { self.set_expiry(expire.account_ttl(), expire.channel_ttl(), expire.warn_ttl()); diff --git a/src/main.rs b/src/main.rs index d9bf8ed..5cc7e2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -186,6 +186,7 @@ async fn main() -> Result<()> { db.set_outbound(gossip_tx.clone()); db.set_email_enabled(cfg.email.is_some()); db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external)); + db.set_confusable_check(cfg.register.confusable_check); match cfg.extban.as_ref().map(|e| e.enabled.as_slice()) { Some(list) if !list.is_empty() => { db.set_extban_enabled(list.to_vec());