From 3c2413d748cc41280eff56878a6b1d1bc9a66646 Mon Sep 17 00:00:00 2001 From: nandgator <38415384+nandgator@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:16:34 +0530 Subject: [PATCH] Reject IDNA-mapped lookalikes in domains and simple hostnames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/validators/domain.py | 20 ++++++++++++++++++++ src/validators/hostname.py | 4 +++- tests/test_domain.py | 36 ++++++++++++++++++++++++++++++++++++ tests/test_email.py | 6 ++++++ tests/test_hostname.py | 12 ++++++++++++ tests/test_url.py | 9 +++++++++ 6 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/validators/domain.py b/src/validators/domain.py index 1ce4a262..df0ebe59 100644 --- a/src/validators/domain.py +++ b/src/validators/domain.py @@ -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 @@ -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 @@ -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"$" diff --git a/src/validators/hostname.py b/src/validators/hostname.py index ad415e13..e34ab7e4 100644 --- a/src/validators/hostname.py +++ b/src/validators/hostname.py @@ -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])?(?