Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions app/assets/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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 <img> 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)
Expand Down Expand Up @@ -356,6 +370,7 @@ async def stream_file_chunks():
"Content-Disposition": cd,
"Content-Length": str(file_size),
"X-Content-Type-Options": "nosniff",
**extra_headers,
},
)

Expand Down
15 changes: 12 additions & 3 deletions app/user_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,13 +343,22 @@ async def getuserdata(request):
# XSS). Content-Disposition: attachment is the load-bearing guard;
# the content-type override and nosniff are defence in depth.
content_type = mimetypes.guess_type(path)[0] or 'application/octet-stream'
if folder_paths.is_dangerous_content_type(content_type):
content_type = 'application/octet-stream'

user_root = self.get_request_user_filepath(request, None, create_dir=False)
is_user_css = path == os.path.abspath(os.path.join(user_root, "user.css"))

if is_user_css:
content_type = "text/css"
disposition = "inline"
else:
if folder_paths.is_dangerous_content_type(content_type):
content_type = 'application/octet-stream'
disposition = "attachment"

return web.FileResponse(path, headers={
"Content-Type": content_type,
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "attachment",
"Content-Disposition": disposition,
})

@routes.post("/userdata/{file}")
Expand Down
35 changes: 29 additions & 6 deletions comfy_execution/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,19 @@ def is_previewable(media_type: str, item: dict) -> bool:
return False


def is_text_preview(media_type: str, item: dict) -> bool:
"""
Check if a previewable output item is textual rather than visual media.

Saved text files (SaveText's .txt/.md/.json) are real outputs but must not
outrank visual media when picking the job preview.
"""
if media_type == 'text':
return True
filename = item.get('filename', '').lower()
return any(filename.endswith(ext) for ext in TEXT_EXTENSIONS)


def normalize_queue_item(item: tuple, status: str) -> dict:
"""Convert queue item tuple to unified job dict.

Expand Down Expand Up @@ -259,8 +272,13 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
Returns (outputs_count, preview_output).

Preview priority (matching frontend):
1. type="output" with previewable media
2. Any previewable media
1. type="output" visual media (saved images/video/audio/3d)
2. any other previewable visual media (e.g. temp/preview images)
3. saved text file (e.g. SaveText's .txt/.md/.json)
4. raw text (only when the job produced nothing else previewable)

Text is kept in its own slots so node/execution order can't let a text
output mask a visual one (e.g. a text node that runs before an image).

Text content entries (strings under 'text') are preview-only metadata,
matching the frontend's METADATA_KEYS: they can serve as the fallback
Expand All @@ -269,6 +287,8 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
count = 0
preview_output = None
fallback_preview = None
text_file_fallback = None
text_fallback = None

for node_id, node_outputs in outputs.items():
if not isinstance(node_outputs, dict):
Expand Down Expand Up @@ -296,8 +316,8 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
'nodeId': node_id,
'mediaType': media_type
}
if fallback_preview is None:
fallback_preview = enriched
if text_fallback is None:
text_fallback = enriched
continue
# normalize_output_item returned a dict (e.g. 3D file)
item = normalized
Expand All @@ -314,12 +334,15 @@ def get_outputs_summary(outputs: dict) -> tuple[int, Optional[dict]]:
}
if 'mediaType' not in item:
enriched['mediaType'] = media_type
if item.get('type') == 'output':
if is_text_preview(media_type, item):
if text_file_fallback is None:
text_file_fallback = enriched
elif item.get('type') == 'output':
preview_output = enriched
elif fallback_preview is None:
fallback_preview = enriched

return count, preview_output or fallback_preview
return count, preview_output or fallback_preview or text_file_fallback or text_fallback


def apply_sorting(jobs: list[dict], sort_by: str, sort_order: str) -> list[dict]:
Expand Down
17 changes: 17 additions & 0 deletions folder_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<img>`` 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`.

Expand Down
36 changes: 23 additions & 13 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,8 +624,9 @@ async def view_image(request):
# 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.
Expand All @@ -635,18 +636,27 @@ async def view_image(request):
# 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}\""

return web.FileResponse(
file,
headers={
"Content-Disposition": disposition,
"Content-Type": content_type,
"X-Content-Type-Options": "nosniff"
}
)
# 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}\""

headers["Content-Disposition"] = disposition
headers["Content-Type"] = content_type
return web.FileResponse(file, headers=headers)

return web.Response(status=404)

Expand Down
Loading
Loading