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)