Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/+file_url_sanitation.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed file downloader path sanitation. CVE-2026-90959
34 changes: 19 additions & 15 deletions pulpcore/app/serializers/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
4 changes: 4 additions & 0 deletions pulpcore/download/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from urllib.parse import urlparse

import aiofiles
from rest_framework.serializers import ValidationError

from .base import BaseDownloader, DownloadResult

Expand Down Expand Up @@ -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)

Expand Down
7 changes: 7 additions & 0 deletions pulpcore/tests/unit/download/test_downloader_factory.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
41 changes: 41 additions & 0 deletions pulpcore/tests/unit/download/test_downloader_file.py
Original file line number Diff line number Diff line change
@@ -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)
Loading