Skip to content

Commit 5f45343

Browse files
committed
Do not add or replace Content-Type header
If the Content-Type is missing or not application/pdf, then we serve the file with Content-Disposition: attachment. Iterate through the headers in a way that avoids adding duplicate headers.
1 parent 50cb894 commit 5f45343

File tree

2 files changed

+63
-12
lines changed

2 files changed

+63
-12
lines changed

packages/reflex-components-core/src/reflex_components_core/core/_upload.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,11 @@ async def _upload_chunk_file(
577577
return Response(status_code=202)
578578

579579

580+
header_content_disposition = b"content-disposition"
581+
header_content_type = b"content-type"
582+
header_x_content_type_options = b"x-content-type-options"
583+
584+
580585
class UploadedFilesHeadersMiddleware:
581586
"""ASGI middleware that adds security headers to uploaded file responses."""
582587

@@ -600,16 +605,28 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
600605
await self.app(scope, receive, send)
601606
return
602607

603-
is_pdf = scope.get("path", "").lower().endswith(".pdf")
604-
605608
async def send_with_headers(message: MutableMapping[str, Any]) -> None:
606609
if message["type"] == "http.response.start":
607-
headers = list(message.get("headers", []))
608-
headers.append((b"x-content-type-options", b"nosniff"))
609-
if is_pdf:
610-
headers.append((b"content-type", b"application/pdf"))
611-
else:
612-
headers.append((b"content-disposition", b"attachment"))
610+
content_disposition = None
611+
content_type = None
612+
headers = [(header_x_content_type_options, b"nosniff")]
613+
for header_name, header_value in message.get("headers", []):
614+
lower_name = header_name.lower()
615+
if lower_name == header_content_disposition:
616+
content_disposition = header_value.lower()
617+
# Always append content-disposition header if non-empty.
618+
continue
619+
if lower_name == header_x_content_type_options:
620+
# Always replace this value with "nosniff", so ignore existing value.
621+
continue
622+
if lower_name == header_content_type:
623+
content_type = header_value.lower()
624+
headers.append((header_name, header_value))
625+
if content_type != b"application/pdf":
626+
# Unknown content or non-PDF forces download.
627+
content_disposition = b"attachment"
628+
if content_disposition:
629+
headers.append((header_content_disposition, content_disposition))
613630
message = {**message, "headers": headers}
614631
await send(message)
615632

tests/integration/test_upload.py

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -749,17 +749,44 @@ def test_upload_download_file(
749749
assert downloaded_file.read_text() == exp_contents
750750

751751

752+
@pytest.mark.parametrize(
753+
("exp_name", "exp_contents", "expect_attachment", "expected_mime_type"),
754+
[
755+
(
756+
"malicious.html",
757+
"<html><body><script>alert('xss')</script></body></html>",
758+
True,
759+
"text/html; charset=utf-8",
760+
),
761+
("document.pdf", "%PDF-1.4 fake pdf contents", False, "application/pdf"),
762+
("readme.txt", "plain text contents", True, "text/plain; charset=utf-8"),
763+
],
764+
ids=["html", "pdf", "txt"],
765+
)
752766
def test_uploaded_file_security_headers(
753767
tmp_path,
754768
upload_file: AppHarness,
755769
driver: WebDriver,
770+
exp_name: str,
771+
exp_contents: str,
772+
expect_attachment: bool,
773+
expected_mime_type: str,
756774
):
757-
"""Upload an HTML file and verify security headers prevent inline rendering.
775+
"""Upload a file and verify security headers on the served response.
776+
777+
For non-PDF files, Content-Disposition: attachment must be set to force a
778+
download. For PDF files, Content-Disposition must NOT be set so the browser
779+
can render them inline, but Content-Type: application/pdf is always present.
780+
X-Content-Type-Options: nosniff is always required.
758781
759782
Args:
760783
tmp_path: pytest tmp_path fixture
761784
upload_file: harness for UploadFile app.
762785
driver: WebDriver instance.
786+
exp_name: filename to upload.
787+
exp_contents: file contents to upload.
788+
expect_attachment: whether the response should force a download.
789+
expected_mime_type: expected Content-Type mime type.
763790
"""
764791
import httpx
765792
from reflex_base.config import get_config
@@ -772,8 +799,6 @@ def test_uploaded_file_security_headers(
772799
upload_box = driver.find_elements(By.XPATH, "//input[@type='file']")[2]
773800
upload_button = driver.find_element(By.ID, "upload_button_tertiary")
774801

775-
exp_name = "malicious.html"
776-
exp_contents = "<html><body><script>alert('xss')</script></body></html>"
777802
target_file = tmp_path / exp_name
778803
target_file.write_text(exp_contents)
779804

@@ -788,8 +813,17 @@ def test_uploaded_file_security_headers(
788813
resp = httpx.get(upload_url)
789814
assert resp.status_code == 200
790815
assert resp.text == exp_contents
791-
assert resp.headers["content-disposition"] == "attachment"
792816
assert resp.headers["x-content-type-options"] == "nosniff"
817+
assert resp.headers["content-type"] == expected_mime_type
818+
819+
if expect_attachment:
820+
assert resp.headers["content-disposition"] == "attachment"
821+
else:
822+
assert "content-disposition" not in resp.headers
823+
824+
if not expect_attachment:
825+
# PDF: no browser download test needed, skip the rest.
826+
return
793827

794828
# Configure the download directory using CDP.
795829
download_dir = tmp_path / "downloads"

0 commit comments

Comments
 (0)