Skip to content

Sanitize uppercase and mixed-case confusable IDN domains - #74

Merged
jeremy merged 2 commits into
mainfrom
sec/10250033354-idn-mixed-case-punycode
Aug 28, 2026
Merged

Sanitize uppercase and mixed-case confusable IDN domains#74
jeremy merged 2 commits into
mainfrom
sec/10250033354-idn-mixed-case-punycode

Conversation

@jeremy

@jeremy jeremy commented Aug 28, 2026

Copy link
Copy Markdown
Member

Problem

Detector::Idn#initialize lowercases the domain before analysis (@domain = domain.downcase), so every IDN detection carries a lowercased detection.label. Sanitizer::Base#punycode then substituted that label into the original-cased field with a case-sensitive gsub:

source.gsub(label, Dnsruby::Name.punycode(label))

So an uppercase or mixed-case confusable domain was detected as a spoof but returned unchanged — silent under-sanitization. Confirmed on main (ruby 4.0.6):

HomographicSpoofing.idn_spoof?("Аpple.com")   # => true   (uppercase Cyrillic А, U+0410)
HomographicSpoofing.sanitize_idn("Аpple.com")  # => "Аpple.com"   (unchanged — bug)
HomographicSpoofing.sanitize_idn("аpple.com")  # => "xn--pple-43d.com"   (lowercase works)

This affects both public entry points that touch a domain: sanitize_idn and sanitize_email_address (the latter routes the address's domain part through Detector::Idn in Detector::EmailAddress#detections), so every downstream sink that punycodes a domain-bearing identity was under-sanitized for uppercase/mixed-case confusables.

Fix

Match the label case-insensitively when substituting:

def punycode(source, label)
  punycoded = Dnsruby::Name.punycode(label)
  source.gsub(/#{Regexp.escape(label)}/i) { punycoded }
end

Domain labels are case-insensitive by spec, and Dnsruby::Name.punycode nameprep-folds case (punycode("Аpple") == punycode("аpple") == "xn--pple-43d"), so the substituted encoding stays canonical regardless of the matched case.

Why this is safe (reviewer notes)

  • Non-IDN paths unaffected in practice. Quoted-string and local-part detections carry exact-case labels (their detectors do not lowercase), so /i still matches the same run; the replacement value is unchanged.

  • Benign text is not over-matched. IDN detection labels always contain a non-ASCII confusable character, and cross-script characters do not case-fold together under /i (ASCII a ≠ Cyrillic а). A benign ASCII display name or local part alongside a confusable domain is left untouched — locked in by a regression test:

    "Apple Support <x@аpple.com>" => "Apple Support <x@xn--pple-43d.com>"
    
  • Whole-field substitution is pre-existing. The per-detection gsub already replaced a confusable token across every part of an address (e.g. tᴡitter@tᴡitter.com); this change only extends that to case-variants of the same token, and Dnsruby's canonical encoding keeps the output correct. The only residual is same-script case-fold pairs (e.g. ſ/s) — display-only, requires the benign run to contain the identical confusable letters, and still yields a valid sanitization; not addressed here.

  • Block-form replacement also avoids backreference interpretation in the xn-- output.

Tests

New regression tests (fail on main, pass here) in sanitizer/idn_test.rb and sanitizer/email_address_test.rb: uppercase confusable domain punycoded, benign uppercase ASCII domain untouched, uppercase confusable domain inside an email address, and the benign-ASCII-name guard above. Full suite green (bin/test: 43 runs, 307 assertions, 0 failures).

Spun off from HackerOne #3960349 / basecamp/haystack#8670, where the Imbox sender-byline path worked around this by detecting on a pre-lowercased domain.

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.
Copilot AI balanced review requested due to automatic review settings August 28, 2026 16:12

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes sanitization of uppercase or mixed-case confusable IDN domains.

Changes:

  • Uses case-insensitive label substitution.
  • Adds IDN and email regression tests.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
lib/homographic_spoofing/sanitizer/base.rb Adds case-insensitive punycode replacement.
test/sanitizer/idn_test.rb Tests mixed-case IDN sanitization.
test/sanitizer/email_address_test.rb Tests IDNs in email domains and display-name preservation.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread lib/homographic_spoofing/sanitizer/base.rb Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c2e488f903

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/homographic_spoofing/sanitizer/base.rb Outdated
Comment thread lib/homographic_spoofing/sanitizer/base.rb Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e28cbaf109

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/homographic_spoofing/detector/idn.rb Outdated
@jeremy
jeremy force-pushed the sec/10250033354-idn-mixed-case-punycode branch from e28cbaf to 14390b1 Compare August 28, 2026 16:52

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 14390b13e8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/homographic_spoofing/detector/idn.rb
@jeremy
jeremy force-pushed the sec/10250033354-idn-mixed-case-punycode branch from 14390b1 to 17d020f Compare August 28, 2026 17:04

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 17d020f1d5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/homographic_spoofing/detector/idn.rb Outdated
@jeremy
jeremy force-pushed the sec/10250033354-idn-mixed-case-punycode branch from 17d020f to 433909e Compare August 28, 2026 17:14
…tion

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.
@jeremy
jeremy force-pushed the sec/10250033354-idn-mixed-case-punycode branch from 433909e to a58dbc2 Compare August 28, 2026 17:22
@jeremy
jeremy merged commit f3a62a8 into main Aug 28, 2026
13 checks passed
@jeremy
jeremy deleted the sec/10250033354-idn-mixed-case-punycode branch August 28, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants