nickserv/chanserv: reject look-alike / mixed-script registration names (homoglyph impersonation guard)
All checks were successful
CI / check (push) Successful in 3m56s

This commit is contained in:
Jean Chevronnet 2026-07-19 00:02:44 +00:00
parent 03f9c2d3bd
commit c2b70f5004
No known key found for this signature in database
3 changed files with 110 additions and 0 deletions

View file

@ -2143,6 +2143,86 @@ pub trait Service: Send {
fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &dyn NetView, store: &mut dyn Store);
}
// Reject a look-alike / mixed-script registration name (nick or channel): the
// impersonation trick that a message-content filter never sees at the registration
// seam. Returns a rejection reason, or None if the name is fine. Genuine
// monolingual text in any script passes — including accented French, which is
// Latin — so only three things are caught: invisible/text-direction characters,
// mixing two alphabets in one name, and an all-homoglyph name imitating Latin.
pub fn confusable_reason(name: &str) -> Option<&'static str> {
#[derive(PartialEq, Clone, Copy)]
enum Script {
Latin,
Other, // Greek/Cyrillic/Armenian/Hebrew/Arabic/CJK — one distinct non-Latin script per range
}
// A letter's script, coarse: Latin vs a single non-Latin bucket per range. We
// only need "is it Latin" and "how many distinct scripts", so non-Latin ranges
// map to distinct discriminants via the range id.
fn script_id(cp: u32) -> Option<(Script, u8)> {
match cp {
0x41..=0x5A | 0x61..=0x7A | 0xC0..=0x24F => Some((Script::Latin, 0)),
0x370..=0x3FF => Some((Script::Other, 1)), // Greek
0x400..=0x4FF => Some((Script::Other, 2)), // Cyrillic
0x530..=0x58F => Some((Script::Other, 3)), // Armenian
0x590..=0x5FF => Some((Script::Other, 4)), // Hebrew
0x600..=0x6FF => Some((Script::Other, 5)), // Arabic
0x4E00..=0x9FFF | 0x3040..=0x30FF => Some((Script::Other, 6)), // CJK
_ => None,
}
}
// Invisible / zero-width / bidi-control characters: no legitimate use in a name,
// and a classic way to hide a spoof or reverse how a name renders.
fn is_invisible(cp: u32) -> bool {
matches!(cp, 0xAD | 0x180E | 0x200B..=0x200F | 0x202A..=0x202E | 0x2060 | 0x2066..=0x2069 | 0xFEFF)
}
// 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).
fn is_latin_confusable(cp: u32) -> bool {
matches!(cp,
0x0430 | 0x0410 | 0x0435 | 0x0415 | 0x043E | 0x041E | 0x0440 | 0x0420 |
0x0441 | 0x0421 | 0x0443 | 0x0423 | 0x0445 | 0x0425 | 0x0456 | 0x0406 |
0x0455 | 0x0405 | 0x0458 | 0x0408 | 0x043A | 0x041A | 0x043C | 0x041C |
0x043D | 0x041D | 0x0432 | 0x0412 | 0x0442 | 0x0422 |
0x03BF | 0x039F | 0x03B1 | 0x0391 | 0x03B5 | 0x0395 | 0x03C1 | 0x03A1 |
0x03C5 | 0x03A5 | 0x03BD | 0x03BA | 0x039A | 0x03B9 | 0x0399 | 0x03BC |
0x0392 | 0x039D | 0x03A4 | 0x0397 | 0x03A7 | 0x0396)
}
let mut scripts: Vec<u8> = Vec::new();
let mut has_latin = false;
let mut letters = 0usize;
let mut confusables = 0usize;
for ch in name.chars() {
let cp = ch as u32;
if is_invisible(cp) {
return Some("That name uses invisible or text-direction characters. Please choose a plain name.");
}
if let Some((script, id)) = script_id(cp) {
letters += 1;
if script == Script::Latin {
has_latin = true;
}
if !scripts.contains(&id) {
scripts.push(id);
}
if is_latin_confusable(cp) {
confusables += 1;
}
}
}
if scripts.len() > 1 {
return Some("That name mixes characters from different alphabets, a look-alike trick used to impersonate others. Please choose a plain name.");
}
// A single non-Latin script whose every letter is a Latin look-alike is a
// homoglyph spoof even without mixing; genuine monolingual text isn't all
// look-alikes, so it passes.
if letters > 0 && !has_latin && confusables == letters {
return Some("That name is made of look-alike characters imitating Latin letters. Please choose a plain name.");
}
None
}
// Parse a duration like "30d", "12h", "45m", "90s", or bare seconds. Shared by
// the SUSPEND commands.
pub fn parse_duration(s: &str) -> Option<u64> {
@ -2255,6 +2335,25 @@ pub fn human_time(ts: u64) -> String {
mod tests {
use super::*;
#[test]
fn confusable_names_rejected_but_french_passes() {
// Genuine names pass — including accented French (which is Latin), other
// monolingual scripts, digits/symbols, and emoji.
for ok in ["Zoé", "José", "Amélie", "sœur", "Noël", "#café", "crème-brûlée", "user_42", "привет", "日本語", "Ελληνικά", "Zoé😀", "#général"] {
assert!(confusable_reason(ok).is_none(), "should pass: {ok}");
}
// Look-alike / spoof names are rejected.
for bad in [
"аdmin", // Cyrillic а + Latin — mixed script
"#frее", // Latin fr + Cyrillic ее — mixed script
"аеосх", // all-Cyrillic Latin look-alikes — homoglyph
"ad\u{200b}min", // zero-width space
"\u{202e}nimda", // right-to-left override
] {
assert!(confusable_reason(bad).is_some(), "should be rejected: {bad}");
}
}
#[test]
fn akick_extbans_parse_and_match() {
// Parse: hostmask fallback (incl IPv6), named + letter extbans, realmask

View file

@ -131,6 +131,12 @@ impl Service for ChanServ {
ctx.notice(me, from.uid, format!("\x02{chan}\x02 can't be registered: {reason}"));
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;
}
match db.register_channel(chan, account) {
Ok(()) => {
ctx.channel_mode(me, chan, "+r"); // mark the channel registered

View file

@ -12,6 +12,11 @@ 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;
}
let email = args.get(2).map(|s| s.to_string());
ctx.defer_register(from.nick, *password, email, RegReply::NickServ {
agent: me.to_string(),