Serve _upload files with content-disposition: attachment#6303
Conversation
masenf
commented
Apr 8, 2026
- All files served from the /_upload endpoint will now trigger download
- PDF files are exempt, but they will always be served with the content-type set
- If an application needs some other behavior, they are encouraged to mount their own StaticFiles instance to handle it.
* All files served from the /_upload endpoint will now trigger download * PDF files are exempt, but they will always be served with the content-type set * If an application needs some other behavior, they are encouraged to mount their own StaticFiles instance to handle it.
Greptile SummaryThis PR hardens the Key changes:
Issues found:
Confidence Score: 3/5Not safe to merge — a Two concrete issues block this PR: (1) a
|
| Filename | Overview |
|---|---|
| packages/reflex-components-core/src/reflex_components_core/core/_upload.py | Adds UploadedFilesHeadersMiddleware that injects Content-Disposition: attachment and X-Content-Type-Options: nosniff on all upload responses, with a PDF carve-out — but the PDF branch appends a duplicate content-type header instead of replacing the existing one. |
| reflex/app.py | Wraps the StaticFiles mount for /_upload with the new UploadedFilesHeadersMiddleware — minimal, correct one-liner change. |
| tests/integration/test_media.py | Adds a test for the uploaded-image flow but contains an accidental breakpoint() on line 199 that will hang CI, and a computed-var with file I/O side effects. |
| tests/integration/test_upload.py | Adds test_uploaded_file_security_headers that uploads a malicious HTML file and verifies Content-Disposition: attachment / X-Content-Type-Options: nosniff headers; logic is sound. |
Sequence Diagram
sequenceDiagram
participant Browser
participant UploadedFilesHeadersMiddleware
participant StaticFiles
Browser->>UploadedFilesHeadersMiddleware: GET /_upload/file.html
UploadedFilesHeadersMiddleware->>StaticFiles: forward (scope, receive, send_with_headers)
StaticFiles-->>UploadedFilesHeadersMiddleware: http.response.start (content-type: text/html)
Note over UploadedFilesHeadersMiddleware: Appends X-Content-Type-Options: nosniff<br/>Appends Content-Disposition: attachment
UploadedFilesHeadersMiddleware-->>Browser: http.response.start (modified headers)
StaticFiles-->>UploadedFilesHeadersMiddleware: http.response.body (file bytes)
UploadedFilesHeadersMiddleware-->>Browser: http.response.body (unchanged)
Browser->>UploadedFilesHeadersMiddleware: GET /_upload/doc.pdf
UploadedFilesHeadersMiddleware->>StaticFiles: forward (scope, receive, send_with_headers)
StaticFiles-->>UploadedFilesHeadersMiddleware: http.response.start (content-type: application/pdf)
Note over UploadedFilesHeadersMiddleware: Appends X-Content-Type-Options: nosniff<br/>⚠️ Also appends content-type: application/pdf (duplicate!)
UploadedFilesHeadersMiddleware-->>Browser: http.response.start (two content-type headers)
StaticFiles-->>UploadedFilesHeadersMiddleware: http.response.body (file bytes)
UploadedFilesHeadersMiddleware-->>Browser: http.response.body (unchanged)
Reviews (1): Last reviewed commit: "Serve _upload files with content-disposi..." | Re-trigger Greptile
| headers.append((b"x-content-type-options", b"nosniff")) | ||
| if is_pdf: | ||
| headers.append((b"content-type", b"application/pdf")) | ||
| else: | ||
| headers.append((b"content-disposition", b"attachment")) |
There was a problem hiding this comment.
Duplicate
content-type header for PDF responses
Starlette's StaticFiles already sets a content-type header for .pdf files (it uses Python's mimetypes module, which maps .pdf → application/pdf). By unconditionally appending a second content-type: application/pdf, the response ends up with two content-type headers, which is technically malformed HTTP and can confuse strict proxies or clients.
The intent (force the browser to know this is a PDF so it renders inline) is sound, but the implementation should replace any existing content-type instead of appending to it:
async def send_with_headers(message: MutableMapping[str, Any]) -> None:
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
headers.append((b"x-content-type-options", b"nosniff"))
if is_pdf:
# Replace any existing content-type to ensure correct MIME type.
headers = [h for h in headers if h[0].lower() != b"content-type"]
headers.append((b"content-type", b"application/pdf"))
else:
headers.append((b"content-disposition", b"attachment"))
message = {**message, "headers": headers}
await send(message)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.