mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-07-27 10:37:31 +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:
@@ -42,8 +42,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/Comfy-Org/ComfyUI-Manager/main"
|
||||
|
||||
@@ -39,6 +39,7 @@ comfyui_tag = None
|
||||
|
||||
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/Comfy-Org/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/Comfy-Org/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/Comfy-Org/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/Comfy-Org/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."
|
||||
@@ -489,8 +490,12 @@ 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 L504/507 already
|
||||
# handle url is None, so we just need a None-safe title.
|
||||
url = core.unified_manager.unknown_active_nodes[node_name][0]
|
||||
title = os.path.basename(url)
|
||||
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']
|
||||
@@ -549,6 +554,14 @@ async def task_worker():
|
||||
return "An error occurred while updating 'comfyui'."
|
||||
|
||||
async def do_fix(item) -> str:
|
||||
# Align check level with SECURITY_MESSAGE_HIGH_P (which names "high+"),
|
||||
# matching the parallel update in comfyui_manager/glob/manager_server.py
|
||||
# do_fix. Prior combo of `'high' + HIGH_P` logged a stricter-sounding
|
||||
# message than the gate actually enforced.
|
||||
if not is_allowed_security_level('high+'):
|
||||
logging.error(SECURITY_MESSAGE_HIGH_P)
|
||||
return 'failed'
|
||||
|
||||
ui_id, node_name, node_ver = item
|
||||
|
||||
try:
|
||||
@@ -896,8 +909,11 @@ async def fetch_updates(request):
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/update_all")
|
||||
@routes.post("/v2/manager/queue/update_all")
|
||||
async def update_all(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
json_data = dict(request.rel_url.query)
|
||||
return await _update_all(json_data)
|
||||
|
||||
@@ -1155,8 +1171,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 = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
@@ -1178,8 +1197,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 = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if not is_allowed_security_level('middle+'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_P)
|
||||
return web.Response(status=403)
|
||||
@@ -1217,8 +1239,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 = manager_security.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)
|
||||
@@ -1357,14 +1382,12 @@ async def import_fail_info_bulk(request):
|
||||
return web.Response(status=500, text="Internal server error")
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/reinstall")
|
||||
async def reinstall_custom_node(request):
|
||||
await uninstall_custom_node(request)
|
||||
await install_custom_node(request)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/reset")
|
||||
@routes.post("/v2/manager/queue/reset")
|
||||
async def reset_queue(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
global task_batch_queue
|
||||
global temp_queue_batch
|
||||
|
||||
@@ -1375,19 +1398,6 @@ async def reset_queue(request):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/abort_current")
|
||||
async def abort_queue(request):
|
||||
global task_batch_queue
|
||||
global temp_queue_batch
|
||||
|
||||
with task_worker_lock:
|
||||
temp_queue_batch = []
|
||||
if len(task_batch_queue) > 0:
|
||||
task_batch_queue[0].abort()
|
||||
task_batch_queue.popleft()
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/status")
|
||||
async def queue_count(request):
|
||||
@@ -1413,13 +1423,6 @@ async def queue_count(request):
|
||||
'is_processing': is_processing})
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/install")
|
||||
async def install_custom_node(request):
|
||||
json_data = await request.json()
|
||||
print(f"install={json_data}")
|
||||
return await _install_custom_node(json_data)
|
||||
|
||||
|
||||
async def _install_custom_node(json_data):
|
||||
if not is_allowed_security_level('middle+'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE_P)
|
||||
@@ -1482,8 +1485,11 @@ async def _install_custom_node(json_data):
|
||||
|
||||
task_worker_thread:threading.Thread = None
|
||||
|
||||
@routes.get("/v2/manager/queue/start")
|
||||
@routes.post("/v2/manager/queue/start")
|
||||
async def queue_start(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
with task_worker_lock:
|
||||
finalize_temp_queue_batch()
|
||||
return _queue_start()
|
||||
@@ -1500,12 +1506,6 @@ def _queue_start():
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/fix")
|
||||
async def fix_custom_node(request):
|
||||
json_data = await request.json()
|
||||
return await _fix_custom_node(json_data)
|
||||
|
||||
|
||||
async def _fix_custom_node(json_data):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_GENERAL)
|
||||
@@ -1557,12 +1557,6 @@ async def install_custom_node_pip(request):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/uninstall")
|
||||
async def uninstall_custom_node(request):
|
||||
json_data = await request.json()
|
||||
return await _uninstall_custom_node(json_data)
|
||||
|
||||
|
||||
async def _uninstall_custom_node(json_data):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
@@ -1583,12 +1577,6 @@ async def _uninstall_custom_node(json_data):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/update")
|
||||
async def update_custom_node(request):
|
||||
json_data = await request.json()
|
||||
return await _update_custom_node(json_data)
|
||||
|
||||
|
||||
async def _update_custom_node(json_data):
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
@@ -1607,8 +1595,11 @@ async def _update_custom_node(json_data):
|
||||
return web.Response(status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/queue/update_comfyui")
|
||||
@routes.post("/v2/manager/queue/update_comfyui")
|
||||
async def update_comfyui(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
is_stable = core.get_config()['update_policy'] != 'nightly-comfyui'
|
||||
temp_queue_batch.append(("update-comfyui", ('comfyui', is_stable)))
|
||||
return web.Response(status=200)
|
||||
@@ -1625,24 +1616,28 @@ 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:
|
||||
if "ver" in request.rel_url.query:
|
||||
core.switch_comfyui(request.rel_url.query['ver'])
|
||||
# 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 is_allowed_security_level('high+'):
|
||||
logging.error(SECURITY_MESSAGE_HIGH_P)
|
||||
return web.Response(status=403)
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
ver = data.get('ver')
|
||||
if not ver:
|
||||
return web.Response(status=400, text="missing 'ver' field")
|
||||
core.switch_comfyui(ver)
|
||||
return web.Response(status=200)
|
||||
except json.JSONDecodeError:
|
||||
return web.Response(status=400, text="Invalid JSON body")
|
||||
except Exception as e:
|
||||
logging.error(f"ComfyUI update fail: {e}", file=sys.stderr)
|
||||
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
@routes.post("/v2/manager/queue/disable")
|
||||
async def disable_node(request):
|
||||
json_data = await request.json()
|
||||
await _disable_node(json_data)
|
||||
return web.Response(status=200)
|
||||
return web.Response(status=400)
|
||||
|
||||
|
||||
async def _disable_node(json_data):
|
||||
@@ -1712,48 +1707,88 @@ async def _install_model(json_data):
|
||||
|
||||
@routes.get("/v2/manager/db_mode")
|
||||
async def db_mode(request):
|
||||
if "value" in request.rel_url.query:
|
||||
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 is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
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')
|
||||
|
||||
|
||||
@routes.get("/v2/manager/policy/update")
|
||||
async def update_policy(request):
|
||||
if "value" in request.rel_url.query:
|
||||
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 is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
try:
|
||||
data = await request.json()
|
||||
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')
|
||||
|
||||
|
||||
@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)
|
||||
res = {'selected': selected,
|
||||
'list': core.get_channel_list()}
|
||||
return web.json_response(res, status=200)
|
||||
|
||||
return web.Response(status=200)
|
||||
|
||||
@routes.post("/v2/manager/channel_url_list")
|
||||
async def set_channel_url(request):
|
||||
# See set_db_mode_api above for gate rationale.
|
||||
if not 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 glob set_channel_url (comfyui_manager/glob/manager_server.py)
|
||||
# and 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')
|
||||
|
||||
|
||||
def add_target_blank(html_text):
|
||||
@@ -1817,13 +1852,12 @@ async def get_notice(request):
|
||||
|
||||
|
||||
# legacy /manager/notice
|
||||
@routes.get("/manager/notice")
|
||||
async def get_notice_legacy(request):
|
||||
return web.Response(text="""<font color="red">Starting from ComfyUI-Manager V4.0+, it should be installed via pip.<BR><BR>Please remove the ComfyUI-Manager installed in the <font color="white">'custom_nodes'</font> directory.</font>""", status=200)
|
||||
|
||||
|
||||
@routes.get("/v2/manager/reboot")
|
||||
def restart(self):
|
||||
@routes.post("/v2/manager/reboot")
|
||||
def restart(request):
|
||||
rejection = manager_security.reject_simple_form_post(request)
|
||||
if rejection is not None:
|
||||
return rejection
|
||||
if not is_allowed_security_level('middle'):
|
||||
logging.error(SECURITY_MESSAGE_MIDDLE)
|
||||
return web.Response(status=403)
|
||||
@@ -1937,9 +1971,13 @@ if not os.path.exists(context.manager_config_path):
|
||||
|
||||
|
||||
# policy setup
|
||||
manager_security.add_handler_policy(reinstall_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(install_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(fix_custom_node, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
# WI-V: removed stale references to reinstall_custom_node / install_custom_node /
|
||||
# fix_custom_node — these legacy handlers were deleted in prior refactors
|
||||
# (likely the CSRF POST-conversion / security level work). Their remaining
|
||||
# add_handler_policy() calls raised NameError at module import, aborting
|
||||
# `comfyui_manager.start()`'s legacy branch before EXTENSION_WEB_DIRS could
|
||||
# register the legacy-UI JS directory — which is why the legacy Manager
|
||||
# button never rendered in the ComfyUI toolbar for Playwright tests.
|
||||
manager_security.add_handler_policy(install_custom_node_git_url, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(install_custom_node_pip, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
manager_security.add_handler_policy(install_model, manager_security.HANDLER_POLICY.MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD)
|
||||
|
||||
Reference in New Issue
Block a user