From 3d72077fa142d830cb3e4796402ea6e6fbdd56fc Mon Sep 17 00:00:00 2001 From: nandgator <38415384+nandgator@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:23:12 +0530 Subject: [PATCH] Require exactly one pair of brackets around IP literals hostname() stripped every leading "[" and trailing "]" from a host followed by a port, then validated what was left as a simple hostname, domain, IPv4 or IPv6 address. "[1.2.3.4]:80", "[example.com]:80", "[[::1]]:80" and "::1]:80" all validated. RFC 3986 (3.2.2) only encloses an IPv6 (or IPvFuture) address in brackets, so a bracketed host must now be exactly one pair around an IPv6 address. email() likewise stripped repeated brackets from an address literal, accepting "user@[[192.0.2.1]]" and "user@[192.0.2.1]]". It now strips exactly one pair. Inside the pair, any further bracket is rejected, since ipv6() accepts "]" as part of a zone ID ("::1%]"). --- src/validators/email.py | 4 +++- src/validators/hostname.py | 8 +++++++- tests/test_email.py | 30 ++++++++++++++++++++++++++++++ tests/test_hostname.py | 26 ++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/validators/email.py b/src/validators/email.py index 46cc5946..021adf76 100644 --- a/src/validators/email.py +++ b/src/validators/email.py @@ -67,7 +67,9 @@ def email( if ipv6_address or ipv4_address: if domain_part.startswith("[") and domain_part.endswith("]"): # ref: RFC 5321 - domain_part = domain_part.lstrip("[").rstrip("]") + domain_part = domain_part[1:-1] + if "[" in domain_part or "]" in domain_part: + return False else: return False diff --git a/src/validators/hostname.py b/src/validators/hostname.py index e34ab7e4..c6578271 100644 --- a/src/validators/hostname.py +++ b/src/validators/hostname.py @@ -37,7 +37,7 @@ def _port_validator(value: str): # with ipv6 host_seg, port_seg = value.rsplit(":", 1) if _port_regex().fullmatch(f":{port_seg}"): - return host_seg.lstrip("[").rstrip("]") + return host_seg if value.count(":") == 1: # with ipv4 or simple hostname @@ -125,6 +125,12 @@ def _matches_simple_hostname(host: str): return _simple_hostname_regex().fullmatch(host) if may_have_port and (host_seg := _port_validator(value)): + if host_seg.startswith("[") and host_seg.endswith("]"): + # ref: RFC 3986 (3.2.2), one pair of brackets encloses an IPv6 address + host_seg = host_seg[1:-1] + if skip_ipv6_addr or "[" in host_seg or "]" in host_seg: + return False + return ipv6(host_seg, cidr=False) return ( _matches_simple_hostname(host_seg) or domain(host_seg, consider_tld=consider_tld, rfc_1034=rfc_1034, rfc_2782=rfc_2782) diff --git a/tests/test_email.py b/tests/test_email.py index 9b82eb46..f31e43e0 100644 --- a/tests/test_email.py +++ b/tests/test_email.py @@ -72,3 +72,33 @@ 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", "ipv6_address", "ipv4_address"), + [ + ("user@[192.0.2.1]", False, True), + ("user@[2001:db8::1]", True, False), + ], +) +def test_returns_true_on_address_literal(value: str, ipv6_address: bool, ipv4_address: bool): + """Test returns true on an email whose domain is a bracketed IP address.""" + assert email(value, ipv6_address=ipv6_address, ipv4_address=ipv4_address) + + +@pytest.mark.parametrize( + "value", + [ + "user@[[192.0.2.1]]", + "user@[192.0.2.1]]", + "user@[[192.0.2.1]", + "user@[[2001:db8::1]]", + "user@[[[2001:db8::1]]]", + "user@[2001:db8::1]]", + "user@[2001:db8::1%]]", + "user@[]", + ], +) +def test_returns_failed_validation_on_address_literal_with_extra_brackets(value: str): + """Test returns failed validation on an address literal with more than one pair of brackets.""" + assert isinstance(email(value, ipv6_address=True, ipv4_address=True), ValidationError) diff --git a/tests/test_hostname.py b/tests/test_hostname.py index 83f00212..cd1932e2 100644 --- a/tests/test_hostname.py +++ b/tests/test_hostname.py @@ -171,3 +171,29 @@ 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) + + +@pytest.mark.parametrize( + "value", + [ + # ref: RFC 3986 (3.2.2), only an IPv6 address is enclosed in brackets + "[1.2.3.4]:80", + "[example.com]:80", + "[localhost]:80", + # exactly one pair of brackets + "[[::1]]:80", + "[[::1]:80", + "[::1]]:80", + "::1]:80", + "localhost]:80", + "[::1%]]:80", + ], +) +def test_returns_failed_validation_on_malformed_bracketed_host(value: str): + """Test returns failed validation on brackets that do not enclose one IPv6 address.""" + assert isinstance(hostname(value), ValidationError) + + +def test_returns_failed_validation_on_bracketed_ipv6_when_skipped(): + """Test returns failed validation on a bracketed IPv6 address when IPv6 is skipped.""" + assert isinstance(hostname("[::1]:80", skip_ipv6_addr=True), ValidationError)