mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-05-09 16:42:32 +08:00
Defense-in-depth over GET→POST alone: reject the three CORS-safelisted simple-form Content-Types (x-www-form-urlencoded, multipart/form-data, text/plain) on 16 no-body POST handlers (glob + legacy) to block <form method=POST> CSRF that bypasses method-only gating. Move comfyui_switch_version to a JSON body so the preflight requirement applies. Split db_mode/policy/update/channel_url_list into GET(read) + POST(write). Tighten do_fix (high → high+) and gate three previously-ungated config setters at middle. Resynchronize openapi.yaml (27 paths, 30 operations, ComfyUISwitchVersionParams as a shared $ref component). Add E2E harness variants, Playwright config, CSRF/secgate suites, 39-endpoint coverage, and a CHANGELOG. Breaking: legacy per-op POST routes (install/uninstall/fix/disable/update/ reinstall/abort_current) are removed; callers already use queue/batch. Legacy /manager/notice (v1) is removed; /v2/manager/notice is retained. Reported-by: XlabAI Team of Tencent Xuanwu Lab CVSS: 8.1 (AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H)
243 lines
9.4 KiB
Python
243 lines
9.4 KiB
Python
"""E2E PoC for the strict-mode security gate (T2 SECGATE-PENDING harness).
|
|
|
|
SCOPE — important clarification:
|
|
This suite is the PoC for the security_level=strong harness needed to verify
|
|
the 403 contract on middle/middle+ gates. The default E2E config
|
|
(security_level=normal, is_local_mode=True) puts NORMAL inside the allowed
|
|
set for both middle and middle+ checks (see comfyui_manager/glob/utils/
|
|
security_utils.py:32-38), so the 403 path on these gates is unreachable
|
|
without elevating the security_level to STRONG.
|
|
|
|
The 4 T2 SECGATE-PENDING Goals at middle/middle+ that depend on this harness:
|
|
- SR4 (snapshot/remove, gate=middle) ← THIS PoC
|
|
- SR6 (snapshot/restore, gate=middle+) ← follow-up
|
|
- V5 (manager/reboot, gate=middle) ← follow-up
|
|
- UA2 (manager/queue/update_all, gate=middle+) ← follow-up
|
|
|
|
WI-KK established the harness pattern (start_comfyui_strict.sh + a fixture
|
|
that backs up + restores config.ini). Once this PoC lands, the remaining
|
|
3 strict-mode tests are mechanical additions to this file.
|
|
|
|
Why a separate file (not test_e2e_csrf*.py-style mixed):
|
|
The strict-mode server lifecycle is heavyweight (config patch + restart). It
|
|
must NOT contaminate normal-mode test suites where security_level=normal is
|
|
expected. By keeping strict tests in their own module with their own fixture,
|
|
we keep the cost contained and the contracts unambiguous.
|
|
|
|
Negative-check coverage (per verification_design.md §7.3 Security Boundary
|
|
Template):
|
|
- 403 status
|
|
- target snapshot file UNCHANGED on disk
|
|
- (log substring check is OPTIONAL and not asserted here — observable in
|
|
server logs but cumbersome to scrape from pytest)
|
|
|
|
Requires a pre-built E2E environment (from setup_e2e_env.sh).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
|
|
import pytest
|
|
import requests
|
|
|
|
E2E_ROOT = os.environ.get("E2E_ROOT", "")
|
|
COMFYUI_PATH = os.path.join(E2E_ROOT, "comfyui") if E2E_ROOT else ""
|
|
SCRIPTS_DIR = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "scripts"
|
|
)
|
|
MANAGER_CONFIG = (
|
|
os.path.join(COMFYUI_PATH, "user", "__manager", "config.ini")
|
|
if COMFYUI_PATH else ""
|
|
)
|
|
SNAPSHOT_DIR = (
|
|
os.path.join(COMFYUI_PATH, "user", "__manager", "snapshots")
|
|
if COMFYUI_PATH else ""
|
|
)
|
|
|
|
PORT = 8199
|
|
BASE_URL = f"http://127.0.0.1:{PORT}"
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not E2E_ROOT
|
|
or not os.path.isfile(os.path.join(E2E_ROOT, ".e2e_setup_complete")),
|
|
reason="E2E_ROOT not set or E2E environment not ready",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _start_comfyui_strict() -> int:
|
|
"""Launch ComfyUI with security_level=strong (start_comfyui_strict.sh)."""
|
|
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
|
|
r = subprocess.run(
|
|
["bash", os.path.join(SCRIPTS_DIR, "start_comfyui_strict.sh")],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180,
|
|
env=env,
|
|
)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"Failed to start ComfyUI (strict):\n{r.stderr}")
|
|
for part in r.stdout.strip().split():
|
|
if part.startswith("COMFYUI_PID="):
|
|
return int(part.split("=")[1])
|
|
raise RuntimeError(f"Could not parse PID:\n{r.stdout}")
|
|
|
|
|
|
def _stop_comfyui():
|
|
env = {**os.environ, "E2E_ROOT": E2E_ROOT, "PORT": str(PORT)}
|
|
subprocess.run(
|
|
["bash", os.path.join(SCRIPTS_DIR, "stop_comfyui.sh")],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
env=env,
|
|
)
|
|
|
|
|
|
def _restore_config_from_backup():
|
|
"""Restore config.ini from the .before-strict backup left by the script."""
|
|
backup = MANAGER_CONFIG + ".before-strict"
|
|
if os.path.isfile(backup):
|
|
shutil.copyfile(backup, MANAGER_CONFIG)
|
|
os.remove(backup)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def comfyui_strict():
|
|
"""Start ComfyUI in strict mode for the duration of the module.
|
|
|
|
Teardown order matters:
|
|
1. Stop the server (so it releases the config file lock).
|
|
2. Restore the original config.ini (so subsequent test modules see
|
|
the default security_level=normal again).
|
|
"""
|
|
pid = _start_comfyui_strict()
|
|
try:
|
|
yield pid
|
|
finally:
|
|
_stop_comfyui()
|
|
_restore_config_from_backup()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSecurityGate403_SR4:
|
|
"""Goal SR4 — POST /v2/snapshot/remove must return 403 below `middle`.
|
|
|
|
Handler: comfyui_manager/glob/manager_server.py:1535-1554
|
|
Gate: is_allowed_security_level("middle")
|
|
Strict (security_level=strong) → False → 403.
|
|
|
|
Negative check (verification_design.md §7.3): the target snapshot file
|
|
on disk must NOT be removed when the gate rejects the request.
|
|
"""
|
|
|
|
def test_remove_returns_403(self, comfyui_strict):
|
|
# Seed a snapshot file directly on disk so we have something the
|
|
# handler MIGHT delete if the gate were broken. We do not call
|
|
# /v2/snapshot/save here because that path also requires server
|
|
# state; touching a file is sufficient for the negative check.
|
|
os.makedirs(SNAPSHOT_DIR, exist_ok=True)
|
|
seed_name = "secgate-sr4-seed"
|
|
seed_path = os.path.join(SNAPSHOT_DIR, f"{seed_name}.json")
|
|
with open(seed_path, "w") as f:
|
|
f.write('{"snapshot": "seed for SR4 negative check"}')
|
|
try:
|
|
assert os.path.isfile(seed_path), "test seed file missing"
|
|
|
|
resp = requests.post(
|
|
f"{BASE_URL}/v2/snapshot/remove",
|
|
params={"target": seed_name},
|
|
timeout=10,
|
|
)
|
|
|
|
# Primary assertion: 403 from the security gate
|
|
assert resp.status_code == 403, (
|
|
f"SR4 SECURITY-GATE BYPASS: POST snapshot/remove?target="
|
|
f"{seed_name} returned {resp.status_code} at "
|
|
f"security_level=strong (expected 403). Response: "
|
|
f"{resp.text[:200]}"
|
|
)
|
|
|
|
# Negative-side assertion: the target file was NOT deleted
|
|
assert os.path.isfile(seed_path), (
|
|
f"SR4 NEGATIVE-CHECK FAILURE: snapshot file {seed_path} was "
|
|
f"deleted despite a 403 response — gate failed to block the "
|
|
f"side effect."
|
|
)
|
|
finally:
|
|
# Cleanup — remove the seed file we created
|
|
if os.path.isfile(seed_path):
|
|
os.remove(seed_path)
|
|
|
|
# Positive counterpart (POST works at default after teardown) is covered
|
|
# by test_e2e_secgate_default.py via its own ComfyUI startup; a
|
|
# skip-placeholder was previously parked here for documentation but
|
|
# removed in WI-MM because it added no verification and created a
|
|
# stale-skip row. See reports/e2e_verification_audit.md § SECGATE.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config setter 403 contract (added in WI #255)
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# Before WI #255 the three config setters below had NO security_level gate,
|
|
# so any caller could mutate db_mode / update_policy / channel_url even at
|
|
# security_level=strong. WI #255 added `is_allowed_security_level('middle')`
|
|
# to all three (glob + legacy) at the same risk tier as uninstall/update /
|
|
# snapshot/remove.
|
|
#
|
|
# These tests belong in the STRICT harness — at default `security_level =
|
|
# normal`, the 'middle' allow-set is [weak, normal, normal-] which INCLUDES
|
|
# normal, so the 403 path is not reachable. Strong excludes normal, so the
|
|
# rejection is observable.
|
|
CONFIG_SETTER_ENDPOINTS = [
|
|
("/v2/manager/db_mode", {"value": "local"}, "set_db_mode_api"),
|
|
("/v2/manager/policy/update", {"value": "nightly-comfyui"}, "set_update_policy_api"),
|
|
("/v2/manager/channel_url_list", {"value": "default"}, "set_channel_url"),
|
|
]
|
|
|
|
|
|
class TestConfigSetterRequiresMiddle:
|
|
"""Each of the 3 config-mutation POST handlers must return 403 under
|
|
security_level=strong. Before WI #255 they returned 200 unconditionally.
|
|
|
|
Handlers (glob):
|
|
- comfyui_manager/glob/manager_server.py :: set_db_mode_api (L1954)
|
|
- comfyui_manager/glob/manager_server.py :: set_update_policy_api (L1972)
|
|
- comfyui_manager/glob/manager_server.py :: set_channel_url (L2000)
|
|
Gate: is_allowed_security_level('middle')
|
|
Strong → normal not in [weak, normal, normal-] → False → 403.
|
|
|
|
Negative-check aspect: a 403 response means the config file was NOT
|
|
mutated. We don't re-read config.ini here because the strict harness
|
|
also patches it — checking for absence of mutation would require
|
|
a pre/post diff that is brittle; the 403 status is the contract.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"path,body,handler_name",
|
|
CONFIG_SETTER_ENDPOINTS,
|
|
ids=[p for p, _, _ in CONFIG_SETTER_ENDPOINTS],
|
|
)
|
|
def test_config_setter_returns_403(self, comfyui_strict, path, body, handler_name):
|
|
resp = requests.post(
|
|
f"{BASE_URL}{path}",
|
|
json=body,
|
|
timeout=10,
|
|
)
|
|
assert resp.status_code == 403, (
|
|
f"CONFIG-SETTER SECURITY-GATE BYPASS: POST {path} "
|
|
f"(handler {handler_name}) returned {resp.status_code} at "
|
|
f"security_level=strong (expected 403). Config mutation must "
|
|
f"require middle+. Response: {resp.text[:200]}"
|
|
)
|