From 0ea8d4271f70cf91a03206e3b104e5d16b7c6dd5 Mon Sep 17 00:00:00 2001 From: nandgator <38415384+nandgator@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:46:59 +0530 Subject: [PATCH 1/3] docs: describe the fullwidth example in domain() instead of embedding it The _relies_on_idna_mapping docstring contained a literal fullwidth "example.com", which reads as plain ASCII in the source. --- src/validators/domain.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/validators/domain.py b/src/validators/domain.py index df0ebe59..0907d042 100644 --- a/src/validators/domain.py +++ b/src/validators/domain.py @@ -46,7 +46,8 @@ def _relies_on_idna_mapping(value: str): (RFC 3491) drops invisible characters (RFC 3454, table B.1), folds compatibility characters such as fullwidth letters to ASCII, and treats U+3002 and similar dots as label separators. A value that needs any of - these only looks like the name it encodes to, e.g. "example.com". + these only looks like the name it encodes to, e.g. "example.com" written + with fullwidth letters. """ return not value.isascii() and ( normalize("NFKC", value) != value From 6772220788e8b81aaf6899e5155da9554720be7a Mon Sep 17 00:00:00 2001 From: nandgator <38415384+nandgator@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:49:49 +0530 Subject: [PATCH 2/3] test: escape the remaining confusable test characters An ideographic full stop and a combining diaeresis on an ASCII letter in the domain lookalike tests, and the fullwidth digits in the Spanish identifier tests, read as plain ASCII in the source. Write them as escapes; the test values are unchanged. --- tests/i18n/test_es.py | 9 ++++++++- tests/test_domain.py | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/i18n/test_es.py b/tests/i18n/test_es.py index dc227726..55790400 100644 --- a/tests/i18n/test_es.py +++ b/tests/i18n/test_es.py @@ -127,7 +127,14 @@ def test_returns_true_on_valid_doi(value: str): assert es_doi(value) -@pytest.mark.parametrize("digits", ["0123456789", "٠١٢٣٤٥٦٧٨٩", "०१२३४५६७८९"]) +@pytest.mark.parametrize( + "digits", + [ + "\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19", + "\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669", + "\u0966\u0967\u0968\u0969\u096a\u096b\u096c\u096d\u096e\u096f", + ], +) @pytest.mark.parametrize( "validate,value", [ diff --git a/tests/test_domain.py b/tests/test_domain.py index c26b2bbf..41f29c31 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -178,7 +178,7 @@ def test_returns_failed_validation_on_domain_beyond_rfc_limits(value: str): "example.\u217dom", # small roman numeral one hundred "\u212aelvin.com", # Kelvin sign "\u017fhop.com", # long s - "example。com", # ideographic full stop + "example\u3002com", # ideographic full stop "example\uff0ecom", # fullwidth full stop "example\uff61com", # halfwidth ideographic full stop "exam\u00adple.com", # soft hyphen @@ -187,7 +187,7 @@ def test_returns_failed_validation_on_domain_beyond_rfc_limits(value: str): "exam\ufe0fple.com", # variation selector "examp\u2060le.com", # word joiner "\ufeffexample.com", # zero width no-break space - "exämple.com".replace("ä", "ä"), # not NFC + "exämple.com".replace("ä", "a\u0308"), # not NFC ], ) @pytest.mark.parametrize("consider_tld", [False, True]) From 4b1eaeb34b60ea533b3cf9a631d09069deed6d32 Mon Sep 17 00:00:00 2001 From: nandgator <38415384+nandgator@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:49:49 +0530 Subject: [PATCH 3/3] Reject all-numeric IDN top-level labels in domain() RFC 1123 (2.1) and RFC 3696 (2) require the top-level label to not be all-numeric, so a host name never has the dotted-decimal form #.#.#.#. domain() enforced this by requiring the encoded TLD to end in a letter, but a label of non-ASCII digits encodes to a letter-final A-label, so "10.0.0.\u0661" (Arabic-Indic one) validated as a domain, and through hostname() and url() as a host that looks like a private IPv4 address. Reject a value whose last label is made only of decimal digits from any script. --- src/validators/domain.py | 5 +++++ tests/test_domain.py | 26 ++++++++++++++++++++++++++ tests/test_hostname.py | 6 ++++++ tests/test_url.py | 7 +++++++ 4 files changed, 44 insertions(+) diff --git a/src/validators/domain.py b/src/validators/domain.py index 0907d042..24f46180 100644 --- a/src/validators/domain.py +++ b/src/validators/domain.py @@ -101,6 +101,11 @@ def domain( if _relies_on_idna_mapping(value): return False + if value.rstrip(".").rsplit(".", 1)[-1].isdecimal(): + # ref: RFC 1123 (2.1) and RFC 3696 (2), a TLD is not all-numeric. An + # all-digit IDN label like "\u0661" is, but encodes to a letter-final A-label. + return False + service_record = r"_" if rfc_2782 else "" trailing_dot = r"\.?$" if rfc_1034 else r"$" diff --git a/tests/test_domain.py b/tests/test_domain.py index 41f29c31..ab56fc7d 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -203,3 +203,29 @@ def test_returns_failed_validation_on_idna_mapped_lookalike(value: str, consider def test_returns_true_on_idn_without_lookalike_mapping(value: str): """Test returns true on internationalized names that need no lossy mapping.""" assert domain(value) + + +@pytest.mark.parametrize( + "value", + [ + "10.0.0.\u0661", # Arabic-Indic one; looks like 10.0.0.1 + "example.\u0661\u0662\u0663", + "example.\u0968", # Devanagari two + "123.5.77.\u06688", # Arabic-Indic and ASCII digits + ], +) +@pytest.mark.parametrize("rfc_1034", [False, True]) +def test_returns_failed_validation_on_all_numeric_idn_tld(value: str, rfc_1034: bool): + """Test returns failed validation on a TLD made only of digits from any script.""" + assert isinstance(domain(value, rfc_1034=rfc_1034), ValidationError) + + +def test_returns_failed_validation_on_all_numeric_idn_tld_with_trailing_dot(): + """Test returns failed validation on an all-numeric IDN TLD followed by a trailing dot.""" + assert isinstance(domain("example.\u0661.", rfc_1034=True), ValidationError) + + +@pytest.mark.parametrize("value", ["\u0661\u0662.com", "example.\u0661a", "1.example.com"]) +def test_returns_true_on_digits_outside_or_mixed_in_tld(value: str): + """Test returns true when digits appear only in other labels or alongside letters.""" + assert domain(value) diff --git a/tests/test_hostname.py b/tests/test_hostname.py index 5855649c..f62dced7 100644 --- a/tests/test_hostname.py +++ b/tests/test_hostname.py @@ -139,3 +139,9 @@ def test_returns_failed_validation_on_non_ascii_simple_hostname(value: str): def test_returns_failed_validation_on_idna_mapped_lookalike_hostname(value: str): """Test returns failed validation on hostnames that only IDNA 2003 mapping makes valid.""" assert isinstance(hostname(value), ValidationError) + + +@pytest.mark.parametrize("value", ["10.0.0.\u0661", "123.5.77.\u06688:31000"]) +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) diff --git a/tests/test_url.py b/tests/test_url.py index af22edb3..9b5e2d77 100644 --- a/tests/test_url.py +++ b/tests/test_url.py @@ -266,3 +266,10 @@ def test_url_accepts_percent_encoded_controls(encoded: str, template: str): def test_returns_failed_validation_on_idna_mapped_lookalike_url(value: str): """Test returns failed validation on url hosts that only IDNA 2003 mapping makes valid.""" assert isinstance(url(value, simple_host=True), ValidationError) + + +@pytest.mark.parametrize("value", ["http://10.0.0.\u0661/", "https://192.168.1.\u0661:8080/"]) +@pytest.mark.parametrize("private", [None, False, True]) +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)