Reject outbound URLs that HTTP clients may split differently

urllib3 treats a backslash as the end of the URL authority, while urlparse
and httpx do not. For a URL such as http://127.0.0.1\@evil.example/ the
check resolved evil.example while urllib3 would connect to 127.0.0.1, so a
redirect to such a URL could reach an internal host.

When internal addresses are disallowed, validate_outbound_http_url now
rejects any URL containing a backslash, an ASCII control character or
whitespace before resolving it. URLs validated with internal addresses
allowed are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
stumpylog
2026-09-22 12:47:40 -07:00
co-authored by Claude Opus 5
parent 5e971bc0ce
commit 0da50ad348
2 changed files with 58 additions and 0 deletions
+9
View File
@@ -1,5 +1,6 @@
import functools
import ipaddress
import re
import socket
from collections.abc import Callable
from collections.abc import Collection
@@ -207,6 +208,12 @@ def format_host_for_url(host: str) -> str:
return host
# urllib3 treats a backslash as ending the authority while urlparse and httpx do
# not, so the host checked here could differ from the one that is dialled.
# Control and whitespace characters are refused for the same reason.
_UNSAFE_URL_CHARS = re.compile(r"[\\\x00-\x1f\x7f\s]")
def _dns_name(url: str) -> str:
"""
The ASCII hostname that httpx and urllib3 look up for ``url``.
@@ -246,6 +253,8 @@ def validate_outbound_http_url(
raise ValueError("Destination port not permitted.")
if not allow_internal:
if _UNSAFE_URL_CHARS.search(url):
raise ValueError("Invalid URL scheme or hostname.")
try:
resolve_public_addresses(_dns_name(url), port)
except (OutboundRequestBlockedError, HostResolutionError) as e:
+49
View File
@@ -590,3 +590,52 @@ class TestValidateOutboundHttpUrl:
)
resolver.assert_not_called()
@pytest.mark.parametrize(
"url",
[
pytest.param(r"http://127.0.0.1\@evil.example/", id="backslash"),
pytest.param(
r"http://127.0.0.1:80\@evil.example/",
id="backslash-with-port",
),
pytest.param("http://evil\t.example/", id="tab-in-host"),
pytest.param("http://evil .example/", id="space-in-host"),
],
)
def test_rejects_urls_http_clients_may_parse_differently(
self,
mocker: MockerFixture,
url: str,
) -> None:
"""
GIVEN:
- A URL containing a backslash, control or whitespace character,
which urllib3 may split into a different host than urlparse and
httpx do
- A resolver that would answer with a public address
WHEN:
- The URL is validated with internal addresses disallowed
THEN:
- It is rejected as invalid without a resolver call
"""
resolver = _answer(mocker, "93.184.216.34")
with pytest.raises(ValueError, match="Invalid URL scheme or hostname"):
validate_outbound_http_url(url, allow_internal=False)
resolver.assert_not_called()
def test_allow_internal_does_not_reject_backslash(self) -> None:
"""
GIVEN:
- A URL containing a backslash
WHEN:
- The URL is validated with internal addresses allowed
THEN:
- It is not rejected, since no host check is made
"""
validate_outbound_http_url(
r"http://127.0.0.1\@evil.example/",
allow_internal=True,
)