Skip to content

Commit 0dd7311

Browse files
committed
improve port matching
1 parent 9ad835d commit 0dd7311

File tree

2 files changed

+45
-16
lines changed

2 files changed

+45
-16
lines changed

src/guardrails/checks/text/urls.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,13 @@ def _is_url_allowed(
320320
url_host = url_host.lower()
321321
url_domain = url_host.replace("www.", "")
322322
scheme_lower = parsed_url.scheme.lower() if parsed_url.scheme else ""
323-
# Check if port was explicitly specified (safely)
323+
# Safely get port (rejects malformed ports)
324+
url_port = _safe_get_port(parsed_url, scheme_lower)
325+
# Early rejection of malformed ports
324326
try:
325-
url_port_explicit = parsed_url.port
327+
_ = parsed_url.port # This will raise ValueError for malformed ports
326328
except ValueError:
327-
# Malformed port (out of range or invalid) - reject the URL
328329
return False
329-
url_port = _safe_get_port(parsed_url, scheme_lower)
330330
url_path = parsed_url.path or "/"
331331
url_query = parsed_url.query
332332
url_fragment = parsed_url.fragment
@@ -368,8 +368,8 @@ def _is_url_allowed(
368368
# Scheme matching for IPs: if both allow list and URL have explicit schemes, they must match
369369
if has_explicit_scheme and url_had_explicit_scheme and allowed_scheme and allowed_scheme != scheme_lower:
370370
continue
371-
# Port matching: only enforce if either side explicitly specified a port
372-
if (allowed_port_explicit is not None or url_port_explicit is not None) and allowed_port != url_port:
371+
# Port matching: enforce if allow list has explicit port
372+
if allowed_port_explicit is not None and allowed_port != url_port:
373373
continue
374374
if allowed_ip == url_ip:
375375
return True
@@ -390,8 +390,8 @@ def _is_url_allowed(
390390

391391
allowed_domain = allowed_host.replace("www.", "")
392392

393-
# Port matching: only enforce if either side explicitly specified a port
394-
if (allowed_port_explicit is not None or url_port_explicit is not None) and allowed_port != url_port:
393+
# Port matching: enforce if allow list has explicit port
394+
if allowed_port_explicit is not None and allowed_port != url_port:
395395
continue
396396

397397
host_matches = url_domain == allowed_domain or (

tests/unit/checks/test_urls.py

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,30 +230,34 @@ def test_is_url_allowed_handles_cidr_blocks() -> None:
230230

231231

232232
def test_is_url_allowed_handles_port_matching() -> None:
233-
"""Port matching: only enforced when explicitly specified."""
233+
"""Port matching: enforced if allow list has explicit port, otherwise any port allowed."""
234234
config = URLConfig(
235235
url_allow_list=["https://example.com:8443", "api.internal.com"],
236236
allow_subdomains=False,
237237
allowed_schemes={"https"},
238238
)
239-
# Explicit port 8443 matches allow list
239+
# Explicit port 8443 matches allow list's explicit port → ALLOWED
240240
correct_port, _, had_scheme1 = _validate_url_security("https://example.com:8443", config)
241-
# Implicit 443 doesn't match explicit 8443 in allow list
241+
# Implicit 443 doesn't match allow list's explicit 8443 → BLOCKED
242242
wrong_port, _, had_scheme2 = _validate_url_security("https://example.com", config)
243-
# Explicit 9000 doesn't match implicit default in allow list (no port = not checking)
244-
explicit_port_vs_implicit, _, had_scheme3 = _validate_url_security("https://api.internal.com:9000", config)
245-
# Implicit default matches implicit default
243+
# Explicit 9000 with no port restriction in allow list → ALLOWED
244+
explicit_port_no_restriction, _, had_scheme3 = _validate_url_security("https://api.internal.com:9000", config)
245+
# Implicit 443 with no port restriction in allow list → ALLOWED
246246
implicit_match, _, had_scheme4 = _validate_url_security("https://api.internal.com", config)
247+
# Explicit default 443 with no port restriction in allow list → ALLOWED (regression fix)
248+
explicit_default_port, _, had_scheme5 = _validate_url_security("https://api.internal.com:443", config)
247249

248250
assert correct_port is not None # noqa: S101
249251
assert wrong_port is not None # noqa: S101
250-
assert explicit_port_vs_implicit is not None # noqa: S101
252+
assert explicit_port_no_restriction is not None # noqa: S101
251253
assert implicit_match is not None # noqa: S101
254+
assert explicit_default_port is not None # noqa: S101
252255

253256
assert _is_url_allowed(correct_port, config.url_allow_list, config.allow_subdomains, had_scheme1) is True # noqa: S101
254257
assert _is_url_allowed(wrong_port, config.url_allow_list, config.allow_subdomains, had_scheme2) is False # noqa: S101
255-
assert _is_url_allowed(explicit_port_vs_implicit, config.url_allow_list, config.allow_subdomains, had_scheme3) is False # noqa: S101
258+
assert _is_url_allowed(explicit_port_no_restriction, config.url_allow_list, config.allow_subdomains, had_scheme3) is True # noqa: S101
256259
assert _is_url_allowed(implicit_match, config.url_allow_list, config.allow_subdomains, had_scheme4) is True # noqa: S101
260+
assert _is_url_allowed(explicit_default_port, config.url_allow_list, config.allow_subdomains, had_scheme5) is True # noqa: S101
257261

258262

259263
def test_is_url_allowed_handles_query_and_fragment() -> None:
@@ -415,6 +419,31 @@ def test_is_url_allowed_handles_ipv6_addresses() -> None:
415419
assert _is_url_allowed(ipv6_with_ftp, config.url_allow_list, config.allow_subdomains, had_scheme2) is True # noqa: S101
416420

417421

422+
def test_is_url_allowed_handles_ipv6_cidr_notation() -> None:
423+
"""IPv6 CIDR blocks should be handled correctly (brackets stripped, path concatenated)."""
424+
config = URLConfig(
425+
url_allow_list=["[2001:db8::]/64", "[fe80::]/10"],
426+
allow_subdomains=False,
427+
allowed_schemes={"https"},
428+
)
429+
# IP within first CIDR range
430+
ip_in_range1, _, had_scheme1 = _validate_url_security("https://[2001:db8::1234]", config)
431+
# IP within second CIDR range
432+
ip_in_range2, _, had_scheme2 = _validate_url_security("https://[fe80::5678]", config)
433+
# IP outside CIDR ranges
434+
ip_outside, _, had_scheme3 = _validate_url_security("https://[2001:db9::1]", config)
435+
436+
assert ip_in_range1 is not None # noqa: S101
437+
assert ip_in_range2 is not None # noqa: S101
438+
assert ip_outside is not None # noqa: S101
439+
440+
# IPs within CIDR ranges should be allowed
441+
assert _is_url_allowed(ip_in_range1, config.url_allow_list, config.allow_subdomains, had_scheme1) is True # noqa: S101
442+
assert _is_url_allowed(ip_in_range2, config.url_allow_list, config.allow_subdomains, had_scheme2) is True # noqa: S101
443+
# IP outside should be blocked
444+
assert _is_url_allowed(ip_outside, config.url_allow_list, config.allow_subdomains, had_scheme3) is False # noqa: S101
445+
446+
418447
@pytest.mark.asyncio
419448
async def test_urls_guardrail_blocks_subdomains_and_paths_correctly() -> None:
420449
"""Verify subdomains and paths are still blocked according to allow list rules."""

0 commit comments

Comments
 (0)