diff --git a/CHANGES/+file_url_sanitation.bugfix b/CHANGES/+file_url_sanitation.bugfix new file mode 100644 index 00000000000..43b2827dcb7 --- /dev/null +++ b/CHANGES/+file_url_sanitation.bugfix @@ -0,0 +1 @@ +Fixed file downloader path sanitation. CVE-2026-90959 diff --git a/pulpcore/app/serializers/repository.py b/pulpcore/app/serializers/repository.py index 8a6d669775e..24377375cab 100644 --- a/pulpcore/app/serializers/repository.py +++ b/pulpcore/app/serializers/repository.py @@ -282,24 +282,28 @@ def validate_url(self, url): ) ) - if not url.lower().startswith("file://"): - return url - - user_path = url[7:] - if not os.path.isabs(user_path): - raise serializers.ValidationError( - _("The path '{}' needs to be an absolute pathname.").format(user_path) - ) + if parsed_url.scheme == "file": + user_path = parsed_url.path + if not os.path.isabs(user_path): + raise serializers.ValidationError( + _("The path '{}' needs to be an absolute pathname.").format(user_path) + ) + if user_path.rstrip("/") != os.path.normpath(user_path): + raise serializers.ValidationError(_("The path '{}' is not normalized.")) - user_provided_realpath = os.path.realpath(user_path) + user_provided_realpath = os.path.realpath(user_path) - for allowed_path in settings.ALLOWED_IMPORT_PATHS: - if user_provided_realpath.startswith(allowed_path): - return url + if not any( + user_provided_realpath.startswith(allowed_path) + for allowed_path in settings.ALLOWED_IMPORT_PATHS + ): + raise serializers.ValidationError( + _("The path '{}' does not start with any of the allowed import paths").format( + user_path + ) + ) - raise serializers.ValidationError( - _("The path '{}' does not start with any of the allowed import paths").format(user_path) - ) + return url def validate_proxy_url(self, value): """ diff --git a/pulpcore/download/file.py b/pulpcore/download/file.py index 4e72570cdeb..9fa26697929 100644 --- a/pulpcore/download/file.py +++ b/pulpcore/download/file.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse import aiofiles +from rest_framework.serializers import ValidationError from .base import BaseDownloader, DownloadResult @@ -35,6 +36,9 @@ def __init__(self, url, *args, **kwargs): RemoteSerializer().validate_url(url) p = urlparse(url) + if p.scheme != "file": + raise ValidationError("Not a valid file url.") + self._path = os.path.abspath(os.path.join(p.netloc, p.path)) super().__init__(url, *args, **kwargs) diff --git a/pulpcore/tests/unit/download/test_downloader_factory.py b/pulpcore/tests/unit/download/test_downloader_factory.py index 9277fb82af2..900a0847456 100644 --- a/pulpcore/tests/unit/download/test_downloader_factory.py +++ b/pulpcore/tests/unit/download/test_downloader_factory.py @@ -1,9 +1,16 @@ import pytest +from pulpcore.app.util import get_domain from pulpcore.download.factory import DownloaderFactory from pulpcore.plugin.models import Remote +@pytest.fixture(autouse=True) +def mock_default_domain(db): + # Cache the domain in synchronous context before starting the tests. + get_domain() + + @pytest.mark.asyncio async def test_user_agent_header(): remote = Remote(url="http://example.org/", name="foo") diff --git a/pulpcore/tests/unit/download/test_downloader_file.py b/pulpcore/tests/unit/download/test_downloader_file.py new file mode 100644 index 00000000000..1a78348e518 --- /dev/null +++ b/pulpcore/tests/unit/download/test_downloader_file.py @@ -0,0 +1,41 @@ +import pytest +from rest_framework.serializers import ValidationError + +from pulpcore.download import FileDownloader + + +@pytest.fixture +def import_paths(settings): + settings.ALLOWED_IMPORT_PATHS = ["/static", "/var/www"] + + +@pytest.mark.parametrize( + "url", + [ + "file:///tmp/www", + "file:///tmp/www/", + "file:///tmp/www/a", + "file:///tmp/www/a/", + ], +) +def test_file_downloader_accepts_regular_file_urls(url, import_paths): + FileDownloader(url) + + +@pytest.mark.parametrize( + "url", + [ + "", + "...", + "/", + "file:///", + "file:.", + "file://var/www", + "file:///../../../../var/www", + "file:///var/www/../../../../etc/secrets", + "file:../../../../../../../etc/secrets", + ], +) +def test_file_downloader_rejects_path_traversal_attempts(url, import_paths): + with pytest.raises(ValidationError): + FileDownloader(url)