use unicode_normalization::UnicodeNormalization;
use crate::{confusables, invisibles, scripts};
fn is_invisible_in_hostname(ch: char) -> bool {
invisibles::is_zero_width(ch)
|| invisibles::is_tag(ch)
|| invisibles::is_variation_selector(ch)
|| invisibles::is_noncharacter(ch)
|| invisibles::is_pua(ch)
}
fn is_ipv6_literal(normalized: &str) -> bool {
if !(normalized.starts_with('[') && normalized.ends_with(']')) {
return false;
}
let inner = &normalized[1..normalized.len() - 1];
if inner.is_empty() || !inner.contains(':') {
return false;
}
let addr_part = match inner.find('%') {
Some(pos) => &inner[..pos],
None => inner,
};
let colon_count = addr_part.chars().filter(|&c| c == ':').count();
if colon_count > 7 {
return false;
}
if inner.bytes().filter(|&b| b == b'%').count() > 1 {
return false;
}
inner
.as_bytes()
.iter()
.all(|&b| b.is_ascii_hexdigit() || b == b':' || b == b'.' || b == b'%')
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct HostnameAnalysis {
pub(crate) suspicious: bool,
pub(crate) scripts: Vec<String>,
pub(crate) mixed_script: bool,
pub(crate) has_confusables: bool,
pub(crate) bidi_conflict: bool,
pub(crate) bidi_control: bool,
pub(crate) has_invisible: bool,
pub(crate) cross_label_script: bool,
pub(crate) label_scripts: Vec<Vec<String>>,
pub(crate) whole_script_confusable: bool,
pub(crate) label_whole_script_confusable: Vec<bool>,
pub(crate) canonical: String,
}
pub(crate) fn is_suspicious_hostname_opts(
hostname: &str,
contractions: bool,
) -> (bool, HostnameAnalysis) {
let normalized: String = hostname.nfkc().collect();
if is_ipv6_literal(&normalized) {
return (
false,
HostnameAnalysis {
suspicious: false,
scripts: Vec::new(),
mixed_script: false,
has_confusables: false,
bidi_conflict: false,
bidi_control: false,
has_invisible: false,
cross_label_script: false,
label_scripts: Vec::new(),
whole_script_confusable: false,
label_whole_script_confusable: Vec::new(),
canonical: normalized,
},
);
}
let mut suspicious = false;
let mut all_scripts: Vec<&str> = Vec::new();
let mut seen_scripts: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut has_mixed = false;
let mut has_confusables = false;
let mut has_invisible = false;
let mut decoded_labels: Vec<String> = Vec::new();
let mut per_label_scripts: Vec<Vec<String>> = Vec::new();
let mut per_label_wsc: Vec<bool> = Vec::new();
for raw_label in normalized.split('.') {
if raw_label.is_empty() {
decoded_labels.push(String::new());
per_label_scripts.push(Vec::new());
per_label_wsc.push(false);
continue;
}
let is_ace =
raw_label.len() >= 4 && raw_label.as_bytes()[..4].eq_ignore_ascii_case(b"xn--");
let mut label: String = if is_ace {
let (unicode, result) = idna::domain_to_unicode(raw_label);
if result.is_err() {
suspicious = true;
}
unicode.nfkc().collect()
} else {
raw_label.to_string()
};
if label.chars().any(is_invisible_in_hostname) {
has_invisible = true;
label.retain(|c| !is_invisible_in_hostname(c));
}
decoded_labels.push(label.clone());
let label_scripts = scripts::detect_scripts(&label);
per_label_scripts.push(label_scripts.iter().map(|s| (*s).to_string()).collect());
let label_wsc = label_scripts.len() == 1 && label_scripts[0] != "Latin" && {
let skeleton = confusables::normalize_confusables(&label, "latin", "numeric")
.unwrap_or_else(|_| label.clone());
matches!(scripts::detect_scripts(&skeleton).as_slice(), ["Latin"])
};
per_label_wsc.push(label_wsc);
for s in &label_scripts {
if seen_scripts.insert(s) {
all_scripts.push(s);
}
}
if label_scripts.len() > 1 {
has_mixed = true;
suspicious = true;
}
match confusables::is_confusable(&label, "latin") {
Ok(true) => {
has_confusables = true;
suspicious = true;
}
Ok(false) => {}
Err(_) => {
suspicious = true;
}
}
}
let mut decoded_hostname = decoded_labels.join(".");
let bidi_conflict = scripts::has_bidi_conflict(&decoded_hostname);
let cross_label_script = all_scripts.len() > 1;
if bidi_conflict {
suspicious = true;
}
let bidi_control = scripts::has_bidi_control(&decoded_hostname);
if bidi_control {
suspicious = true;
decoded_hostname.retain(|c| !scripts::is_bidi_control(c));
}
if has_invisible {
suspicious = true;
}
let canonical = confusables::normalize_confusables(&decoded_hostname, "latin", "numeric")
.unwrap_or(decoded_hostname);
let canonical = if contractions {
canonical
.split('.')
.map(|label| crate::contraction::contract(label).into_owned())
.collect::<Vec<_>>()
.join(".")
} else {
canonical
};
let whole_script_confusable = per_label_wsc.iter().any(|&b| b);
(
suspicious,
HostnameAnalysis {
suspicious,
scripts: all_scripts.into_iter().map(String::from).collect(),
mixed_script: has_mixed,
has_confusables,
bidi_conflict,
bidi_control,
has_invisible,
cross_label_script,
label_scripts: per_label_scripts,
whole_script_confusable,
label_whole_script_confusable: per_label_wsc,
canonical,
},
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_clean_hostname_not_suspicious() {
let (suspicious, details) = is_suspicious_hostname_opts("paypal.com", false);
assert!(!suspicious);
assert!(!details.has_confusables);
assert!(!details.mixed_script);
}
#[test]
fn test_cyrillic_spoof() {
let (suspicious, details) = is_suspicious_hostname_opts("\u{0440}\u{0430}ypal.com", false);
assert!(suspicious);
assert!(details.has_confusables);
assert!(details.mixed_script);
assert_eq!(details.canonical, "paypal.com");
}
#[test]
fn test_full_cyrillic_domain() {
let (_, d) = is_suspicious_hostname_opts("яндекс.ру", false);
assert!(!d.mixed_script);
assert_eq!(d.label_whole_script_confusable, vec![false, true]);
assert!(
d.whole_script_confusable,
"top-level bool over-fires on the `ру` ccTLD — the documented graded-signal FP"
);
}
#[test]
fn test_whole_script_confusable_attack() {
let (_, d) =
is_suspicious_hostname_opts("\u{0430}\u{0440}\u{0440}\u{04CF}\u{0435}.com", false);
assert!(d.whole_script_confusable);
assert_eq!(d.label_whole_script_confusable, vec![true, false]);
assert_eq!(d.canonical, "apple.com");
}
#[test]
fn test_whole_script_confusable_legit_domains_not_flagged() {
for host in [
"\u{043C}\u{043E}\u{0441}\u{043A}\u{0432}\u{0430}.\u{0440}\u{0444}", "\u{043F}\u{043E}\u{0447}\u{0442}\u{0430}.\u{0440}\u{0444}", "\u{03B1}\u{03B8}\u{03AE}\u{03BD}\u{03B1}.gr", "\u{05D0}\u{05EA}\u{05E8}.\u{05E7}\u{05D5}\u{05DD}", "\u{4F8B}\u{3048}.jp", ] {
let (_, d) = is_suspicious_hostname_opts(host, false);
assert!(
!d.whole_script_confusable,
"{host:?} must not be whole-script-confusable, got {:?}",
d.label_whole_script_confusable
);
}
}
#[test]
fn test_whole_script_confusable_known_fp_osa() {
let (_, d) =
is_suspicious_hostname_opts("\u{043E}\u{0441}\u{0430}.\u{0440}\u{0444}", false);
assert_eq!(d.label_whole_script_confusable, vec![true, false]);
assert!(d.whole_script_confusable);
assert!(!d.mixed_script);
}
#[test]
fn test_whole_script_confusable_spoof_under_cyrillic_tld() {
let (_, d) = is_suspicious_hostname_opts(
"\u{0430}\u{0440}\u{0440}\u{04CF}\u{0435}.\u{0440}\u{0444}",
false,
);
assert!(d.label_whole_script_confusable[0]);
assert_ne!(d.label_scripts.last().unwrap(), &vec!["Latin".to_string()]);
}
#[test]
fn test_mixed_non_latin_scripts_suspicious() {
let (suspicious, details) = is_suspicious_hostname_opts("\u{044F}\u{03C8}.com", false);
assert!(suspicious, "mixed Cyrillic+Greek label must be suspicious");
assert!(details.mixed_script);
assert!(
!details.has_confusables,
"neither я nor ψ is a Latin confusable; the mixed-script rule must \
be what flags this label"
);
assert!(details.scripts.iter().any(|s| s == "Cyrillic"));
assert!(details.scripts.iter().any(|s| s == "Greek"));
}
#[test]
fn test_punycode_non_homograph_not_suspicious() {
let (suspicious, _) = is_suspicious_hostname_opts("xn--n3h.com", false);
assert!(!suspicious);
}
#[test]
fn test_punycode_homograph_suspicious() {
let spoof = "\u{0430}\u{0440}\u{0440}\u{04CF}\u{0435}"; let ace = idna::domain_to_ascii(spoof).expect("encode Cyrillic spoof to ACE");
assert!(
ace.starts_with("xn--"),
"expected an xn-- label, got {ace:?}"
);
let hostname = format!("{ace}.com");
let (suspicious, details) = is_suspicious_hostname_opts(&hostname, false);
assert!(
suspicious,
"Cyrillic homograph in ACE form {hostname:?} must be suspicious"
);
assert!(details.has_confusables);
}
#[test]
fn test_ipv6_loopback_not_suspicious() {
let (suspicious, details) = is_suspicious_hostname_opts("[::1]", false);
assert!(!suspicious);
assert!(!details.mixed_script);
assert!(!details.has_confusables);
}
#[test]
fn test_ipv6_full_not_suspicious() {
let (suspicious, details) = is_suspicious_hostname_opts("[2001:db8::1]", false);
assert!(!suspicious);
assert!(details.scripts.is_empty());
}
#[test]
fn test_bidi_swap_hostname_flags_direction_conflict() {
let (suspicious, d) =
is_suspicious_hostname_opts("varonis.com.\u{05D5}.\u{05E7}\u{05D5}\u{05DD}", false);
assert!(suspicious);
assert!(
d.bidi_conflict,
"LTR+RTL across labels must set bidi_conflict"
);
assert!(d.cross_label_script);
assert!(!d.mixed_script, "no single label is mixed-script");
assert_eq!(d.label_scripts.len(), 4);
assert_eq!(d.label_scripts[0], vec!["Latin".to_string()]);
assert_eq!(d.label_scripts[3], vec!["Hebrew".to_string()]);
}
#[test]
fn test_bidi_conflict_intra_label() {
let (suspicious, d) = is_suspicious_hostname_opts("varonis\u{05D5}.com", false);
assert!(suspicious);
assert!(d.bidi_conflict);
}
#[test]
fn test_benign_idn_cctld_no_direction_conflict() {
let (_, d) = is_suspicious_hostname_opts("google.\u{0440}\u{0444}", false);
assert!(!d.bidi_conflict);
assert!(d.cross_label_script);
}
#[test]
fn test_all_rtl_hostname_no_direction_conflict() {
let (_, d) =
is_suspicious_hostname_opts("\u{05D0}\u{05EA}\u{05E8}.\u{05E7}\u{05D5}\u{05DD}", false);
assert!(!d.bidi_conflict);
assert!(!d.cross_label_script);
}
#[test]
fn test_ascii_hostname_no_new_signals() {
let (suspicious, d) = is_suspicious_hostname_opts("example.com", false);
assert!(!suspicious);
assert!(!d.bidi_conflict);
assert!(!d.cross_label_script);
assert_eq!(
d.label_scripts,
vec![vec!["Latin".to_string()], vec!["Latin".to_string()]]
);
}
#[test]
fn bidi_controls_are_flagged_and_stripped_from_canonical() {
for c in [
'\u{200E}', '\u{200F}', '\u{061C}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}',
'\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}',
] {
let host = format!("paypal{c}moc.evil.com");
let (suspicious, d) = is_suspicious_hostname_opts(&host, false);
assert!(suspicious, "U+{:04X} must flag suspicious", c as u32);
assert!(d.bidi_control, "U+{:04X} must set bidi_control", c as u32);
assert!(
!d.canonical.contains(c),
"U+{:04X} must not survive into canonical (got {:?})",
c as u32,
d.canonical
);
}
}
#[test]
fn bidi_control_is_disjoint_from_bidi_conflict() {
let (_, rlo) = is_suspicious_hostname_opts("paypal\u{202E}moc.evil.com", false);
assert!(rlo.bidi_control && !rlo.bidi_conflict);
let (_, swap) =
is_suspicious_hostname_opts("varonis.com.\u{05D5}.\u{05E7}\u{05D5}\u{05DD}", false);
assert!(swap.bidi_conflict && !swap.bidi_control);
}
#[test]
fn clean_hostname_sets_no_bidi_control() {
for host in [
"paypal.com",
"\u{043C}\u{043E}\u{0441}\u{043A}\u{0432}\u{0430}.\u{0440}\u{0444}",
"example.co.uk",
] {
let (_, d) = is_suspicious_hostname_opts(host, false);
assert!(!d.bidi_control, "{host} must not set bidi_control");
assert_eq!(
d.canonical
.chars()
.filter(|c| crate::scripts::is_bidi_control(*c))
.count(),
0
);
}
}
#[test]
fn test_bidi_conflict_on_decoded_punycode() {
let (_, d) = is_suspicious_hostname_opts("xn--9db.xn--9dbq2a", false);
assert!(!d.bidi_conflict);
}
}