mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-07-26 10:07:38 +08:00
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)
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
|
||||
SECURITY_MESSAGE_MIDDLE = "ERROR: To use this action, a security_level of `normal or below` is required. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_MIDDLE_P = "ERROR: To use this action, security_level must be `normal or below`, and network_mode must be set to `personal_cloud`. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_HIGH_P = "ERROR: To use this action, '--listen' must be set to a local IP and security_level must be 'normal-' or lower. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/ltdrdata/ComfyUI-Manager#security-policy"
|
||||
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower."
|
||||
|
||||
@@ -44,8 +44,13 @@ from ..common.enums import NetworkMode, SecurityLevel, DBMode
|
||||
from ..common import context
|
||||
|
||||
|
||||
version_code = [4, 2]
|
||||
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
_raw_version = _pkg_version("comfyui-manager")
|
||||
except Exception:
|
||||
_raw_version = "unknown"
|
||||
|
||||
version_str = f"V{_raw_version}"
|
||||
|
||||
|
||||
DEFAULT_CHANNEL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main"
|
||||
@@ -2033,6 +2038,10 @@ def install_manager_requirements(repo_path):
|
||||
Install packages from manager_requirements.txt if it exists.
|
||||
This is specifically for ComfyUI's manager_requirements.txt.
|
||||
"""
|
||||
if os.environ.get("COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS", "").lower() in ("1", "true", "yes"):
|
||||
logging.info("[ComfyUI-Manager] Skipping manager_requirements.txt install (COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS set)")
|
||||
return
|
||||
|
||||
manager_requirements_path = os.path.join(repo_path, "manager_requirements.txt")
|
||||
if not os.path.exists(manager_requirements_path):
|
||||
return
|
||||
|
||||
@@ -80,13 +80,14 @@ from ..data_models import (
|
||||
SecurityLevel,
|
||||
UpdateAllQueryParams,
|
||||
UpdateComfyUIQueryParams,
|
||||
ComfyUISwitchVersionQueryParams,
|
||||
ComfyUISwitchVersionParams,
|
||||
)
|
||||
|
||||
from .constants import (
|
||||
model_dir_name_map,
|
||||
SECURITY_MESSAGE_MIDDLE,
|
||||
SECURITY_MESSAGE_MIDDLE_P,
|
||||
SECURITY_MESSAGE_HIGH_P,
|
||||
)
|
||||
|
||||
if not manager_util.is_manager_pip_package():
|
||||
@@ -335,6 +336,7 @@ class TaskQueue:
|
||||
status=status,
|
||||
batch_id=self.batch_id,
|
||||
end_time=now,
|
||||
params=item.params,
|
||||
)
|
||||
|
||||
# Force cache refresh for successful pack-modifying operations
|
||||
@@ -656,8 +658,7 @@ class TaskQueue:
|
||||
def _get_manager_version(self) -> str:
|
||||
"""Get ComfyUI Manager version."""
|
||||
try:
|
||||
version_code = getattr(core, "version_code", [4, 0])
|
||||
return f"V{version_code[0]}.{version_code[1]}"
|
||||
return core.version_str
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -886,11 +887,13 @@ async def task_worker():
|
||||
res = core.unified_manager.unified_update(node_name, node_ver)
|
||||
|
||||
if res.ver == "unknown":
|
||||
# unknown_active_nodes[node_id] = (url, fullpath) — url can be
|
||||
# None when git_utils.git_url() in manager_core can't determine
|
||||
# the remote URL. Downstream branches at L901/904 already
|
||||
# handle url is None, so we just need a None-safe title.
|
||||
# Harmonized with legacy/manager_server.py equivalent (WI #252).
|
||||
url = core.unified_manager.unknown_active_nodes[node_name][0]
|
||||
try:
|
||||
title = os.path.basename(url)
|
||||
except Exception:
|
||||
title = node_name
|
||||
title = os.path.basename(url) if url else node_name
|
||||
else:
|
||||
url = core.unified_manager.cnr_map[node_name].get("repository")
|
||||
title = core.unified_manager.cnr_map[node_name]["name"]
|
||||
@@ -966,8 +969,12 @@ async def task_worker():
|
||||
return "An error occurred while updating 'comfyui'."
|
||||
|
||||
async def do_fix(params: FixPackParams) -> str:
|
||||
if not security_utils.is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
# Align check with SECURITY_MESSAGE_HIGH_P (which names "high+"); the
|
||||
# previous 'high' gate allowed the operation while logging a message
|
||||
# that implied a stricter requirement — confusing and slightly too lax
|
||||
# for a state-mutating fix path. Legacy/do_fix was updated to match.
|
||||
if not security_utils.is_allowed_security_level('high+'):
|
||||
logging.error(SECURITY_MESSAGE_HIGH_P)
|
||||
return OperationResult.failed.value
|
||||
|
||||
node_name = params.node_name
|
||||
@@ -1346,6 +1353,19 @@ async def get_history(request):
|
||||
}
|
||||
history = filtered_history
|
||||
|
||||
# Serialize TaskHistoryItem pydantic models to dicts for JSON output.
|
||||
# aiohttp's json_response uses json.dumps which cannot serialize BaseModel
|
||||
# instances; convert via model_dump(mode='json') to handle datetime fields.
|
||||
def _to_serializable(obj):
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump(mode="json")
|
||||
return obj
|
||||
|
||||
if isinstance(history, dict):
|
||||
history = {k: _to_serializable(v) for k, v in history.items()}
|
||||
else:
|
||||
history = _to_serializable(history)
|
||||
|
||||
return web.json_response({"history": history}, content_type="application/json")
|
||||
|
||||
except Exception as e:
|
||||
@@ -1408,8 +1428,11 @@ async def fetch_updates(request):
|
||||
)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/update_all")
|
||||
@routes.post("/v2/manager/queue/update_all")
|
||||
async def update_all(request: web.Request) -> web.Response:
|
||||
rejection = security_utils.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
try:
|
||||
# Validate query parameters using Pydantic model
|
||||
query_params = UpdateAllQueryParams.model_validate(dict(request.rel_url.query))
|
||||
@@ -1518,8 +1541,11 @@ async def get_snapshot_list(request):
|
||||
return web.json_response({"items": items}, content_type="application/json")
|
||||
|
||||
|
||||
@routes.get("/v2/snapshot/remove")
|
||||
@routes.post("/v2/snapshot/remove")
|
||||
async def remove_snapshot(request):
|
||||
rejection = security_utils.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if not security_utils.is_allowed_security_level("middle"):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
@@ -1540,8 +1566,11 @@ async def remove_snapshot(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.get("/v2/snapshot/restore")
|
||||
@routes.post("/v2/snapshot/restore")
|
||||
async def restore_snapshot(request):
|
||||
rejection = security_utils.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if not security_utils.is_allowed_security_level("middle+"):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_P)
|
||||
return web.Response(status=403)
|
||||
@@ -1582,8 +1611,11 @@ async def get_current_snapshot_api(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.get("/v2/snapshot/save")
|
||||
@routes.post("/v2/snapshot/save")
|
||||
async def save_snapshot(request):
|
||||
rejection = security_utils.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
try:
|
||||
await core.save_snapshot_with_postfix("snapshot")
|
||||
return web.Response(status=200)
|
||||
@@ -1715,8 +1747,11 @@ async def import_fail_info_bulk(request):
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/reset")
|
||||
@routes.post("/v2/manager/queue/reset")
|
||||
async def reset_queue(request):
|
||||
rejection = security_utils.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
logging.debug("[ComfyUI-Manager] Queue reset requested")
|
||||
task_queue.wipe_queue()
|
||||
return web.Response(status=200)
|
||||
@@ -1775,8 +1810,11 @@ async def queue_count(request):
|
||||
)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/start")
|
||||
@routes.post("/v2/manager/queue/start")
|
||||
async def queue_start(request):
|
||||
rejection = security_utils.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
logging.debug("[ComfyUI-Manager] Queue start requested")
|
||||
started = task_queue.start_worker()
|
||||
|
||||
@@ -1788,9 +1826,12 @@ async def queue_start(request):
|
||||
return web.Response(status=201) # Already in-progress
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/update_comfyui")
|
||||
@routes.post("/v2/manager/queue/update_comfyui")
|
||||
async def update_comfyui(request):
|
||||
"""Queue a ComfyUI update based on the configured update policy."""
|
||||
rejection = security_utils.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
try:
|
||||
# Validate query parameters using Pydantic model
|
||||
query_params = UpdateComfyUIQueryParams.model_validate(
|
||||
@@ -1837,17 +1878,26 @@ async def comfyui_versions(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.get("/v2/comfyui_manager/comfyui_switch_version")
|
||||
@routes.post("/v2/comfyui_manager/comfyui_switch_version")
|
||||
async def comfyui_switch_version(request):
|
||||
try:
|
||||
# Validate query parameters using Pydantic model
|
||||
query_params = ComfyUISwitchVersionQueryParams.model_validate(
|
||||
dict(request.rel_url.query)
|
||||
)
|
||||
# Body-reading handler — Content-Type gate omitted per
|
||||
# comfyui_manager/common/manager_security.py module policy: a cross-origin
|
||||
# <form method=POST> cannot forge a valid application/json body because
|
||||
# the browser would trigger a CORS preflight that this server refuses.
|
||||
if not security_utils.is_allowed_security_level("high+"):
|
||||
logging.error(SECURITY_MESSAGE_HIGH_P)
|
||||
return web.Response(status=403)
|
||||
|
||||
target_version = query_params.ver
|
||||
client_id = query_params.client_id
|
||||
ui_id = query_params.ui_id
|
||||
try:
|
||||
# Parse and validate JSON body (previously read from query string).
|
||||
# ComfyUISwitchVersionParams is reused — the field set is
|
||||
# identical for body and query; only the transport changed.
|
||||
json_data = await request.json()
|
||||
params = ComfyUISwitchVersionParams.model_validate(json_data)
|
||||
|
||||
target_version = params.ver
|
||||
client_id = params.client_id
|
||||
ui_id = params.ui_id
|
||||
|
||||
# Create update-comfyui task with target version
|
||||
task = QueueTaskItem(
|
||||
@@ -1859,6 +1909,8 @@ async def comfyui_switch_version(request):
|
||||
|
||||
task_queue.put(task)
|
||||
return web.Response(status=200)
|
||||
except json.JSONDecodeError:
|
||||
return web.Response(status=400, text="Invalid JSON body")
|
||||
except ValidationError as e:
|
||||
return web.json_response(
|
||||
{"error": "Validation error", "details": e.errors()}, status=400
|
||||
@@ -1902,51 +1954,97 @@ async def install_model(request):
|
||||
|
||||
@routes.get("/v2/manager/db_mode")
|
||||
async def db_mode(request):
|
||||
if "value" in request.rel_url.query:
|
||||
environment_utils.set_db_mode(request.rel_url.query["value"])
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.get_config()["db_mode"], status=200)
|
||||
return web.Response(text=core.get_config()["db_mode"], status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
@routes.post("/v2/manager/db_mode")
|
||||
async def set_db_mode_api(request):
|
||||
# Config writes are at the same risk tier as uninstall/update — apply the
|
||||
# 'middle' gate consistent with snapshot/remove, etc. Content-Type gate is
|
||||
# NOT applied here: this handler consumes application/json and a
|
||||
# cross-origin <form method=POST> cannot forge that without triggering
|
||||
# CORS preflight (see module docstring in common/manager_security.py).
|
||||
if not security_utils.is_allowed_security_level("middle"):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
environment_utils.set_db_mode(data["value"])
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return web.Response(status=400, text="Invalid request")
|
||||
except ValueError as e:
|
||||
return web.Response(status=400, text=str(e))
|
||||
|
||||
|
||||
@routes.get("/v2/manager/policy/update")
|
||||
async def update_policy(request):
|
||||
if "value" in request.rel_url.query:
|
||||
environment_utils.set_update_policy(request.rel_url.query["value"])
|
||||
core.write_config()
|
||||
else:
|
||||
return web.Response(text=core.get_config()["update_policy"], status=200)
|
||||
return web.Response(text=core.get_config()["update_policy"], status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
@routes.post("/v2/manager/policy/update")
|
||||
async def set_update_policy_api(request):
|
||||
# See set_db_mode_api above for gate rationale.
|
||||
if not security_utils.is_allowed_security_level("middle"):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
environment_utils.set_update_policy(data["value"])
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return web.Response(status=400, text="Invalid request")
|
||||
except ValueError as e:
|
||||
return web.Response(status=400, text=str(e))
|
||||
|
||||
|
||||
@routes.get("/v2/manager/channel_url_list")
|
||||
async def channel_url_list(request):
|
||||
channels = core.get_channel_dict()
|
||||
if "value" in request.rel_url.query:
|
||||
channel_url = channels.get(request.rel_url.query["value"])
|
||||
if channel_url is not None:
|
||||
core.get_config()["channel_url"] = channel_url
|
||||
core.write_config()
|
||||
else:
|
||||
selected = "custom"
|
||||
selected_url = core.get_config()["channel_url"]
|
||||
selected = "custom"
|
||||
selected_url = core.get_config()["channel_url"]
|
||||
|
||||
for name, url in channels.items():
|
||||
if url == selected_url:
|
||||
selected = name
|
||||
break
|
||||
for name, url in channels.items():
|
||||
if url == selected_url:
|
||||
selected = name
|
||||
break
|
||||
|
||||
res = {"selected": selected, "list": core.get_channel_list()}
|
||||
return web.json_response(res, status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
res = {"selected": selected, "list": core.get_channel_list()}
|
||||
return web.json_response(res, status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/reboot")
|
||||
def restart(self):
|
||||
@routes.post("/v2/manager/channel_url_list")
|
||||
async def set_channel_url(request):
|
||||
# See set_db_mode_api above for gate rationale.
|
||||
if not security_utils.is_allowed_security_level("middle"):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
channels = core.get_channel_dict()
|
||||
channel_url = channels.get(data["value"])
|
||||
if channel_url is None:
|
||||
# Reject unknown channel name explicitly instead of silent no-op.
|
||||
# Parity with set_db_mode / set_update_policy whitelist enforcement.
|
||||
return web.Response(
|
||||
status=400,
|
||||
text=f"Invalid channel name {data['value']!r}; "
|
||||
f"must be one of {sorted(channels.keys())}",
|
||||
)
|
||||
core.get_config()["channel_url"] = channel_url
|
||||
core.write_config()
|
||||
return web.Response(status=200)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
return web.Response(status=400, text="Invalid request")
|
||||
|
||||
|
||||
@routes.post("/v2/manager/reboot")
|
||||
def restart(request):
|
||||
rejection = security_utils.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if not security_utils.is_allowed_security_level("middle"):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
|
||||
@@ -91,11 +91,23 @@ def print_comfyui_version():
|
||||
)
|
||||
|
||||
|
||||
ALLOWED_UPDATE_POLICIES = ("stable", "stable-comfyui", "nightly", "nightly-comfyui")
|
||||
ALLOWED_DB_MODES = ("cache", "channel", "local", "remote")
|
||||
|
||||
|
||||
def set_update_policy(mode):
|
||||
if mode not in ALLOWED_UPDATE_POLICIES:
|
||||
raise ValueError(
|
||||
f"Invalid update_policy {mode!r}; must be one of {ALLOWED_UPDATE_POLICIES}"
|
||||
)
|
||||
core.get_config()["update_policy"] = mode
|
||||
|
||||
|
||||
def set_db_mode(mode):
|
||||
if mode not in ALLOWED_DB_MODES:
|
||||
raise ValueError(
|
||||
f"Invalid db_mode {mode!r}; must be one of {ALLOWED_DB_MODES}"
|
||||
)
|
||||
core.get_config()["db_mode"] = mode
|
||||
|
||||
|
||||
|
||||
@@ -5,10 +5,18 @@ from comfyui_manager.common.manager_security import (
|
||||
is_loopback,
|
||||
is_safe_path_target,
|
||||
get_safe_file_path,
|
||||
reject_simple_form_post,
|
||||
)
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = ['is_loopback', 'is_safe_path_target', 'get_safe_file_path', 'is_allowed_security_level', 'get_risky_level']
|
||||
__all__ = [
|
||||
'is_loopback',
|
||||
'is_safe_path_target',
|
||||
'get_safe_file_path',
|
||||
'reject_simple_form_post',
|
||||
'is_allowed_security_level',
|
||||
'get_risky_level',
|
||||
]
|
||||
|
||||
|
||||
def is_allowed_security_level(level):
|
||||
|
||||
Reference in New Issue
Block a user