From 56a83b59b6f6f0d79dc1de9b3bf78a9c1d6d8a54 Mon Sep 17 00:00:00 2001 From: DevKaurav-14 Date: Tue, 20 Oct 2026 18:01:03 +0530 Subject: [PATCH] Look up the IDNA-encoded TLD when consider_tld is set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IANA list stores internationalized TLDs in punycode (XN--P1AI), but domain() checked the raw Unicode TLD against it. Unicode domains such as пример.рф were rejected with consider_tld=True while their punycode form was accepted. hostname() and url() inherit this through domain(). Run the TLD check after IDNA encoding, on the encoded TLD. --- src/validators/domain.py | 8 +++++--- tests/test_domain.py | 4 ++++ tests/test_hostname.py | 26 ++++++++++++++++++++++++++ tests/test_url.py | 26 ++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/validators/domain.py b/src/validators/domain.py index 24f4618..ee6db4e 100644 --- a/src/validators/domain.py +++ b/src/validators/domain.py @@ -94,9 +94,6 @@ def domain( if not value: return False - if consider_tld and not _IanaTLD.check(value.rstrip(".").rsplit(".", 1)[-1].upper()): - return False - try: if _relies_on_idna_mapping(value): return False @@ -114,6 +111,11 @@ def domain( # ref: RFC 1035, presentation form is limited to 253 characters return False + # IANA lists IDN TLDs in their ASCII (punycode) form, + # so look up the encoded TLD, not the raw one. + if consider_tld and not _IanaTLD.check(encoded.rstrip(".").rsplit(".", 1)[-1].upper()): + return False + return not re.search(r"\s|__+", value) and re.match( # First character of the domain rf"^(?:[a-z0-9{service_record}]" diff --git a/tests/test_domain.py b/tests/test_domain.py index e61d195..533dde9 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -49,6 +49,9 @@ def test_returns_true_on_valid_domain(value: str, rfc_1034: bool, rfc_2782: bool ("somerandomexample.onion", True, False, False), # delegated 2026-07-23 ("somerandomexample.web", True, False, False), + ("пример.рф", True, False, False), + ("пример.рф.", True, True, False), + ("例子.中国", True, False, False), ], ) def test_returns_true_on_valid_top_level_domain( @@ -104,6 +107,7 @@ def test_returns_failed_validation_on_invalid_domain(value: str, rfc_1034: bool, ("somerandomexample.xn-n-fiqs8s", True, False, False), # retired from the root zone ("somerandomexample.goo", True, False, False), + ("пример.рфы", True, False, False), ], ) def test_returns_failed_validation_invalid_top_level_domain( diff --git a/tests/test_hostname.py b/tests/test_hostname.py index f62dced..83f0021 100644 --- a/tests/test_hostname.py +++ b/tests/test_hostname.py @@ -145,3 +145,29 @@ def test_returns_failed_validation_on_idna_mapped_lookalike_hostname(value: str) def test_returns_failed_validation_on_ipv4_lookalike_hostname(value: str): """Test returns failed validation on dotted names whose last label is non-ASCII digits.""" assert isinstance(hostname(value), ValidationError) + + +@pytest.mark.parametrize( + "value", + [ + "example.com:443", + "xn--e1afmkfd.xn--p1ai", + "пример.рф", + "пример.рф:8080", + ], +) +def test_returns_true_on_valid_top_level_domain_hostname(value: str): + """Test returns true on hostname with a TLD allowed by IANA.""" + assert hostname(value, consider_tld=True) + + +@pytest.mark.parametrize( + "value", + [ + "example.flat:443", + "пример.рфы", + ], +) +def test_returns_failed_validation_on_invalid_top_level_domain_hostname(value: str): + """Test returns failed validation on hostname with a TLD not allowed by IANA.""" + assert isinstance(hostname(value, consider_tld=True), ValidationError) diff --git a/tests/test_url.py b/tests/test_url.py index 9b5e2d7..d48a03e 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -273,3 +273,29 @@ def test_returns_failed_validation_on_idna_mapped_lookalike_url(value: str): def test_returns_failed_validation_on_ipv4_lookalike_url(value: str, private: Optional[bool]): """Test returns failed validation on hosts that only look like an IPv4 address.""" assert isinstance(url(value, private=private), ValidationError) + + +@pytest.mark.parametrize( + "value", + [ + "https://example.com/path", + "https://xn--e1afmkfd.xn--p1ai/", + "https://пример.рф/", + "http://例子.中国:8080/index.html", + ], +) +def test_returns_true_on_valid_top_level_domain_url(value: str): + """Test returns true on url with a TLD allowed by IANA.""" + assert url(value, consider_tld=True) + + +@pytest.mark.parametrize( + "value", + [ + "https://example.flat/", + "https://пример.рфы/", + ], +) +def test_returns_failed_validation_on_invalid_top_level_domain_url(value: str): + """Test returns failed validation on url with a TLD not allowed by IANA.""" + assert isinstance(url(value, consider_tld=True), ValidationError)