diff --git a/CHANGES/+cve-2026-84232.bugfix b/CHANGES/+cve-2026-84232.bugfix new file mode 100644 index 00000000000..dc1fb1b50ca --- /dev/null +++ b/CHANGES/+cve-2026-84232.bugfix @@ -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. diff --git a/pulp_file/tests/functional/api/test_responses.py b/pulp_file/tests/functional/api/test_responses.py new file mode 100644 index 00000000000..9f38a235eaf --- /dev/null +++ b/pulp_file/tests/functional/api/test_responses.py @@ -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()) diff --git a/pulpcore/content/handler.py b/pulpcore/content/handler.py index 9341ca0b25f..7654f1e552a 100644 --- a/pulpcore/content/handler.py +++ b/pulpcore/content/handler.py @@ -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, @@ -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.""" @@ -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) @@ -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: @@ -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