Skip to content

Commit 4410ebc

Browse files
authored
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)
1 parent 49e205a commit 4410ebc

70 files changed

Lines changed: 13632 additions & 428 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,4 @@ jobs:
7171
fi
7272
uv pip install --python "$VENV_PY" pytest pytest-timeout
7373
74-
"$VENV_PY" -m pytest tests/e2e/test_e2e_uv_compile.py -v -s --timeout=300
74+
"$VENV_PY" -m pytest tests/cli/test_uv_compile.py -v -s --timeout=300

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,5 @@ dist
2424
.env
2525
.claude
2626
test_venv
27+
node_modules/
28+
artifacts/

CHANGELOG.md

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
# Changelog
2+
3+
All notable changes to **ComfyUI-Manager** are documented in this file.
4+
5+
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
Security-hardening release on branch `fix/csrf-post-conversion`. Contains
11+
breaking-ish API changes for state-mutating endpoints. See **Migration notes**
12+
below before upgrading programmatic clients.
13+
14+
### Security
15+
16+
- **CSRF Content-Type gate**: 18 state-mutation POST handlers (9 in `glob`, 9 in
17+
`legacy`) now reject the three CORS "simple request" Content-Types
18+
(`application/x-www-form-urlencoded`, `multipart/form-data`, `text/plain`).
19+
This closes the residual `<form method="POST">` bypass route that remained
20+
after the GET→POST transition. Legitimate clients using `application/json`
21+
(or no body) are unaffected.
22+
- **`do_fix` security level raised from `high` to `high+`**: aligns the
23+
enforcement gate (`is_allowed_security_level`) with the log text emitted by
24+
`SECURITY_MESSAGE_HIGH_P`. Both `glob/manager_server.py` and
25+
`legacy/manager_server.py` updated in lockstep. Environments running at
26+
`security_level = high` can no longer fix a nodepack — use
27+
`security_level = normal` or lower.
28+
- **Config setters now gated at `middle` security level**:
29+
`POST /v2/manager/db_mode`, `POST /v2/manager/policy/update`, and
30+
`POST /v2/manager/channel_url_list` now check
31+
`is_allowed_security_level('middle')` before mutating configuration (both
32+
`glob` and `legacy`). Closes a pre-existing gap where the write path was
33+
reachable at any security level. Reads (`GET`) remain unrestricted.
34+
35+
### Changed
36+
37+
- **State-changing endpoints converted from `GET` to `POST`** (CSRF hardening):
38+
`/v2/manager/queue/{update_all, reset, start, update_comfyui}`,
39+
`/v2/snapshot/{remove, restore, save}`,
40+
`/v2/comfyui_manager/comfyui_switch_version`,
41+
`/v2/manager/reboot`.
42+
Query-string parameters are preserved where they existed; only the HTTP
43+
method changes.
44+
- **`POST /v2/comfyui_manager/comfyui_switch_version` parameters moved from
45+
query string to JSON body** (REST idiom + body-reading CSRF posture):
46+
The handler now consumes `application/json` with the body shape
47+
`{"ver": "...", "client_id": "...", "ui_id": "..."}` instead of reading
48+
`?ver=...&client_id=...&ui_id=...` from the URL. Because body-reading
49+
handlers are already covered by the CORS-preflight mechanism for
50+
cross-origin protection, the Content-Type rejection gate introduced for
51+
the other state-mutation endpoints is intentionally NOT applied here
52+
(see `comfyui_manager/common/manager_security.py` module docstring).
53+
The first-party JS client in `comfyui_manager/js/comfyui-manager.js`
54+
was updated in the same change; third-party callers must migrate.
55+
- **Config endpoints split into `GET` (read) + `POST` (write)**:
56+
`/v2/manager/{db_mode, policy/update, channel_url_list}`. `GET` returns the
57+
current value; `POST` accepts a JSON body `{"value": "..."}`. The prior
58+
single-method form that accepted a `?value=...` query parameter on either
59+
verb is retired.
60+
- **`openapi.yaml` fully resynchronized** with the server: HTTP methods, the
61+
dual-method splits above, request-body schemas for the new POST setters,
62+
and the `TaskHistoryItem.params` field now match `manager_server.py`.
63+
- **Legacy `restart(self)``restart(request)`**: parameter name corrected.
64+
No behavioral change.
65+
66+
### Added
67+
68+
- **E2E test harness variants** for security-level and legacy-mode scenarios:
69+
`tests/e2e/scripts/start_comfyui_legacy.sh`,
70+
`tests/e2e/scripts/start_comfyui_permissive.sh`,
71+
`tests/e2e/scripts/start_comfyui_strict.sh`. See
72+
`docs/guide/GUIDE_E2E_TEST.md` for usage.
73+
- **`COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS` environment variable**: when
74+
set, skips the `manager_requirements.txt` reinstall path. Intended for E2E
75+
environments where those dependencies are provisioned separately.
76+
- **`TaskHistoryItem.params` field** (Pydantic + `openapi.yaml`): mirrors
77+
`QueueTaskItem.params` so that task history retains the original request
78+
payload (nullable when unavailable).
79+
- **Automated endpoint coverage** — pytest E2E + Playwright specs covering all
80+
39 unique `(method, path)` endpoints across `glob` and `legacy`. Coverage is
81+
tracked in `reports/api-coverage-matrix.md` and
82+
`reports/e2e_test_coverage.md`.
83+
84+
### Removed
85+
86+
- **Legacy per-operation POST routes consolidated into `POST /v2/manager/queue/batch`**:
87+
`/v2/manager/queue/{install, uninstall, update, fix, disable, reinstall, abort_current}`.
88+
The first-party JS client already uses `queue/batch`; only third-party
89+
scripts that call the per-operation routes directly are affected.
90+
- **`GET /manager/notice`** (v1, pip-install redirect banner).
91+
`GET /v2/manager/notice` remains available.
92+
93+
### Migration notes
94+
95+
- Third-party clients calling `POST /v2/manager/queue/install` (and the other
96+
per-operation queue routes) must switch to
97+
`POST /v2/manager/queue/batch` with a body such as
98+
`{"install": [{id, ver, ...}], "batch_id": "..."}`. See
99+
`reports/endpoint_scenarios.md` for the full payload shape.
100+
- Programmatic clients that posted to the CSRF-hardened endpoints with
101+
`application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`
102+
must switch to `application/json` (or omit the body entirely when the
103+
endpoint takes its parameters from the query string).
104+
- Clients that called any of the methods listed under **Changed → State-changing
105+
endpoints** with `GET` must switch to `POST`. Query parameters remain valid.
106+
- Clients that wrote configuration via
107+
`GET /v2/manager/{db_mode, policy/update, channel_url_list}?value=...`
108+
must switch to `POST` with JSON body `{"value": "..."}`.
109+
- Third-party scripts calling
110+
`POST /v2/comfyui_manager/comfyui_switch_version?ver=...&client_id=...&ui_id=...`
111+
must switch to `POST` with `Content-Type: application/json` and body
112+
`{"ver": "...", "client_id": "...", "ui_id": "..."}`. The query-string
113+
form no longer works.
114+
- Environments running at `security_level = high` can no longer run
115+
`do_fix`. Either lower the security level (`normal`, `normal-`, or `weak`
116+
as appropriate) or skip the fix operation.
117+
- Environments running at `security_level = high` can no longer mutate
118+
`db_mode`, `policy/update`, or `channel_url_list` via POST (returns `403`).
119+
Lower the security level to `normal` or below to change configuration, or
120+
perform the change from a trusted entry point. Read access via `GET` is
121+
unaffected.
122+
123+
[Unreleased]: https://github.com/Comfy-Org/ComfyUI-Manager/compare/v4.1b6...HEAD

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -324,8 +324,8 @@ The security settings are applied based on whether the ComfyUI server's listener
324324
325325
| Risky Level | features |
326326
|-------------|---------------------------------------------------------------------------------------------------------------------------------------|
327-
| high+ | * `Install via git url`, `pip install`<BR>* Installation of nodepack registered not in the `default channel`. |
328-
| high | * Fix nodepack |
327+
| high+ | * `Install via git url`, `pip install`<BR>* Installation of nodepack registered not in the `default channel`.<BR>* **Switch ComfyUI version**<BR>* **Fix nodepack** |
328+
| high | _(no features at this tier — `Fix nodepack` promoted to `high+` to align the enforcement gate with the `SECURITY_MESSAGE_HIGH_P` log text)_ |
329329
| middle+ | * Uninstall/Update<BR>* Installation of nodepack registered in the `default channel`.<BR>* Restore/Remove Snapshot<BR>* Install model |
330330
| middle | * Restart |
331331
| low | * Update ComfyUI |

