diff --git a/src/validators/domain.py b/src/validators/domain.py index df0ebe59..24f46180 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 @@ -100,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/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..ab56fc7d 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]) @@ -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)