diff --git a/src/paperless/network.py b/src/paperless/network.py index 7c598063c..83d48e12d 100644 --- a/src/paperless/network.py +++ b/src/paperless/network.py @@ -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: diff --git a/src/paperless/tests/test_network.py b/src/paperless/tests/test_network.py index 09a35fe82..23d9fb3ba 100644 --- a/src/paperless/tests/test_network.py +++ b/src/paperless/tests/test_network.py @@ -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, + )