comfyui_manager/__init__.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@ def start():
2626
logging.info("[ComfyUI-Manager] Legacy UI is enabled.")
2727
nodes.EXTENSION_WEB_DIRS['comfyui-manager-legacy'] = os.path.join(os.path.dirname(__file__), 'js')
2828
except Exception as e:
29-
print("Error enabling legacy ComfyUI Manager frontend:", e)
29+
# WI-V: upgraded silent `print` to a proper logging.error with
30+
# traceback so future legacy-UI load failures are visible in
31+
# the log, not swallowed. The original `print` could be lost
32+
# depending on how stdout is captured.
33+
import traceback
34+
logging.error(
35+
"[ComfyUI-Manager] Error enabling legacy frontend: "
36+
f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
37+
)
3038
core = None
3139
else:
3240
from .glob import manager_server # noqa: F401

comfyui_manager/common/manager_security.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,74 @@
1+
"""Security helpers for CSRF protection and Content-Type gating.
2+
3+
reject_simple_form_post() is applied ONLY to POST handlers that do not consume
4+
a request body (e.g., snapshot/save, queue/reset, queue/start, reboot). These
5+
are vulnerable to cross-origin <form method=POST> attacks because the server
6+
accepts the request without parsing any body — the attacker needs no ability
7+
to forge a valid payload, only to point a hidden form at the URL.
8+
9+
Handlers that DO read a body via ``await request.json()`` (install/git_url,
10+
install/pip, queue/install_model, db_mode POST, policy/update POST,
11+
channel_url_list POST, queue/batch, queue/task, import_fail_info, etc.) are
12+
NOT gated here — a cross-origin <form method=POST> cannot forge a valid JSON
13+
body because the browser refuses to send ``application/json`` without a CORS
14+
preflight, which this server rejects by not responding with an appropriate
15+
Access-Control-Allow-Origin.
16+
17+
DO NOT add the gate to body-reading handlers (redundant + UX-breaking).
18+
DO NOT remove the gate from no-body handlers (this is the bypass vector).
19+
"""
20+
121
import os
222
from enum import Enum
323
from typing import Optional
424

