From c2e488f903a55bfb031614b56dc83f8624a53c96 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 28 Aug 2026 09:12:29 -0700 Subject: [PATCH 1/2] Sanitize uppercase and mixed-case confusable IDN domains The IDN detector lowercases the domain before analysis (`Detector::Idn#initialize`), so its detections carry a lowercased label. The sanitizer then substituted that label into the original-cased field with a case-sensitive `gsub`, so an uppercase or mixed-case confusable domain was detected as a spoof yet returned unchanged. Match the label case-insensitively when substituting the punycode. Domain labels are case-insensitive and `Dnsruby::Name.punycode` nameprep-folds case, so the substituted encoding stays canonical. Quoted-string and local-part detections carry exact-case labels and are unaffected; cross-script confusables do not case-fold to ASCII, so benign ASCII names and local parts are left untouched. --- lib/homographic_spoofing/sanitizer/base.rb | 3 ++- test/sanitizer/email_address_test.rb | 8 ++++++++ test/sanitizer/idn_test.rb | 5 +++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/homographic_spoofing/sanitizer/base.rb b/lib/homographic_spoofing/sanitizer/base.rb index f725beb..6754f1b 100644 --- a/lib/homographic_spoofing/sanitizer/base.rb +++ b/lib/homographic_spoofing/sanitizer/base.rb @@ -22,7 +22,8 @@ def sanitize attr_reader :field def punycode(source, label) - source.gsub(label, Dnsruby::Name.punycode(label)) + punycoded = Dnsruby::Name.punycode(label) + source.gsub(/#{Regexp.escape(label)}/i) { punycoded } end def detector_class diff --git a/test/sanitizer/email_address_test.rb b/test/sanitizer/email_address_test.rb index b96900f..1f99e41 100644 --- a/test/sanitizer/email_address_test.rb +++ b/test/sanitizer/email_address_test.rb @@ -38,6 +38,14 @@ class HomographicSpoofing::Sanitizer::EmailAddressTest < ActiveSupport::TestCase HomographicSpoofing::Sanitizer::EmailAddress.logger = previous_logger end + test "sanitize uppercase confusable idn domain" do + assert_sanitize "jacopo@xn--pple-43d.com", "jacopo@Аpple.com" + end + + test "sanitize uppercase confusable idn domain leaves benign ascii name untouched" do + assert_sanitize "Apple Support ", "Apple Support " + end + private def assert_sanitize(sanitized, email_address) assert_equal sanitized, HomographicSpoofing::Sanitizer::EmailAddress.sanitize(email_address) diff --git a/test/sanitizer/idn_test.rb b/test/sanitizer/idn_test.rb index a1a7dea..b75be0e 100644 --- a/test/sanitizer/idn_test.rb +++ b/test/sanitizer/idn_test.rb @@ -16,6 +16,11 @@ class HomographicSpoofing::Sanitizer::IdnTest < ActiveSupport::TestCase HomographicSpoofing::Sanitizer::Idn.logger = previous_logger end + test "sanitize uppercase and mixed-case confusable domain" do + assert_sanitize "xn--pple-43d.com", "Аpple.com" + assert_sanitize "APPLE.com", "APPLE.com" + end + private def assert_sanitize(sanitized, domain) assert_equal sanitized, HomographicSpoofing::Sanitizer::Idn.sanitize(domain) From a58dbc2b4fab621743be719ac271e40816ba2c91 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Fri, 28 Aug 2026 09:39:55 -0700 Subject: [PATCH 2/2] Recover original-cased IDN labels instead of case-folding on substitution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut lowercased the detection label and substituted it back with a case-insensitive gsub (/i). That both over-matched and under-matched: - Over-match: /i applied to every detection type, across the whole field, so a confusable local part rewrote a benign name that differed only by case — a long-s (ſ) local part folding onto an ASCII "Support" name, a small-capital-w (ᴡ) local part folding onto its "Tᴡitter" case-variant name. - Under-match: regexp case folding does not pair every uppercase letter with the lowercase form String#downcase produces (e.g. Ⱥ/ⱥ, Ⱦ/ⱦ), so those uppercase domains were detected yet left unsanitized on affected Rubies. Have Detector::Idn report each label in the original case it occupies in the input domain, and keep the sanitizer's substitution an exact match. The detector only lowercases for analysis, so recover the original casing by finding the run of the domain whose lowercase equals the label. Matching content rather than a fixed offset stays correct where offset arithmetic would not: when a character lowercases to a different length (İ → i̇), and when PublicSuffix stripped surrounding characters the raw domain still carries. Domain labels are case-insensitive by spec and Dnsruby::Name.punycode folds case when encoding, so the substituted punycode stays canonical. --- lib/homographic_spoofing/detector/idn.rb | 36 ++++++++++++++++++++-- lib/homographic_spoofing/sanitizer/base.rb | 3 +- test/sanitizer/email_address_test.rb | 9 ++++++ test/sanitizer/idn_test.rb | 20 ++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/lib/homographic_spoofing/detector/idn.rb b/lib/homographic_spoofing/detector/idn.rb index 48965af..9f18e97 100644 --- a/lib/homographic_spoofing/detector/idn.rb +++ b/lib/homographic_spoofing/detector/idn.rb @@ -16,6 +16,7 @@ def self.detections(domain) end def initialize(domain) + @original_domain = domain @domain = domain.downcase end @@ -25,15 +26,44 @@ def detected? def detections rules.select(&:attack_detected?).map do |rule| - HomographicSpoofing::Detector::Detection.new(rule.reason, rule.label) + HomographicSpoofing::Detector::Detection.new(rule.reason, original_case(rule.label)) end rescue PublicSuffix::Error # Invalid IDN is a spoof. - [ HomographicSpoofing::Detector::Detection.new("invalid_domain", domain) ] + [ HomographicSpoofing::Detector::Detection.new("invalid_domain", original_domain) ] end private - attr_reader :domain + attr_reader :domain, :original_domain + + # Detection runs on the lowercased domain, so labels come back lowercased. + # Recover the original-cased run of the domain the label occupies, so the + # sanitizer can substitute by exact match instead of regexp case folding — + # which both over-matches (folds unrelated ASCII, e.g. ſ/s) and under-matches + # (misses case pairs folding omits, e.g. Ⱥ/ⱥ). Map each lowercased position + # back to the original character it came from, then locate the label in the + # lowercased form. This stays correct — and linear — where a fixed offset + # would not: when a character lowercases to a different length (İ → i̇), and + # when PublicSuffix stripped surrounding characters the raw domain carries. + def original_case(label) + origin = [] + lowercased = +"" + original_domain.each_char.with_index do |char, index| + downcased = char.downcase + lowercased << downcased + downcased.length.times { origin << index } + end + + from = 0 + while (start = lowercased.index(label, from)) + span = original_domain[origin[start]..origin[start + label.length - 1]] + # `index` can land inside a character whose lowercase spans several (İ → + # i̇), so accept only a span that round-trips exactly to the label. + return span if span.downcase == label + from = start + 1 + end + label + end def rules @rules ||= contexts.flat_map { |ctx| rules_for(ctx) } diff --git a/lib/homographic_spoofing/sanitizer/base.rb b/lib/homographic_spoofing/sanitizer/base.rb index 6754f1b..f725beb 100644 --- a/lib/homographic_spoofing/sanitizer/base.rb +++ b/lib/homographic_spoofing/sanitizer/base.rb @@ -22,8 +22,7 @@ def sanitize attr_reader :field def punycode(source, label) - punycoded = Dnsruby::Name.punycode(label) - source.gsub(/#{Regexp.escape(label)}/i) { punycoded } + source.gsub(label, Dnsruby::Name.punycode(label)) end def detector_class diff --git a/test/sanitizer/email_address_test.rb b/test/sanitizer/email_address_test.rb index 1f99e41..f120644 100644 --- a/test/sanitizer/email_address_test.rb +++ b/test/sanitizer/email_address_test.rb @@ -46,6 +46,15 @@ class HomographicSpoofing::Sanitizer::EmailAddressTest < ActiveSupport::TestCase assert_sanitize "Apple Support ", "Apple Support " end + # The name is only substituted when its own detector flags it. A confusable + # local part must not bleed into a name that differs from it by case: the long + # s (ſ) case-folds to ASCII s, and the small capital w (ᴡ) shares a case pair + # with ASCII W, but each name here is left to its quoted-string detector. + test "confusable local part does not mutate a case-variant name" do + assert_sanitize "Support ", "Support <ſupport@example.com>" + assert_sanitize "Tᴡitter ", "Tᴡitter " + end + private def assert_sanitize(sanitized, email_address) assert_equal sanitized, HomographicSpoofing::Sanitizer::EmailAddress.sanitize(email_address) diff --git a/test/sanitizer/idn_test.rb b/test/sanitizer/idn_test.rb index b75be0e..ca2b88d 100644 --- a/test/sanitizer/idn_test.rb +++ b/test/sanitizer/idn_test.rb @@ -21,6 +21,26 @@ class HomographicSpoofing::Sanitizer::IdnTest < ActiveSupport::TestCase assert_sanitize "APPLE.com", "APPLE.com" end + # Ⱥ (U+023A) lowercases to ⱥ (U+2C65) under String#downcase but not under + # regexp case folding, so recovering the original-cased label positionally — + # rather than via /i — is what lets this uppercase spoof be sanitized. + test "sanitize confusable domain with a special-cased character" do + assert_sanitize "xn--pple-k49b.com", "Ⱥpple.com" + end + + # İ (U+0130) lowercases to two codepoints (i + combining dot), so the label's + # original casing is recovered by matching lowercase content rather than a + # character offset, which the length change would otherwise shift. + test "sanitize confusable domain with a length-changing lowercase" do + assert_sanitize "xn--ipple-7fd.com", "İpple.com" + end + + # PublicSuffix strips surrounding whitespace the raw domain still carries, so a + # fixed offset into the domain would miss the label; content matching does not. + test "sanitize confusable domain with surrounding whitespace" do + assert_sanitize " xn--pple-43d.com ", " Аpple.com " + end + private def assert_sanitize(sanitized, domain) assert_equal sanitized, HomographicSpoofing::Sanitizer::Idn.sanitize(domain)