Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions lib/homographic_spoofing/detector/idn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def self.detections(domain)
end

def initialize(domain)
@original_domain = domain
@domain = domain.downcase
end

Expand All @@ -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))
Comment thread
jeremy marked this conversation as resolved.
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) }
Expand Down
17 changes: 17 additions & 0 deletions test/sanitizer/email_address_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,23 @@ 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 <x@xn--pple-43d.com>", "Apple Support <x@аpple.com>"
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@example.com>", "Support <ſupport@example.com>"
assert_sanitize "Tᴡitter <xn--titter-345b@twitter.com>", "Tᴡitter <tᴡitter@twitter.com>"
end

private
def assert_sanitize(sanitized, email_address)
assert_equal sanitized, HomographicSpoofing::Sanitizer::EmailAddress.sanitize(email_address)
Expand Down
25 changes: 25 additions & 0 deletions test/sanitizer/idn_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,31 @@ 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

# Ⱥ (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)
Expand Down