25+
from aiohttp import web
26+
527
is_personal_cloud_mode = False
628
handler_policy = {}
729

30+
31+
# CORS "simple request" Content-Type set per Fetch spec §3.2.3. Browsers send
32+
# <form method=POST> submissions with one of these three MIME types and do NOT
33+
# trigger a CORS preflight, so a malicious cross-origin page can silently POST
34+
# into state-changing endpoints if we only gate on HTTP method. Blocking these
35+
# three Content-Types on our mutation endpoints forces any non-same-origin POST
36+
# to use a non-simple Content-Type (e.g. application/json), which triggers a
37+
# preflight that this server rejects (no Access-Control-Allow-Origin response).
38+
_SIMPLE_FORM_CONTENT_TYPES = frozenset({
39+
'application/x-www-form-urlencoded',
40+
'multipart/form-data',
41+
'text/plain',
42+
})
43+
44+
45+
def reject_simple_form_post(request) -> Optional[web.Response]:
46+
"""Reject Content-Types that enable preflight-less <form method=POST> CSRF.
47+
48+
These 3 MIME types are the complete CORS "simple request" Content-Type set
49+
(Fetch spec §3.2.3 "CORS-safelisted request-header"). Blocking them
50+
eliminates the <form method=POST> cross-origin CSRF vector, because any
51+
other Content-Type triggers a browser-enforced CORS preflight — and this
52+
server does not answer preflights with ``Access-Control-Allow-Origin``,
53+
effectively blocking cross-origin requests that use non-simple types.
54+
55+
Returns:
56+
web.Response(status=400) when the request has a simple-form
57+
Content-Type that must be rejected. None when the request is allowed
58+
to proceed (no body, application/json, or any non-simple Content-Type).
59+
60+
Note:
61+
aiohttp's ``request.content_type`` normalizes the header (lower-cases,
62+
strips parameters), so a ``multipart/form-data; boundary=----X`` header
63+
is compared as ``multipart/form-data``.
64+
"""
65+
if request.content_type in _SIMPLE_FORM_CONTENT_TYPES:
66+
return web.Response(
67+
status=400,
68+
text='Invalid Content-Type for this endpoint. Use application/json or omit body.',
69+
)
70+
return None
71+
872
class HANDLER_POLICY(Enum):
973
MULTIPLE_REMOTE_BAN_NON_LOCAL = 1
1074
MULTIPLE_REMOTE_BAN_NOT_PERSONAL_CLOUD = 2

