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
2 changes: 2 additions & 0 deletions CHANGES/+cve-2026-84232.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Insures that `Content-Disposition`, `Content-Security-Policy`, and `X-Content-Type-Options`
are set consistently in the content-app. This addresses CVE-2026-84232.
60 changes: 60 additions & 0 deletions pulp_file/tests/functional/api/test_responses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import asyncio
import uuid
from urllib.parse import urljoin

import aiohttp
import pytest
from django.conf import settings

from pulpcore.client.pulp_file import FileFileDistribution, RepositoryAddRemoveContent


@pytest.mark.parallel
@pytest.mark.skipif(
settings.REDIRECT_TO_OBJECT_STORAGE
and not settings.storages["default"]["BACKEND"] == "pulpcore.app.models.storage.FileSystem",
reason="Object-storage and redirect==True means response-headers aren't controlled by Pulp code.",
)
def test_response_headers(
file_bindings,
distribution_base_url,
file_repo_with_auto_publish,
gen_object_with_cleanup,
monitor_task,
tmp_path,
):
blob = tmp_path / "blob"
blob.write_bytes(b"response-headers-test")
a_file = file_bindings.ContentFilesApi.upload(
file=str(blob), relative_path=f"{uuid.uuid4()}.html"
)

data = RepositoryAddRemoveContent(add_content_units=[a_file.pulp_href])
monitor_task(
file_bindings.RepositoriesFileApi.modify(file_repo_with_auto_publish.pulp_href, data).task
)

data = FileFileDistribution(
name=str(uuid.uuid4()),
base_path=str(uuid.uuid4()),
repository=file_repo_with_auto_publish.pulp_href,
)
distribution = gen_object_with_cleanup(file_bindings.DistributionsFileApi, data)
distribution_base_url = distribution_base_url(distribution.base_url)

async def do_test():
async with aiohttp.ClientSession() as session:
url = urljoin(distribution_base_url, a_file.relative_path)
async with session.get(url) as response:
hdrs = response.headers
assert hdrs.get("Content-Disposition") and hdrs.get(
"Content-Disposition"
).startswith("attachment;filename=")
assert hdrs.get("Content-Security-Policy") and "sandbox" in hdrs.get(
"Content-Security-Policy"
)
assert hdrs.get("X-Content-Type-Options") and "nosniff" in hdrs.get(
"X-Content-Type-Options"
)

asyncio.run(do_test())
16 changes: 11 additions & 5 deletions pulpcore/content/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,7 @@ async def _match_and_stream(self, path, request):
)
ca = ContentArtifact(relative_path=original_rel_path)
ra = RemoteArtifact(remote=remote, url=url, content_artifact=ca)
self._set_secure_headers(os.path.basename(ca.relative_path), headers)
try:
return await self._stream_remote_artifact(
request,
Expand Down Expand Up @@ -1107,6 +1108,12 @@ def _save_artifact(self, download_result, remote_artifact, request=None):
ret.update({ca.relative_path: ca for ca in cas})
return ret

def _set_secure_headers(self, filename, headers):
content_disposition = f"attachment;filename={filename}"
headers["Content-Disposition"] = content_disposition
headers["Content-Security-Policy"] = "default-src 'none'; sandbox"
headers["X-Content-Type-Options"] = "nosniff"

def _build_response_from_content_artifact(self, content_artifact, headers, request):
"""Helper method to build the correct response to serve a ContentArtifact."""

Expand All @@ -1120,10 +1127,6 @@ def _set_params_from_headers(hdrs, storage_domain):
return params

def _build_url(**kwargs):
filename = os.path.basename(content_artifact.relative_path)
content_disposition = f"attachment;filename={filename}"

headers["Content-Disposition"] = content_disposition
parameters = _set_params_from_headers(headers, domain.storage_class)
storage_url = storage.url(artifact_name, parameters=parameters, **kwargs)

Expand Down Expand Up @@ -1178,7 +1181,7 @@ async def _serve_content_artifact(self, content_artifact, headers, request):
"""
artifact_file = content_artifact.artifact.file
content_length = artifact_file.size

self._set_secure_headers(os.path.basename(content_artifact.relative_path), headers)
try:
range_start, range_stop = request.http_range.start, request.http_range.stop
if range_start or range_stop:
Expand Down Expand Up @@ -1241,6 +1244,9 @@ async def _stream_remote_artifact(
size = remote_artifact.size or "*"
raise HTTPRequestRangeNotSatisfiable(headers={"Content-Range": f"bytes */{size}"})

self._set_secure_headers(
os.path.basename(remote_artifact.content_artifact.relative_path), response.headers
)
original_headers = response.headers.copy()
actual_content_length = None

Expand Down
Loading