fix(api): stop stored SVG/XML files from executing as documents - #39834
Open
kah-ja wants to merge 1 commit into
Open
fix(api): stop stored SVG/XML files from executing as documents#39834kah-ja wants to merge 1 commit into
kah-ja wants to merge 1 commit into
Conversation
Every endpoint that streams a stored file back to the browser now runs through one helper: - `X-Content-Type-Options: nosniff` is always sent. - HTML keeps the existing behaviour (attachment + application/octet-stream). - SVG/XML keep their Content-Type, so `<img src="...">` still renders them, but are marked `Content-Disposition: attachment` and get an inert `Content-Security-Policy`, so a top-level navigation can no longer run script they carry. The deprecated `GET /files/<id>/image-preview` and the public `GET /files/workspaces/<id>/webapp-logo` returned the stored bytes with the uploader's own Content-Type and no download-forcing headers at all, so an `image/svg+xml` file uploaded by a member with dataset-edit rights executed in the app origin as soon as another user opened it — chunk attachments are still signed against `image-preview` (models/dataset.py, dataset_retrieval, retrieval_service), so the URL is handed out by the API. The provider/plugin icon endpoints served plugin-controlled bytes the same way. `enforce_download_for_html` is unchanged and still covers HTML only; the new `harden_served_file` composes it, so existing callers keep their behaviour and additionally gain the SVG/XML and nosniff handling. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kah-ja
force-pushed
the
fix/inert-svg-file-responses
branch
from
July 30, 2026 19:04
39bb358 to
d5ac684
Compare
Contributor
Pyrefly Type Coverage
|
Contributor
Pyrefly Diffbase → PR--- /tmp/pyrefly_base.txt 2026-07-30 20:29:37.102718170 +0000
+++ /tmp/pyrefly_pr.txt 2026-07-30 20:29:20.463587533 +0000
@@ -2121,17 +2121,19 @@
ERROR Argument `list[FromClause]` is not assignable to parameter `tables` with type `Sequence[Table] | None` in function `sqlalchemy.sql.schema.MetaData.create_all` [bad-argument-type]
--> tests/unit_tests/controllers/console/workspace/test_workspace.py:45:54
ERROR `SimpleNamespace` is not assignable to attribute `db` with type `SQLAlchemy` [bad-assignment]
- --> tests/unit_tests/controllers/files/test_image_preview.py:18:17
+ --> tests/unit_tests/controllers/files/test_image_preview.py:19:17
ERROR `SimpleNamespace` is not assignable to attribute `request` with type `Request` [bad-assignment]
- --> tests/unit_tests/controllers/files/test_image_preview.py:37:26
+ --> tests/unit_tests/controllers/files/test_image_preview.py:38:26
ERROR `SimpleNamespace` is not assignable to attribute `request` with type `Request` [bad-assignment]
- --> tests/unit_tests/controllers/files/test_image_preview.py:60:26
+ --> tests/unit_tests/controllers/files/test_image_preview.py:63:26
ERROR `SimpleNamespace` is not assignable to attribute `request` with type `Request` [bad-assignment]
- --> tests/unit_tests/controllers/files/test_image_preview.py:83:26
+ --> tests/unit_tests/controllers/files/test_image_preview.py:81:26
ERROR `SimpleNamespace` is not assignable to attribute `request` with type `Request` [bad-assignment]
- --> tests/unit_tests/controllers/files/test_image_preview.py:113:26
+ --> tests/unit_tests/controllers/files/test_image_preview.py:104:26
ERROR `SimpleNamespace` is not assignable to attribute `request` with type `Request` [bad-assignment]
- --> tests/unit_tests/controllers/files/test_image_preview.py:145:26
+ --> tests/unit_tests/controllers/files/test_image_preview.py:134:26
+ERROR `SimpleNamespace` is not assignable to attribute `request` with type `Request` [bad-assignment]
+ --> tests/unit_tests/controllers/files/test_image_preview.py:166:26
ERROR No attribute `global_db` in module `controllers.files.tool_files` [missing-attribute]
--> tests/unit_tests/controllers/files/test_tool_files.py:25:5
ERROR `SimpleNamespace` is not assignable to attribute `request` with type `Request` [bad-assignment]
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Several endpoints stream a stored file back to the browser. Two of them — the deprecated
GET /files/<file_id>/image-previewand the publicGET /files/workspaces/<workspace_id>/webapp-logo— return the stored bytes with the uploader's ownContent-Typeand no download-forcing headers at all (api/controllers/files/image_preview.py:71,181):svgis an accepted image extension (api/constants/__init__.py:10), uploads are not sanitised, andUPLOAD_FILE_EXTENSION_BLACKLISTis empty by default, so those bytes can be an SVG document that carries<script>. Chunk attachments and RAG retrieval results are still signed againstimage-preview(api/models/dataset.py:1106,1250,api/core/rag/retrieval/dataset_retrieval.py:546,api/core/rag/datasource/retrieval_service.py:935,963,api/core/tools/signature.py:40), so that URL is what the API hands to whoever views the chunk — and opening it renders the file as a document in the app origin, wherecsrf_tokenis readable fromdocument.cookieand the session cookies are same-site. A member with dataset-edit rights (editor/dataset_operator) can therefore get script running as any user who views the chunk.enforce_download_for_htmlalready exists for exactly this problem, but its type set is HTML-only (api/controllers/common/file_response.py:7-8), so SVG and XML fall through — andimage-preview/webapp-logonever call it. The provider and plugin icon endpoints (api/controllers/console/workspace/{model_providers,tool_providers,plugin}.py) serve plugin-controlled bytes the same way.file-previewis not affected: it already forcesapplication/octet-stream.Fix
One helper, used by every endpoint that streams a stored file:
X-Content-Type-Options: nosniffis always sent.attachment+application/octet-stream.Content-Type, so<img src="...">thumbnails, provider icons and webapp logos render exactly as before, but they are markedContent-Disposition: attachmentand get an inertContent-Security-Policy(default-src 'none'; style-src 'unsafe-inline'; sandbox). Both headers are ignored for<img>subresource loads, and each one independently prevents a top-level navigation from executing the file.enforce_download_for_htmlis left untouched and still covers HTML only; the newharden_served_filecomposes it, so the four existing call sites keep their behaviour and additionally gain SVG/XML andnosniffcoverage.If you would rather not send
Content-Disposition: attachmentfor SVG, dropping that single line still closes the hole via the CSP alone.This was reported privately as GHSA-6w5x-w2p4-7837 (2026-05-13, draft still open), which is why there is no public issue to link here. Happy to move the discussion there or to split the icon endpoints into a separate PR if you prefer.
Screenshots
Measured on 1.16.1 with the stock
docker/.env.exampleand a real Chromium, serving an uploaded SVG throughimage-preview:Content-Typeimage/svg+xml; charset=utf-8image/svg+xml; charset=utf-8Content-DispositionattachmentX-Content-Type-OptionsnosniffContent-Security-Policydefault-src 'none'; style-src 'unsafe-inline'; sandbox<img src="...">load, unchanged)image-preview.svg, nothing runsChecklist
make lint && make type-check(backend) andcd web && pnpm exec vp staged(frontend) to appease the lint godsBackend gates, run with the versions pinned in
api/uv.lock:ruff format --check ./apiruff check ./apiapi-contract-lintapi/dev/lint_response_contracts.pyrefactorablehits are pre-existing and in files this PR does not touch. Worth checking here because this PR rewrites threereturn send_file(...)statements that carry# response-contract:ignorecomments.lint-importsdotenv-lintermypy --check-untyped-defs --disable-error-code=import-untyped976->979pyrefly checkpyteston the two touched test modulesCaveat on how the type checks were run: I have no local Python toolchain, so
mypyandpyreflyran inside alanggenius/dify-api:1.16.1container and were scoped to the eight changed source files rather than the whole tree (thetype-checktarget excludestests/anyway), with the A/B baseline described above.ruff, the contract lint andlint-importsran over the wholeapi/tree. There is no frontend change, sopnpm exec vp stagedhas nothing staged to look at.From Claude Code