Fix SVG previews broken by the stored-XSS forced-download (#15149)

* Fix SVG previews broken by the stored-XSS forced-download

/view and the assets download route force every SVG to
application/octet-stream + attachment. That blocks the stored XSS from
GHSA-779p-m5rp-r4h4, but it also breaks the SVG node output and Media
Assets previews, which request the file with a plain <img>.

Exempt only that case. An SVG referenced by an <img> loads in secure
static mode with scripting and external references disabled, so the
payload cannot fire. The attack needs the SVG to become a document,
which arrives with a different Sec-Fetch-Dest. Browsers set that header
themselves and page script cannot override it. A missing header, from a
non-browser client or a proxy that strips it, fails closed.

The blocklist itself is unchanged; this is a call-site gate.

* Don't let a cache replay the inline SVG into document context

The Sec-Fetch-Dest exemption makes /view and the assets content route vary
their Content-Type and Content-Disposition on a request header, but neither
response said so. FileResponse emits Last-Modified/ETag and the cache_control
middleware skips /view (the filename is in the query string, not the path), so
the inline image/svg+xml variant is heuristically cacheable. A cache keyed on
the URL alone could hand an entry primed by an <img> load to a later top-level
navigation of the same URL, turning the SVG back into a document and
re-enabling the stored XSS the forced download blocks.

Set Vary: Sec-Fetch-Dest and Cache-Control: no-store on both branches, not just
the exempt one: a cached attachment replayed to an <img> would re-break the
preview this fix exists to restore.

Also strip parameters from content_type before building the assets response.
mime_type there is uploader-supplied and unvalidated, and aiohttp rejects a
charset in the content_type argument with ValueError, so a stored
"image/svg+xml; charset=utf-8" turned a valid inline SVG into a 500.

Route-level guards now pin the headers on both branches and the parameterised
mime type; all three fail against the previous commit.
This commit is contained in:
Matt Miller
2026-07-29 23:29:36 -07:00
committed by Jedrzej Kosinski
parent 12fc94b336
commit fa2124c52e
4 changed files with 252 additions and 19 deletions

View File

@@ -624,8 +624,9 @@ class PromptServer():
# For security, force renderable/active types (HTML, JS,
# CSS, SVG, XML — anything that can carry inline <script>
# and execute in the page origin) to download instead of
# displaying inline, preventing stored XSS. The
# attachment disposition is the load-bearing guard: a
# displaying inline, preventing stored XSS. SVG loaded
# into an <img> is exempt, see renders_safely_as_image.
# The attachment disposition is the load-bearing guard: a
# bare filename= hint does not force a download per
# RFC 6266, so we only attach it on the dangerous branch
# to avoid breaking inline display of legitimate images.
@@ -635,18 +636,27 @@ class PromptServer():
# header's quoted-string and malform the disposition.
safe_filename = filename.replace("\\", "\\\\").replace('"', '\\"')
disposition = f"filename=\"{safe_filename}\""
headers = {"X-Content-Type-Options": "nosniff"}
sec_fetch_dest = request.headers.get('Sec-Fetch-Dest')
if folder_paths.is_dangerous_content_type(content_type):
content_type = 'application/octet-stream'
disposition = f"attachment; filename=\"{safe_filename}\""
# This response now depends on a request header, so
# it must not be reused across destinations.
# FileResponse emits Last-Modified/ETag and nothing
# sets Cache-Control on /view, which makes it
# heuristically cacheable: without these headers a
# cache could replay the inline SVG served to an
# <img> to a later document navigation of the same
# URL and re-enable the stored XSS, or replay the
# attachment to an <img> and re-break the preview.
headers["Vary"] = "Sec-Fetch-Dest"
headers["Cache-Control"] = "no-store"
if not folder_paths.renders_safely_as_image(content_type, sec_fetch_dest):
content_type = 'application/octet-stream'
disposition = f"attachment; filename=\"{safe_filename}\""
return web.FileResponse(
file,
headers={
"Content-Disposition": disposition,
"Content-Type": content_type,
"X-Content-Type-Options": "nosniff"
}
)
headers["Content-Disposition"] = disposition
headers["Content-Type"] = content_type
return web.FileResponse(file, headers=headers)
return web.Response(status=404)