Skip to content

Commit 13a5cdb

Browse files
committed
Reject IDNA-mapped lookalikes in domains and simple hostnames
Python's idna codec implements IDNA 2003, whose nameprep step drops invisible characters (soft hyphen, zero width space, joiners, variation selectors), folds compatibility characters such as fullwidth letters, the Kelvin sign or the long s to ASCII, and splits labels on U+3002 and similar dots. domain() validated the mapped result, so "example.com" or "exam­ple.com" passed as example.com through domain(), hostname(), url() and email(). Reject non-ASCII values that are not NFKC-stable or that contain characters nameprep maps to nothing or treats as a dot. Internationalized names that need no such mapping, such as "пример.рф" or "straße.de", are still accepted. The simple hostname regex used [a-z] with re.IGNORECASE, which also matches U+0130, U+0131, U+017F and U+212A; add re.ASCII.
1 parent f8c4a0f commit 13a5cdb

6 files changed

Lines changed: 86 additions & 1 deletion

File tree

‎src/validators/domain.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
from os import environ
55
from pathlib import Path
66
import re
7+
from stringprep import in_table_b1
78
from typing import Optional, Set
9+
from unicodedata import normalize
810

911
# local
1012
from .utils import validator
@@ -37,6 +39,21 @@ def check(cls, tld: str):
3739
return tld in cls._full_cache
3840

3941

