diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ccb2f..76fa319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ In Development * The `original` field of the returned validated email object incorrectly un-escaped the local part of the email address. Now it returns the original address, without any display name, as passed to validate_email. * add support python3.15 * The IPv6 tag of a domain literal is now case-insensitive, as in the RFCs, so `me@[ipv6:::1]` is accepted (and normalized to `me@[IPv6:::1]`) when `allow_domain_literal=True`. +* Internationalized domains with a right-to-left label are now rejected if any other label breaks the IDNA Bidi Rule (RFC 5893), such as a label starting with a digit. Such domains, like `1.xn--4dbc.com` and `xn--4dbc.1.com` (`1.אב.com` and `אב.1.com` in Unicode), could be displayed identically. 2.3.0 (August 26, 2025) ----------------------- diff --git a/email_validator/syntax.py b/email_validator/syntax.py index e88a3a4..6af2095 100644 --- a/email_validator/syntax.py +++ b/email_validator/syntax.py @@ -675,6 +675,20 @@ def validate_email_domain_name(domain: str, test_environment: bool = False, glob raise EmailSyntaxError("The part after the @-sign contains invalid characters: " + ", ".join(sorted(bad_chars)) + ".") check_unsafe_chars(domain_i18n) + # If any label has right-to-left characters, the Bidi Rule applies to + # every label in the domain, including left-to-right ones (RFC 5893 + # Section 1.4 and 2, UTS #46 CheckBidi). The idna package only checks + # each label on its own, so a label like "1" next to a right-to-left + # label was accepted, and such domains can be displayed identically to + # a different domain (e.g. 1.xn--4dbc.com and xn--4dbc.1.com, which + # are 1.אב.com and אב.1.com in Unicode, with Hebrew alef bet). + if any(unicodedata.bidirectional(c) in ("R", "AL", "AN") for c in domain_i18n): + for label in domain_i18n.split("."): + try: + idna.check_bidi(label, check_ltr=True) + except idna.IDNAError as e: + raise EmailSyntaxError(f"The part after the @-sign is not valid IDNA ({e}).") from e + # Check that it can be encoded back to IDNA ASCII. We have no test # case for this. try: diff --git a/tests/test_syntax.py b/tests/test_syntax.py index 87db51a..de75725 100644 --- a/tests/test_syntax.py +++ b/tests/test_syntax.py @@ -478,7 +478,7 @@ def test_domain_literal() -> None: ('meme@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.com', 'The email address is too long (4 characters too many).'), ('my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info', 'The email address is too long (2 characters too many).'), ('my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info', 'The email address is too long (1-2 characters too many).'), - ('my.long.address@\uFB2C111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info', 'The email address is too long (1-3 characters too many).'), + ('my.long.address@\uFB2C111111111222222222233333333334444444444555555555.a666666666777777777788888888889999999999000000000.a111111111222222222233333333334444444444555555555.a666666666777777777788888888889999999999000000000.a111111111222222222233333333334444.info', 'The email address is too long (1-3 characters too many).'), ('my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info', 'The email address is too long (1 character too many).'), ('my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info', 'The email address is too long (1-2 characters too many).'), ('my.\u0073\u0323\u0307.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info', 'The email address is too long (1-2 characters too many).'), @@ -489,6 +489,11 @@ def test_domain_literal() -> None: ('me@bad-tld-1', 'The part after the @-sign is not valid. It should have a period.'), ('me@bad.tld-2', 'The part after the @-sign is not valid. It is not within a valid top-level domain.'), ('me@xn--0.tld', 'The part after the @-sign is not valid IDNA (Invalid A-label).'), + # 1.xn--4dbc.com is 1.אב.com and xn--4dbc.1.com is אב.1.com + # (alef bet in Hebrew). Both display as the same string, so both are rejected. + ('me@1.xn--4dbc.com', 'The part after the @-sign is not valid IDNA (First codepoint in label \'1\' must be directionality L, R or AL).'), + ('me@xn--4dbc.1.com', 'The part after the @-sign is not valid IDNA (First codepoint in label \'1\' must be directionality L, R or AL).'), + ('me@1.xn--4dbrk0ce', 'The part after the @-sign is not valid IDNA (First codepoint in label \'1\' must be directionality L, R or AL).'), ('me@yy--0.tld', 'An email address cannot have two letters followed by two dashes immediately after the @-sign or after a period, except Punycode.'), ('me@yy--0.tld', 'An email address cannot have two letters followed by two dashes immediately after the @-sign or after a period, except Punycode.'), ('me@[127.0.0.1]', 'A bracketed IP address after the @-sign is not allowed here.'),