mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-07-03 21:20:49 +08:00
Some checks are pending
Detect Unreviewed Merge / detect (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
* security: fix five vulnerabilities (GHSA-779p-m5rp-r4h4)
- 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.
* security: address review feedback on GHSA-779p fixes
- Fix Windows CI failure in test_get_annotated_filepath: compare against
os.path.abspath(...) to match the intentional abspath normalization added
by the traversal hardening (abspath prepends the drive letter on Windows).
- origin_check: narrow the bare `except:` in is_loopback() to ValueError so
genuine interrupts aren't swallowed (review nit).
- origin_check: guard .port access in is_cross_origin_forbidden() so a
malformed/out-of-range port (e.g. Origin: http://127.0.0.1:99999) fails
closed with a 403 instead of surfacing an uncaught 500 in the middleware.
- server /view: escape backslash/quote in the Content-Disposition filename
(RFC 6266 quoted-string) so a filename containing a double quote can't
malform the response header.
* security: address CodeRabbit review feedback on GHSA-779p tests
- test #3: guard the symlink-escape test with a try/except skip so it no
longer errors on Windows CI where os.symlink needs elevated privileges /
Developer Mode (mirrors the guard in the sibling test #2).
- test #5: refresh the stale module docstring to describe the actual /view
gating (view_image closure calling folder_paths.is_dangerous_content_type,
the normalising check) instead of the bypassable raw set-membership test.
* revert(security): drop CVE-2026-56674 Origin: null CSRF change
Per maintainer review, the reported CSRF is already mitigated by the pre-existing
Sec-Fetch-Site: cross-site check for current browsers, and the null-origin
rejection risked breaking legitimate sandboxed-iframe embeds. Restores
origin_only_middleware and is_loopback in server.py to their prior state
(the Sec-Fetch-Site check is retained) and removes utils/origin_check.py and its
regression test. The other four GHSA-779p fixes are unaffected.
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
|