diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py
index 43e60094c..e25b8a57f 100644
--- a/app/assets/api/routes.py
+++ b/app/assets/api/routes.py
@@ -315,15 +315,29 @@ async def download_asset_content(request: web.Request) -> web.Response:
404, "FILE_NOT_FOUND", "Underlying file not found on disk."
)
- # User-controlled asset content must never render inline in the app origin
+ # User-controlled asset content must not render inline in the app origin
# (stored XSS via SVG/HTML/XML). Force dangerous types to download and
- # override any requested inline disposition. Centralised through
- # folder_paths.is_dangerous_content_type so this can't drift from /view and
- # /userdata (the previous inline set here omitted image/svg+xml and missed
- # the charset/casing/+xml-dialect bypasses).
+ # override any requested inline disposition; SVG loaded into an
is
+ # exempt, see renders_safely_as_image. Centralised through folder_paths so
+ # this can't drift from /view and /userdata (the previous inline set here
+ # omitted image/svg+xml and missed the charset/casing/+xml-dialect bypasses).
+ extra_headers = {}
+ sec_fetch_dest = request.headers.get("Sec-Fetch-Dest")
if folder_paths.is_dangerous_content_type(content_type):
- content_type = "application/octet-stream"
- disposition = "attachment"
+ # This response now depends on a request header, so it must not be
+ # reused across destinations by a browser or intermediary cache: an
+ # inline SVG primed by an
fetch and replayed to a document
+ # navigation of the same URL would re-enable the stored XSS.
+ extra_headers["Vary"] = "Sec-Fetch-Dest"
+ extra_headers["Cache-Control"] = "no-store"
+ if not folder_paths.renders_safely_as_image(content_type, sec_fetch_dest):
+ content_type = "application/octet-stream"
+ disposition = "attachment"
+
+ # mime_type is uploader-supplied and unvalidated, so it can carry
+ # parameters. aiohttp rejects a charset in the content_type argument with
+ # ValueError, which would turn a valid inline SVG into a 500.
+ content_type = content_type.split(";", 1)[0].strip() or "application/octet-stream"
safe_name = (filename or "").replace("\r", "").replace("\n", "")
encoded = urllib.parse.quote(safe_name)
@@ -356,6 +370,7 @@ async def download_asset_content(request: web.Request) -> web.Response:
"Content-Disposition": cd,
"Content-Length": str(file_size),
"X-Content-Type-Options": "nosniff",
+ **extra_headers,
},
)
diff --git a/folder_paths.py b/folder_paths.py
index bd3f25095..df53542dc 100644
--- a/folder_paths.py
+++ b/folder_paths.py
@@ -306,6 +306,23 @@ def is_dangerous_content_type(content_type: str | None) -> bool:
return normalized.endswith('+xml') or normalized.endswith('/xml')
+def renders_safely_as_image(content_type: str | None, sec_fetch_dest: str | None) -> bool:
+ """Return True if a dangerous `content_type` is safe to serve inline anyway.
+
+ An SVG referenced by an ``
`` is loaded in secure static mode: scripts
+ and external references are disabled, so the stored XSS that
+ ``is_dangerous_content_type`` guards against cannot fire. The attack needs
+ the SVG to become a document, which is a separate ``Sec-Fetch-Dest``.
+ Browsers set that header themselves and script cannot override it (the
+ ``Sec-`` prefix makes it a forbidden header name), so it is trustworthy for
+ this decision. Anything else, including a missing header from a non-browser
+ client or a proxy that strips it, fails closed.
+ """
+ if sec_fetch_dest != 'image':
+ return False
+ return (content_type or '').split(';', 1)[0].strip().lower() == 'image/svg+xml'
+
+
def is_within_directory(directory: str, target: str) -> bool:
"""Return True if `target` resolves to a path inside `directory`.
diff --git a/server.py b/server.py
index e28fe2d22..c9ffcaa0d 100644
--- a/server.py
+++ b/server.py
@@ -624,8 +624,9 @@ class PromptServer():
# For security, force renderable/active types (HTML, JS,
# CSS, SVG, XML — anything that can carry inline '
+ASSET_ID = "00000000-0000-4000-8000-000000000001"
+CONTENT_URL = f"/api/assets/{ASSET_ID}/content"
+
+
+class _StubUserManager:
+ def get_request_user_id(self, request):
+ return "test-user"
+
+
+@pytest.fixture
+def asset_app(monkeypatch, tmp_path):
+ """Mount the real /api/assets/{id}/content route over a stored SVG."""
+
+ def _factory(stored_mime_type):
+ svg = tmp_path / "thumb.svg"
+ svg.write_bytes(SVG_PAYLOAD)
+
+ monkeypatch.setattr(asset_routes, "_ASSETS_ENABLED", True)
+ monkeypatch.setattr(asset_routes, "USER_MANAGER", _StubUserManager())
+ monkeypatch.setattr(
+ asset_routes,
+ "resolve_asset_for_download",
+ lambda reference_id, owner_id: DownloadResolutionResult(
+ abs_path=str(svg),
+ content_type=stored_mime_type,
+ download_name="thumb.svg",
+ ),
+ )
+
+ app = web.Application()
+ app.add_routes(asset_routes.ROUTES)
+ return app
+
+ return _factory
+
+
+@pytest.mark.asyncio
+async def test_inline_svg_response_is_not_cacheable_across_destinations(
+ aiohttp_client, asset_app
+):
+ client = await aiohttp_client(asset_app("image/svg+xml"))
+ resp = await client.get(
+ CONTENT_URL, params={"disposition": "inline"}, headers={"Sec-Fetch-Dest": "image"}
+ )
+
+ assert resp.status == 200
+ assert "image/svg+xml" in resp.headers.get("Content-Type", "").lower()
+ # The load-bearing assertion: a cache must not be able to hand this inline
+ # SVG to a later document navigation of the same URL.
+ assert "sec-fetch-dest" in resp.headers.get("Vary", "").lower(), (
+ "The response varies on Sec-Fetch-Dest but does not say so, so a cache "
+ "keyed on the URL alone can replay the inline SVG into document context."
+ )
+ assert "no-store" in resp.headers.get("Cache-Control", "").lower()
+
+
+@pytest.mark.asyncio
+async def test_forced_download_response_also_declares_the_variance(
+ aiohttp_client, asset_app
+):
+ # The attachment branch needs the same headers, in both directions: a
+ # cached octet-stream replayed to an
re-breaks the preview this fix
+ # exists to restore.
+ client = await aiohttp_client(asset_app("image/svg+xml"))
+ resp = await client.get(
+ CONTENT_URL,
+ params={"disposition": "inline"},
+ headers={"Sec-Fetch-Dest": "document"},
+ )
+
+ assert resp.status == 200
+ assert "application/octet-stream" in resp.headers.get("Content-Type", "").lower()
+ assert "attachment" in resp.headers.get("Content-Disposition", "").lower()
+ assert "sec-fetch-dest" in resp.headers.get("Vary", "").lower()
+ assert "no-store" in resp.headers.get("Cache-Control", "").lower()
+
+
+@pytest.mark.asyncio
+async def test_parameterised_svg_mime_type_does_not_500(aiohttp_client, asset_app):
+ # mime_type is uploader-supplied and unvalidated. aiohttp rejects a charset
+ # in the content_type argument with ValueError, so the exempt branch must
+ # strip parameters before building the response.
+ client = await aiohttp_client(asset_app("image/svg+xml; charset=utf-8"))
+ resp = await client.get(
+ CONTENT_URL, params={"disposition": "inline"}, headers={"Sec-Fetch-Dest": "image"}
+ )
+
+ assert resp.status == 200, (
+ "A charset parameter on the stored mime type must not turn a valid "
+ "inline SVG request into a 500."
+ )
+ assert "image/svg+xml" in resp.headers.get("Content-Type", "").lower()