fix(security): harden CSRF with Content-Type gate and expand E2E coverage (#2818)
Publish to PyPI / build-and-publish (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run

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:
Dr.Lt.Data
2026-04-22 05:04:30 +09:00
committed by GitHub
parent 49e205acd4
commit 4410ebc6a6
70 changed files with 13638 additions and 434 deletions
+7 -2
View File
@@ -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"
+139 -101
View File
@@ -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)