From 213f2ceba521f446135200626f8dd22d6b1d7f78 Mon Sep 17 00:00:00 2001 From: nandgator <38415384+nandgator@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:37:55 +0530 Subject: [PATCH] Reject control and whitespace characters in IPv6 zone IDs ipv6() hands its value to ipaddress, which accepts any non-empty zone ID without a "%", so "fe80::1%\0", "fe80::1%\n" and "fe80::1%a b" validated, and through hostname() so did "[fe80::1%a b]:22". RFC 4007 sets no character set for a zone ID, and RFC 9844 (section 6) leaves the checks to each implementation, naming NUL as one to reject. Reject zone IDs containing ASCII or Unicode control characters or whitespace. Numeric and interface-name zone IDs such as "eth0.100" still validate. email() accepted an IPv6 address literal with a zone ID, such as "user@[fe80::1%eth0]". RFC 5321 (4.1.3) defines IPv6-addr without a zone, so a "%" in the domain part is now rejected. --- src/validators/email.py | 4 ++++ src/validators/ip_address.py | 5 +++++ tests/test_email.py | 11 +++++++++++ tests/test_hostname.py | 11 +++++++++++ tests/test_ip_address.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/src/validators/email.py b/src/validators/email.py index 46cc5946..017ccbfc 100644 --- a/src/validators/email.py +++ b/src/validators/email.py @@ -60,6 +60,10 @@ def email( username_part, domain_part = value.rsplit("@", 1) + if "%" in domain_part: + # ref: RFC 5321 (4.1.3), an address literal has no zone ID + return False + if len(username_part) > 64 or len(domain_part) > 253: # ref: RFC 1034 and 5231 return False diff --git a/src/validators/ip_address.py b/src/validators/ip_address.py index 94a42c62..1ae91a3d 100644 --- a/src/validators/ip_address.py +++ b/src/validators/ip_address.py @@ -11,6 +11,7 @@ ) import re from typing import Optional +from unicodedata import category # local from .utils import validator @@ -129,6 +130,10 @@ def ipv6(value: str, /, *, cidr: bool = True, strict: bool = False, host_bit: bo """ if not value: return False + # ref: RFC 9844 (6), RFC 4007 sets no character set for a zone ID, so reject + # the control and whitespace characters that break later string processing + if any(char.isspace() or category(char) == "Cc" for char in value.partition("%")[2]): + return False try: if cidr: if strict and value.count("/") != 1: diff --git a/tests/test_email.py b/tests/test_email.py index 9b82eb46..3a11f341 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -72,3 +72,14 @@ def test_rejects_newline_after_local_part(local_part: str): 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) + + +@pytest.mark.parametrize("value", ["user@[fe80::1%eth0]", "user@[fe80::1%1]"]) +def test_returns_failed_validation_on_address_literal_with_zone_id(value: str): + """Test returns failed validation on an IPv6 address literal with a zone ID.""" + assert isinstance(email(value, ipv6_address=True), ValidationError) + + +def test_returns_true_on_ipv6_address_literal_without_zone_id(): + """Test returns true on an IPv6 address literal without a zone ID.""" + assert email("user@[fe80::1]", ipv6_address=True) diff --git a/tests/test_hostname.py b/tests/test_hostname.py index 83f00212..755d3b4e 100644 --- a/tests/test_hostname.py +++ b/tests/test_hostname.py @@ -171,3 +171,14 @@ def test_returns_true_on_valid_top_level_domain_hostname(value: str): def test_returns_failed_validation_on_invalid_top_level_domain_hostname(value: str): """Test returns failed validation on hostname with a TLD not allowed by IANA.""" assert isinstance(hostname(value, consider_tld=True), ValidationError) + + +def test_returns_true_on_bracketed_ipv6_with_zone_id(): + """Test returns true on a bracketed link-local IPv6 address with a zone ID.""" + assert hostname("[fe80::1%eth0]:22") + + +@pytest.mark.parametrize("value", ["[fe80::1%eth0 ]:22", "[fe80::1%\n]:22", "fe80::1%a b"]) +def test_returns_failed_validation_on_zone_id_with_control_or_space(value: str): + """Test returns failed validation on a zone ID with a control or whitespace character.""" + assert isinstance(hostname(value), ValidationError) diff --git a/tests/test_ip_address.py b/tests/test_ip_address.py index ed6fd7d4..711fb963 100644 --- a/tests/test_ip_address.py +++ b/tests/test_ip_address.py @@ -204,3 +204,32 @@ def test_returns_true_on_valid_public_ipv4_address(address: str, private: bool): def test_returns_failed_validation_on_invalid_public_ipv4_address(address: str, private: bool): """Test returns failed validation on private ipv4 address.""" assert isinstance(ipv4(address, private=private), ValidationError) + + +@pytest.mark.parametrize( + "zone_id", + [ + "\x00", + "\t", + "\n", + "\r", + "\x7f", + "\x85", # next line, a C1 control + " ", + "eth0 ", + "a b", + "\u3000", # ideographic space + "\u2028", # line separator + ], +) +@pytest.mark.parametrize("cidr", [False, True]) +def test_returns_failed_validation_on_zone_id_with_control_or_space(zone_id: str, cidr: bool): + """Test returns failed validation on a zone ID with a control or whitespace character.""" + assert isinstance(ipv6(f"fe80::1%{zone_id}", cidr=cidr), ValidationError) + + +@pytest.mark.parametrize("zone_id", ["1", "eth0", "eth0.100", "br-lan", "en0", "25eth0"]) +@pytest.mark.parametrize("cidr", [False, True]) +def test_returns_true_on_zone_id(zone_id: str, cidr: bool): + """Test returns true on numeric and interface-name zone IDs.""" + assert ipv6(f"fe80::1%{zone_id}", cidr=cidr)