mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-03 06:40:31 +00:00
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:
co-authored by
Claude Opus 5
parent
5e971bc0ce
commit
0da50ad348
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user