Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGES/+cve-2026-84232.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Insures that `Content-Disposition`, `Content-Security-Policy`, and `X-Content-Type-Options`
are set consistently in the content-app.

This addresses CVE-2026-84232.
Comment on lines +1 to +4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this all on one line. Multi-line changelogs break our rendering, e.g. https://pulpproject.org/pulpcore/changes/#3.115.0

12 changes: 11 additions & 1 deletion pulp_file/tests/functional/api/test_mime_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,17 @@ async def fetch_mimetypes():
async def get_content_type(extension, content_unit):
url = urljoin(distribution_base_url, content_unit.relative_path)
async with session.get(url) as response:
return extension, response.headers.get("Content-Type")
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"
)
return extension, hdrs.get("Content-Type")

pairs = await asyncio.gather(
*(get_content_type(ext, unit) for ext, unit in files.items())
Expand Down
10 changes: 6 additions & 4 deletions pulpcore/content/handler.py

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have the same issue still on stream_remote_artiifact? This fix is only for content that is immediately synced/uploaded.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, let me dig - the initial report was only for uploaded-content.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@gerrod3 My goal with this would be to "guarantee" this set of headers as early in the response-process as possible, and preferably Just Once (ie before we make the stream-vs-return decision). We need either the Response, or the/a list of headers we're planning to add to a Response, and Content-Disposition needs/wants the "filename". Do you have any thoughts on the best place to do this setup?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know. _match_and_stream typically calls two methods to generate the response _serve_content_artifact(ca, headers, request) or _stream_content_artifact(request, StreamResponse(headers=headers), ca). In both cases we get the original headers dict from the handler's response_headers(original_rel_path, distro), but when we call this method we don't know the final ContentArtifact that is going to be used (if we are serving a CA at all) for the request. Both of the serving methods update the headers dict with extra things they set before returning the final response, so maybe the best we can do is create a small helper and have both methods call it to update the dict.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hugely helpful, thank you

Original file line number Diff line number Diff line change
Expand Up @@ -1103,10 +1103,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 All @@ -1118,6 +1114,12 @@ def _build_url(**kwargs):
storage = domain.get_storage()
headers["X-PULP-ARTIFACT-SIZE"] = str(artifact_file.size)

filename = os.path.basename(content_artifact.relative_path)
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"

if domain.storage_class == "pulpcore.app.models.storage.FileSystem":
path = storage.path(artifact_name)
if not os.path.exists(path):
Expand Down
Loading