comfyui_manager/data_models/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
EnablePackParams,
5858
UpdateAllQueryParams,
5959
UpdateComfyUIQueryParams,
60-
ComfyUISwitchVersionQueryParams,
60+
ComfyUISwitchVersionParams,
6161
QueueStatus,
6262
ManagerMappings,
6363
ModelMetadata,
@@ -121,7 +121,7 @@
121121
"EnablePackParams",
122122
"UpdateAllQueryParams",
123123
"UpdateComfyUIQueryParams",
124-
"ComfyUISwitchVersionQueryParams",
124+
"ComfyUISwitchVersionParams",
125125
"QueueStatus",
126126
"ManagerMappings",
127127
"ModelMetadata",

comfyui_manager/data_models/generated_models.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: openapi.yaml
3-
# timestamp: 2025-07-31T04:52:26+00:00
3+
# timestamp: 2026-04-19T04:33:23+00:00
44

55
from __future__ import annotations
66

@@ -229,8 +229,8 @@ class UpdateComfyUIQueryParams(BaseModel):
229229
)
230230

231231

232-
class ComfyUISwitchVersionQueryParams(BaseModel):
233-
ver: str = Field(..., description="Version to switch to")
232+
class ComfyUISwitchVersionParams(BaseModel):
233+
ver: str = Field(..., description="Target ComfyUI version tag")
234234
client_id: str = Field(
235235
..., description="Client identifier that initiated the request"
236236
)
@@ -502,6 +502,22 @@ class TaskHistoryItem(BaseModel):
502502
end_time: Optional[datetime] = Field(
503503
None, description="ISO timestamp when task execution ended"
504504
)
505+
params: Optional[
506+
Union[
507+
InstallPackParams,
508+
UpdatePackParams,
509+
UpdateAllPacksParams,
510+
UpdateComfyUIParams,
511+
FixPackParams,
512+
UninstallPackParams,
513+
DisablePackParams,
514+
EnablePackParams,
515+
ModelMetadata,
516+
]
517+
] = Field(
518+
None,
519+
description="Original task parameters (mirrors QueueTaskItem.params); null if unavailable",
520+
)
505521

506522

507523
class TaskStateMessage(BaseModel):

comfyui_manager/glob/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
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"
33
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"
4+
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"
45
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"
56
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"
67
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."

comfyui_manager/glob/manager_core.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,13 @@
4444
from ..common import context
4545

4646

47-
version_code = [4, 2]
48-
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
47+
try:
48+
from importlib.metadata import version as _pkg_version
49+
_raw_version = _pkg_version("comfyui-manager")
50+
except Exception:
51+
_raw_version = "unknown"
52+
53+
version_str = f"V{_raw_version}"
4954

5055

5156
DEFAULT_CHANNEL = "https://raw.githubusercontent.com/ltdrdata/ComfyUI-Manager/main"
@@ -2033,6 +2038,10 @@ def install_manager_requirements(repo_path):
20332038
Install packages from manager_requirements.txt if it exists.
20342039
This is specifically for ComfyUI's manager_requirements.txt.
20352040
"""
2041+
if os.environ.get("COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS", "").lower() in ("1", "true", "yes"):
2042+
logging.info("[ComfyUI-Manager] Skipping manager_requirements.txt install (COMFYUI_MANAGER_SKIP_MANAGER_REQUIREMENTS set)")
2043+
return
2044+
20362045
manager_requirements_path = os.path.join(repo_path, "manager_requirements.txt")
20372046
if not os.path.exists(manager_requirements_path):
20382047
return

0 commit comments

Comments
 (0)