mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-05 22:21:31 +08:00
- CVE-2026-56670: force download of SVG/XML responses on /view to prevent stored XSS
- CVE-2026-56671: contain /experiment/models/preview reads within the model folder
- CVE-2026-56672: stop inline rendering of uploaded /userdata/{file} content
- CVE-2026-56673: prevent path traversal in get_annotated_filepath (LoadImage /prompt input)
- CVE-2026-56674: reject opaque/null Origin to close the CSRF middleware bypass
Adds regression tests under tests-unit/security_test/ covering all five.
193 lines
8.0 KiB
Python
193 lines
8.0 KiB
Python
"""CI unit tests for FIX #2 of GHSA-779p-m5rp-r4h4.
|
|
|
|
Path traversal / hardening in app/model_manager.py get_model_preview
|
|
(route /experiment/models/preview/{folder}/{path_index}/{filename:.*}).
|
|
|
|
Reference: https://github.com/Comfy-Org/ComfyUI/security/advisories/GHSA-779p-m5rp-r4h4
|
|
"""
|
|
import pytest
|
|
import yarl
|
|
from io import BytesIO
|
|
from PIL import Image
|
|
from aiohttp import web
|
|
from unittest.mock import patch
|
|
from app.model_manager import ModelFileManager
|
|
|
|
pytestmark = (
|
|
pytest.mark.asyncio
|
|
) # This applies the asyncio mark to all test functions in the module
|
|
|
|
@pytest.fixture
|
|
def model_manager():
|
|
return ModelFileManager()
|
|
|
|
@pytest.fixture
|
|
def app(model_manager):
|
|
app = web.Application()
|
|
routes = web.RouteTableDef()
|
|
model_manager.add_routes(routes)
|
|
app.add_routes(routes)
|
|
return app
|
|
|
|
|
|
async def test_legit_preview_returns_200(aiohttp_client, app, tmp_path):
|
|
"""Sanity: a real preview PNG inside the model folder is served as webp 200."""
|
|
img = Image.new('RGB', (16, 16), color=(255, 0, 128))
|
|
img.save(tmp_path / "test_model.png", format='PNG')
|
|
|
|
with patch('folder_paths.folder_names_and_paths', {
|
|
'test_folder': ([str(tmp_path)], None)
|
|
}):
|
|
client = await aiohttp_client(app)
|
|
response = await client.get('/experiment/models/preview/test_folder/0/test_model.png')
|
|
|
|
assert response.status == 200
|
|
assert response.content_type == 'image/webp'
|
|
|
|
img_bytes = BytesIO(await response.read())
|
|
served = Image.open(img_bytes)
|
|
assert served.format
|
|
assert served.format.lower() == 'webp'
|
|
served.close()
|
|
|
|
|
|
async def test_non_integer_path_index_returns_400(aiohttp_client, app, tmp_path):
|
|
"""A non-integer path_index segment must be rejected with 400."""
|
|
with patch('folder_paths.folder_names_and_paths', {
|
|
'test_folder': ([str(tmp_path)], None)
|
|
}):
|
|
client = await aiohttp_client(app)
|
|
response = await client.get('/experiment/models/preview/test_folder/abc/test_model.png')
|
|
|
|
assert response.status == 400
|
|
|
|
|
|
async def test_out_of_range_path_index_returns_404(aiohttp_client, app, tmp_path):
|
|
"""A path_index beyond the configured folder list must return 404."""
|
|
with patch('folder_paths.folder_names_and_paths', {
|
|
'test_folder': ([str(tmp_path)], None)
|
|
}):
|
|
client = await aiohttp_client(app)
|
|
response = await client.get('/experiment/models/preview/test_folder/99/test_model.png')
|
|
|
|
assert response.status == 404
|
|
|
|
|
|
async def test_empty_filename_returns_400(aiohttp_client, app, tmp_path):
|
|
"""The "{filename:.*}" capture also matches the empty string (trailing
|
|
slash). It would resolve to the folder itself and must be rejected with 400."""
|
|
with patch('folder_paths.folder_names_and_paths', {
|
|
'test_folder': ([str(tmp_path)], None)
|
|
}):
|
|
client = await aiohttp_client(app)
|
|
response = await client.get('/experiment/models/preview/test_folder/0/')
|
|
|
|
assert response.status == 400
|
|
|
|
|
|
async def test_path_traversal_in_filename_returns_403(aiohttp_client, app, tmp_path):
|
|
"""Path traversal in {filename} must be rejected with 403 and must NOT read
|
|
a file outside the configured model directory.
|
|
|
|
GOTCHA: aiohttp/yarl collapses literal ``../`` dot-segments out of the URL
|
|
path before it reaches the handler, which would make this test vacuously
|
|
pass (the request would hit a different/non-existent route). We percent-encode
|
|
the dots and slashes (``%2e%2e%2f``) and send the URL with
|
|
``yarl.URL(..., encoded=True)`` so the bytes survive client-side normalization
|
|
untouched; aiohttp's router then percent-decodes them into ``match_info``,
|
|
delivering the literal ``../`` traversal to the handler's ``{filename:.*}``
|
|
capture.
|
|
|
|
Without the fix the handler computes
|
|
``os.path.normpath(os.path.join(folder, "../../../../etc/hosts"))``, which
|
|
escapes ``tmp_path`` and would be passed straight to get_model_previews ->
|
|
Image.open, serving bytes from outside the model dir (200/served bytes). The
|
|
is_within_directory() containment check is the load-bearing fix that turns
|
|
that escape into a 403.
|
|
"""
|
|
# Sanity-anchor: a legit preview exists inside tmp_path, so a 200 path is
|
|
# genuinely reachable — proving the 403 below is the containment check
|
|
# firing, not an unrelated 404.
|
|
img = Image.new('RGB', (16, 16), color=(255, 0, 128))
|
|
img.save(tmp_path / "test_model.png", format='PNG')
|
|
|
|
# Percent-encoded "../../../../etc/hosts" so yarl does not collapse the
|
|
# dot-segments before the request leaves the client.
|
|
encoded_traversal = '%2e%2e%2f' * 4 + 'etc%2fhosts'
|
|
raw_path = '/experiment/models/preview/test_folder/0/' + encoded_traversal
|
|
url = yarl.URL(raw_path, encoded=True)
|
|
|
|
with patch('folder_paths.folder_names_and_paths', {
|
|
'test_folder': ([str(tmp_path)], None)
|
|
}):
|
|
client = await aiohttp_client(app)
|
|
response = await client.get(url)
|
|
|
|
# Confirm the traversal actually reached the handler intact: a 200 here
|
|
# would mean either normalization stripped the ``../`` (vacuous pass) or
|
|
# the containment check failed open and served outside-dir bytes.
|
|
assert response.status == 403, (
|
|
f"expected 403 from is_within_directory() containment check, "
|
|
f"got {response.status}; traversal may have been normalized away "
|
|
f"or the fix failed open"
|
|
)
|
|
body = await response.read()
|
|
assert body == b"", "403 response must not carry any file bytes"
|
|
|
|
|
|
async def test_symlink_companion_preview_returns_403(aiohttp_client, app, tmp_path):
|
|
"""A companion preview file is selected by a glob inside get_model_previews
|
|
and then opened. If that companion is a symlink whose path is in-dir but
|
|
whose target escapes the model folder, it must be rejected with 403 — not
|
|
served. The requested path itself stays in-dir (so the first containment
|
|
check passes); the load-bearing fix is the SECOND is_within_directory check
|
|
on the file actually opened.
|
|
"""
|
|
model_dir = tmp_path / "models"
|
|
model_dir.mkdir()
|
|
secret_dir = tmp_path / "secret"
|
|
secret_dir.mkdir()
|
|
# A real image OUTSIDE the model dir — valid, so without the fix Image.open
|
|
# would succeed and its bytes would be served (200).
|
|
secret = secret_dir / "secret.png"
|
|
Image.new('RGB', (8, 8), color=(0, 0, 0)).save(secret, format='PNG')
|
|
# Companion preview, in-dir by name but a symlink escaping the model dir.
|
|
# (No real model file is needed — get_model_previews globs companions by
|
|
# basename, and omitting a .safetensors avoids the metadata-header read.)
|
|
companion = model_dir / "model.preview.png"
|
|
try:
|
|
companion.symlink_to(secret)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("symlinks not supported on this platform/filesystem")
|
|
|
|
with patch('folder_paths.folder_names_and_paths', {
|
|
'test_folder': ([str(model_dir)], None)
|
|
}):
|
|
client = await aiohttp_client(app)
|
|
response = await client.get('/experiment/models/preview/test_folder/0/model.safetensors')
|
|
|
|
assert response.status == 403, (
|
|
f"expected 403 — the globbed companion preview is a symlink resolving "
|
|
f"outside the model dir and must not be served; got {response.status}"
|
|
)
|
|
assert await response.read() == b""
|
|
|
|
|
|
async def test_null_byte_in_filename_no_500(aiohttp_client, app, tmp_path):
|
|
"""A NUL byte in the filename must yield a clean client rejection, not a 500
|
|
from an uncaught ValueError in is_within_directory's realpath() call."""
|
|
raw_path = '/experiment/models/preview/test_folder/0/' + 'a%00b'
|
|
url = yarl.URL(raw_path, encoded=True)
|
|
|
|
with patch('folder_paths.folder_names_and_paths', {
|
|
'test_folder': ([str(tmp_path)], None)
|
|
}):
|
|
client = await aiohttp_client(app)
|
|
response = await client.get(url)
|
|
|
|
assert response.status != 500, (
|
|
f"NUL byte produced a 500 (uncaught ValueError); expected a clean "
|
|
f"4xx rejection, got {response.status}"
|
|
)
|
|
assert 400 <= response.status < 500
|