ComfyUI-Manager/tests/e2e/test_e2e_secgate_strict.py
Dr.Lt.Data 4410ebc6a6
Some checks are pending
Publish to PyPI / build-and-publish (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
fix(security): harden CSRF with Content-Type gate and expand E2E coverage (#2818)
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)
2026-04-22 05:04:30 +09:00

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]}"
)