42+
def _relies_on_idna_mapping(value: str):
43+
"""Return whether `value` only becomes a valid name through IDNA 2003 mapping.
44+
45+
Python's `idna` codec implements IDNA 2003 (RFC 3490), whose nameprep step
46+
(RFC 3491) drops invisible characters (RFC 3454, table B.1), folds
47+
compatibility characters such as fullwidth letters to ASCII, and treats
48+
U+3002 and similar dots as label separators. A value that needs any of
49+
these only looks like the name it encodes to, e.g. "example.com".
50+
"""
51+
return not value.isascii() and (
52+
normalize("NFKC", value) != value
53+
or any(in_table_b1(char) or char in "\u3002\uff0e\uff61" for char in value)
54+
)
55+
56+
4057
@validator
4158
def domain(
4259
value: str, /, *, consider_tld: bool = False, rfc_1034: bool = False, rfc_2782: bool = False
@@ -80,6 +97,9 @@ def domain(
8097
return False
8198

8299
try:
100+
if _relies_on_idna_mapping(value):
101+
return False
102+
83103
service_record = r"_" if rfc_2782 else ""
84104
trailing_dot = r"\.?$" if rfc_1034 else r"$"
85105

‎src/validators/hostname.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ def _simple_hostname_regex():
2626
"""Simple hostname validation regex."""
2727
# {0,59} because two characters are already matched at
2828
# the beginning and at the end, making the range {1, 61}
29-
return re.compile(r"^(?!-)[a-z0-9](?:[a-z0-9-]{0,59}[a-z0-9])?(?<!-)$", re.IGNORECASE)
29+
return re.compile(
30+
r"^(?!-)[a-z0-9](?:[a-z0-9-]{0,59}[a-z0-9])?(?<!-)$", re.IGNORECASE | re.ASCII
31+
)
3032

3133

3234
def _port_validator(value: str):

‎tests/test_domain.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,39 @@ def test_returns_true_on_domain_within_rfc_length_limits(value: str):
167167
def test_returns_failed_validation_on_domain_beyond_rfc_limits(value: str):
168168
"""Test returns failed validation on domain beyond RFC limits."""
169169
assert isinstance(domain(value), ValidationError)
170+
171+
172+
@pytest.mark.parametrize(
173+
"value",
174+
[
175+
# IDNA 2003 maps these to "example.com"; see `_relies_on_idna_mapping`
176+
"example.com", # fullwidth letters
177+
"example.com",
178+
"example.ⅽom", # small roman numeral one hundred
179+
"Kelvin.com", # Kelvin sign
180+
"ſhop.com", # long s
181+
"example。com", # ideographic full stop
182+
"example.com", # fullwidth full stop
183+
"example。com", # halfwidth ideographic full stop
184+
"exam­ple.com", # soft hyphen
185+
"example.co​m", # zero width space
186+
"exa‍mple.com", # zero width joiner
187+
"exam️ple.com", # variation selector
188+
"examp⁠le.com", # word joiner
189+
"example.com", # zero width no-break space
190+
"exämple.com".replace("ä", "ä"), # not NFC
191+
],
192+
)
193+
@pytest.mark.parametrize("consider_tld", [False, True])
194+
def test_returns_failed_validation_on_idna_mapped_lookalike(value: str, consider_tld: bool):
195+
"""Test returns failed validation on names that only IDNA 2003 mapping makes valid."""
196+
assert isinstance(domain(value, consider_tld=consider_tld), ValidationError)
197+
198+
199+
@pytest.mark.parametrize(
200+
"value",
201+
["пример.рф", "ПРИМЕР.РФ", "例子.中国", "kräuter.com", "straße.de", "ς.gr", "εχαμπλε.gr"],
202+
)
203+
def test_returns_true_on_idn_without_lookalike_mapping(value: str):
204+
"""Test returns true on internationalized names that need no lossy mapping."""
205+
assert domain(value)

‎tests/test_email.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,9 @@ def test_rejects_newline_after_local_part(local_part: str):
6666
# 2026-10-11: A newline before the at-sign is not part of a valid local part.
6767
assert email(f"{local_part}@example.com") is True
6868
assert isinstance(email(f"{local_part}\n@example.com"), ValidationError)
69+
70+
71+
@pytest.mark.parametrize("domain_part", ["example.com", "exam­ple.com"])
72+
def test_returns_failed_validation_on_idna_mapped_lookalike_domain(domain_part: str):
73+
"""Test returns failed validation on email domains that only IDNA 2003 mapping makes valid."""
74+
assert isinstance(email(f"user@{domain_part}"), ValidationError)

‎tests/test_hostname.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,15 @@ def test_returns_failed_validation_on_hostname_beyond_rfc_limits(value: str):
125125
def test_rejects_trailing_newline_in_hostnames_and_ports(value: str):
126126
"""Test rejects trailing newline in hostnames and ports."""
127127
assert isinstance(hostname(value), ValidationError)
128+
129+
130+
@pytest.mark.parametrize("value", ["ſerver", "Kelvin", "ıntranet", "İntranet:8080"])
131+
def test_returns_failed_validation_on_non_ascii_simple_hostname(value: str):
132+
"""Test returns failed validation on non-ASCII case equivalents in simple hostnames."""
133+
assert isinstance(hostname(value), ValidationError)
134+
135+
136+
@pytest.mark.parametrize("value", ["example.com", "exam­ple.com:443"])
137+
def test_returns_failed_validation_on_idna_mapped_lookalike_hostname(value: str):
138+
"""Test returns failed validation on hostnames that only IDNA 2003 mapping makes valid."""
139+
assert isinstance(hostname(value), ValidationError)

‎tests/test_url.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,12 @@ def test_url_rejects_raw_ascii_controls(control: str, template: str):
257257
def test_url_accepts_percent_encoded_controls(encoded: str, template: str):
258258
"""Keep escaped data valid while rejecting literal control characters."""
259259
assert url(template.format(encoded=encoded)) is True
260+
261+
262+
@pytest.mark.parametrize(
263+
"value",
264+
["https://example.com/", "https://exam­ple.com/", "http://ſerver/"],
265+
)
266+
def test_returns_failed_validation_on_idna_mapped_lookalike_url(value: str):
267+
"""Test returns failed validation on url hosts that only IDNA 2003 mapping makes valid."""
268+
assert isinstance(url(value, simple_host=True), ValidationError)

0 commit comments

Comments
 (0)