Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/validators/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
from os import environ
from pathlib import Path
import re
from stringprep import in_table_b1
from typing import Optional, Set
from unicodedata import normalize

# local
from .utils import validator
Expand Down Expand Up @@ -37,6 +39,21 @@ def check(cls, tld: str):
return tld in cls._full_cache


def _relies_on_idna_mapping(value: str):
"""Return whether `value` only becomes a valid name through IDNA 2003 mapping.

Python's `idna` codec implements IDNA 2003 (RFC 3490), whose nameprep step
(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".
"""
return not value.isascii() and (
normalize("NFKC", value) != value
or any(in_table_b1(char) or char in "\u3002\uff0e\uff61" for char in value)
)


@validator
def domain(
value: str, /, *, consider_tld: bool = False, rfc_1034: bool = False, rfc_2782: bool = False
Expand Down Expand Up @@ -80,6 +97,9 @@ def domain(
return False

try:
if _relies_on_idna_mapping(value):
return False

service_record = r"_" if rfc_2782 else ""
trailing_dot = r"\.?$" if rfc_1034 else r"$"

Expand Down
4 changes: 3 additions & 1 deletion src/validators/hostname.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ def _simple_hostname_regex():
"""Simple hostname validation regex."""
# {0,59} because two characters are already matched at
# the beginning and at the end, making the range {1, 61}
return re.compile(r"^(?!-)[a-z0-9](?:[a-z0-9-]{0,59}[a-z0-9])?(?<!-)$", re.IGNORECASE)
return re.compile(
r"^(?!-)[a-z0-9](?:[a-z0-9-]{0,59}[a-z0-9])?(?<!-)$", re.IGNORECASE | re.ASCII
)


def _port_validator(value: str):
Expand Down
36 changes: 36 additions & 0 deletions tests/test_domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,39 @@ def test_returns_true_on_domain_within_rfc_length_limits(value: str):
def test_returns_failed_validation_on_domain_beyond_rfc_limits(value: str):
"""Test returns failed validation on domain beyond RFC limits."""
assert isinstance(domain(value), ValidationError)


@pytest.mark.parametrize(
"value",
[
# IDNA 2003 maps these to "example.com"; see `_relies_on_idna_mapping`
"example.com", # fullwidth letters
"example.com",
"example.ⅽom", # small roman numeral one hundred
"Kelvin.com", # Kelvin sign
"ſhop.com", # long s
"example。com", # ideographic full stop
"example.com", # fullwidth full stop
"example。com", # halfwidth ideographic full stop
"exam­ple.com", # soft hyphen
"example.co​m", # zero width space
"exa‍mple.com", # zero width joiner
"exam️ple.com", # variation selector
"examp⁠le.com", # word joiner
"example.com", # zero width no-break space
"exämple.com".replace("ä", "ä"), # not NFC
],
)
@pytest.mark.parametrize("consider_tld", [False, True])
def test_returns_failed_validation_on_idna_mapped_lookalike(value: str, consider_tld: bool):
"""Test returns failed validation on names that only IDNA 2003 mapping makes valid."""
assert isinstance(domain(value, consider_tld=consider_tld), ValidationError)


@pytest.mark.parametrize(
"value",
["пример.рф", "ПРИМЕР.РФ", "例子.中国", "kräuter.com", "straße.de", "ς.gr", "εχαμπλε.gr"],
)
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)
6 changes: 6 additions & 0 deletions tests/test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,9 @@ def test_rejects_newline_after_local_part(local_part: str):
# 2026-10-11: A newline before the at-sign is not part of a valid local part.
assert email(f"{local_part}@example.com") is True
assert isinstance(email(f"{local_part}\n@example.com"), ValidationError)


@pytest.mark.parametrize("domain_part", ["example.com", "exam­ple.com"])
def test_returns_failed_validation_on_idna_mapped_lookalike_domain(domain_part: str):
"""Test returns failed validation on email domains that only IDNA 2003 mapping makes valid."""
assert isinstance(email(f"user@{domain_part}"), ValidationError)
12 changes: 12 additions & 0 deletions tests/test_hostname.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,15 @@ def test_returns_failed_validation_on_hostname_beyond_rfc_limits(value: str):
def test_rejects_trailing_newline_in_hostnames_and_ports(value: str):
"""Test rejects trailing newline in hostnames and ports."""
assert isinstance(hostname(value), ValidationError)


@pytest.mark.parametrize("value", ["ſerver", "Kelvin", "ıntranet", "İntranet:8080"])
def test_returns_failed_validation_on_non_ascii_simple_hostname(value: str):
"""Test returns failed validation on non-ASCII case equivalents in simple hostnames."""
assert isinstance(hostname(value), ValidationError)


@pytest.mark.parametrize("value", ["example.com", "exam­ple.com:443"])
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)
9 changes: 9 additions & 0 deletions tests/test_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,12 @@ def test_url_rejects_raw_ascii_controls(control: str, template: str):
def test_url_accepts_percent_encoded_controls(encoded: str, template: str):
"""Keep escaped data valid while rejecting literal control characters."""
assert url(template.format(encoded=encoded)) is True


@pytest.mark.parametrize(
"value",
["https://example.com/", "https://exam­ple.com/", "http://ſerver/"],
)
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)
Loading