fix(security): harden CSRF with Content-Type gate and OpenAPI sync (#2819)
Python Linting / Run Ruff (push) Has been cancelled

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 5 no-body POST handlers (snapshot/save,
manager/queue/{reset,start,update_comfyui}, manager/reboot) to block
<form method=POST> CSRF that bypasses method-only gating. Convert 10 pure
state-changing endpoints (fetch_updates, queue/{update_all,reset,start,
update_comfyui}, snapshot/{remove,restore,save}, comfyui_switch_version,
reboot) from GET to POST and split 5 config endpoints
(db_mode/preview_method/channel_url_list/policy/{component,update}) into
GET(read) + POST(write, JSON body). Emit the in_progress + done event pair
from the /manager/queue/install sync-enable fast-path so client UI
finalizes (previously only queue/start's empty worker done fired, leaving
item.restart unset and the Enable button visible after a successful enable).
Harden js/custom-nodes-manager.js completion path: await onQueueCompleted
with try/catch (surfaces silent turbogrid stale-item throws), replace the
{}.length == 0 no-op empty guard, set install_context before queue/install
to avoid a sync-completion race, wrap classList/updateCell in try/catch.
Resynchronize openapi.yaml with the converted routes (method → post, query
params → requestBody JSON schema, sibling post on 5 split endpoints).
Update 31 JS fetchApi call sites across 7 files; add
tests/test_csrf_content_type_helper.py covering 5 Content-Type cases via
aiohttp TestClient.

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:07 +09:00
committed by GitHub
parent d57c142019
commit 491f847bbc
11 changed files with 567 additions and 144 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ async function tryInstallCustomNode(event) {
}
}
let response = await api.fetchApi("/manager/reboot");
let response = await api.fetchApi("/manager/reboot", { method: 'POST' });
if(response.status == 403) {
await handle403Response(response);
return false;
+14 -14
View File
@@ -470,12 +470,12 @@ async function updateComfyUI() {
set_inprogress_mode();
const response = await api.fetchApi('/manager/queue/update_comfyui');
const response = await api.fetchApi('/manager/queue/update_comfyui', { method: 'POST' });
showTerminal();
is_updating = true;
await api.fetchApi('/manager/queue/start');
await api.fetchApi('/manager/queue/start', { method: 'POST' });
}
function showVersionSelectorDialog(versions, current, onSelect) {
@@ -625,14 +625,14 @@ async function switchComfyUI() {
showVersionSelectorDialog(versions, obj.current, async (selected_version) => {
if(selected_version == 'nightly') {
update_policy_combo.value = 'nightly-comfyui';
api.fetchApi('/manager/policy/update?value=nightly-comfyui');
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: 'nightly-comfyui' }) });
}
else {
update_policy_combo.value = 'stable-comfyui';
api.fetchApi('/manager/policy/update?value=stable-comfyui');
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: 'stable-comfyui' }) });
}
let response = await api.fetchApi(`/comfyui_manager/comfyui_switch_version?ver=${selected_version}`, { cache: "no-store" });
let response = await api.fetchApi('/comfyui_manager/comfyui_switch_version', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ver: selected_version }), cache: "no-store" });
if (response.status == 200) {
infoToast(`ComfyUI version is switched to ${selected_version}`);
}
@@ -769,10 +769,10 @@ async function updateAll(update_comfyui) {
if(update_comfyui) {
update_all_button.innerText = "Updating ComfyUI...";
await api.fetchApi('/manager/queue/update_comfyui');
await api.fetchApi('/manager/queue/update_comfyui', { method: 'POST' });
}
const response = await api.fetchApi(`/manager/queue/update_all?mode=${mode}`);
const response = await api.fetchApi('/manager/queue/update_all', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: mode }) });
if (response.status == 403) {
await handle403Response(response);
@@ -784,7 +784,7 @@ async function updateAll(update_comfyui) {
}
else if(response.status == 200) {
is_updating = true;
await api.fetchApi('/manager/queue/start');
await api.fetchApi('/manager/queue/start', { method: 'POST' });
}
}
@@ -813,7 +813,7 @@ function restartOrStop() {
rebootAPI();
}
else {
api.fetchApi('/manager/queue/reset');
api.fetchApi('/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
}
@@ -967,7 +967,7 @@ class ManagerMenuDialog extends ComfyDialog {
.then(data => { this.datasrc_combo.value = data; });
this.datasrc_combo.addEventListener('change', function (event) {
api.fetchApi(`/manager/db_mode?value=${event.target.value}`);
api.fetchApi('/manager/db_mode', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
});
const dbRetrievalSetttingItem = createSettingsCombo("DB", this.datasrc_combo);
@@ -1043,7 +1043,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
// Normal operation
api.fetchApi(`/manager/preview_method?value=${event.target.value}`)
api.fetchApi('/manager/preview_method', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) })
.then(response => {
if (response.status === 403) {
// Feature transitioned to native
@@ -1087,7 +1087,7 @@ class ManagerMenuDialog extends ComfyDialog {
}
channel_combo.addEventListener('change', function (event) {
api.fetchApi(`/manager/channel_url_list?value=${event.target.value}`);
api.fetchApi('/manager/channel_url_list', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
});
channel_combo.value = data.selected;
@@ -1152,7 +1152,7 @@ class ManagerMenuDialog extends ComfyDialog {
});
component_policy_combo.addEventListener('change', function (event) {
api.fetchApi(`/manager/policy/component?value=${event.target.value}`);
api.fetchApi('/manager/policy/component', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
set_component_policy(event.target.value);
});
@@ -1171,7 +1171,7 @@ class ManagerMenuDialog extends ComfyDialog {
});
update_policy_combo.addEventListener('change', function (event) {
api.fetchApi(`/manager/policy/update?value=${event.target.value}`);
api.fetchApi('/manager/policy/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value: event.target.value }) });
});
const updateSetttingItem = createSettingsCombo("Update", update_policy_combo);
+1 -1
View File
@@ -185,7 +185,7 @@ export async function rebootAPI() {
const isConfirmed = await customConfirm("Are you sure you'd like to reboot the server?");
if (isConfirmed) {
try {
const response = await api.fetchApi("/manager/reboot");
const response = await api.fetchApi("/manager/reboot", { method: 'POST' });
if (response.status == 403) {
await handle403Response(response);
return false;
+63 -19
View File
@@ -462,7 +462,7 @@ export class CustomNodesManager {
".cn-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset');
api.fetchApi('/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
@@ -1476,9 +1476,15 @@ export class CustomNodesManager {
let needRestart = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset');
await api.fetchApi('/manager/queue/reset', { method: 'POST' });
// Set install_context BEFORE per-item queue enqueue calls so that any
// server-side synchronous completion (e.g., sync enable of an inactive
// node) that emits cm-queue-status before we return here still finds
// install_context populated in onQueueCompleted. target_items is shared
// by reference so further pushes below remain visible.
let target_items = [];
this.install_context = {btn: btn, targets: target_items};
for (const hash of list) {
const item = this.grid.getRowItemBy("hash", hash);
@@ -1550,8 +1556,6 @@ export class CustomNodesManager {
}
}
this.install_context = {btn: btn, targets: target_items};
if(errorMsg) {
this.showError(errorMsg);
show_message("[Installation Errors]\n"+errorMsg);
@@ -1563,7 +1567,7 @@ export class CustomNodesManager {
}
}
else {
await api.fetchApi('/manager/queue/start');
await api.fetchApi('/manager/queue/start', { method: 'POST' });
this.showStop();
showTerminal();
}
@@ -1576,6 +1580,10 @@ export class CustomNodesManager {
const item = self.grid.getRowItemBy("hash", hash);
if (!item) {
return;
}
item.restart = true;
self.restartMap[item.hash] = true;
self.grid.updateCell(item, "action");
@@ -1583,45 +1591,81 @@ export class CustomNodesManager {
}
else if(event.detail.status == 'done') {
self.hideStop();
self.onQueueCompleted(event.detail);
// Await + error logging so any unhandled rejection surfaces to the
// console instead of silently swallowing completion finalization
// (root cause of disable/enable button staying loading with no toast).
try {
await self.onQueueCompleted(event.detail);
} catch (e) {
console.error("[ComfyUI-Manager] onQueueCompleted failed:", e);
}
}
}
async onQueueCompleted(info) {
// `nodepack_result` is a dict serialized from a Python dict, not an array.
// `dict.length` is `undefined` and `undefined == 0` is `false`, so the
// previous `result.length == 0` guard was a no-op; switch to a correct
// empty-check that also tolerates null/undefined.
let result = info.nodepack_result;
if(result.length == 0) {
if (!result || Object.keys(result).length === 0) {
return;
}
let self = CustomNodesManager.instance;
if(!self.install_context) {
if (!self || !self.install_context) {
return;
}
const { target, label, mode } = self.install_context.btn;
target.classList.remove("cn-btn-loading");
const targets = self.install_context.targets || [];
// Compute errorMsg upfront so the downstream user-visible finalization
// (showRestart / showMessage / infoToast) fires regardless of whether
// any DOM-touching step below throws.
let errorMsg = "";
for(let hash in result){
for (let hash in result) {
let v = result[hash];
if(v != 'success' && v != 'skip')
errorMsg += v+'\n';
if (v != 'success' && v != 'skip') {
errorMsg += v + '\n';
}
}
for(let k in self.install_context.targets) {
let item = self.install_context.targets[k];
self.grid.updateCell(item, "action");
// Defensive: `target` may be a detached DOM node (the in_progress
// handler's updateCell can re-render the row and replace the button
// element). classList.remove on a detached node is a no-op, but we
// still guard in case target was torn down entirely.
try {
if (target && target.classList) {
target.classList.remove("cn-btn-loading");
}
} catch (e) {
console.warn("[ComfyUI-Manager] Failed to clear button loading state:", e);
}
// Defensive: grid.updateCell can throw if the item was removed or the
// grid was re-rendered between in_progress and done. Do NOT let this
// loop abort the completion finalization below — that was the observed
// failure mode for disable/enable (no toast, no "restart required"
// message).
try {
for (let k in targets) {
let item = targets[k];
if (item) {
self.grid.updateCell(item, "action");
}
}
} catch (e) {
console.warn("[ComfyUI-Manager] Failed to refresh target cells after queue completion:", e);
}
if (errorMsg) {
self.showError(errorMsg);
show_message("Installation Error:\n"+errorMsg);
show_message("Installation Error:\n" + errorMsg);
} else {
self.showStatus(`${label} ${result.length} custom node(s) successfully`);
self.showStatus(`${label} ${Object.keys(result).length} custom node(s) successfully`);
}
self.showRestart();
+3 -3
View File
@@ -170,7 +170,7 @@ export class ModelManager {
".cmm-manager-stop": {
click: () => {
api.fetchApi('/manager/queue/reset');
api.fetchApi('/manager/queue/reset', { method: 'POST' });
infoToast('Cancel', 'Remaining tasks will stop after completing the current task.');
}
},
@@ -444,7 +444,7 @@ export class ModelManager {
let needRefresh = false;
let errorMsg = "";
await api.fetchApi('/manager/queue/reset');
await api.fetchApi('/manager/queue/reset', { method: 'POST' });
let target_items = [];
@@ -503,7 +503,7 @@ export class ModelManager {
}
}
else {
await api.fetchApi('/manager/queue/start');
await api.fetchApi('/manager/queue/start', { method: 'POST' });
this.showStop();
showTerminal();
}
+3 -3
View File
@@ -9,7 +9,7 @@ loadCss("./snapshot.css");
async function restore_snapshot(target) {
if(SnapshotManager.instance) {
try {
const response = await api.fetchApi(`/snapshot/restore?target=${target}`, { cache: "no-store" });
const response = await api.fetchApi('/snapshot/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }), cache: "no-store" });
if(response.status == 403) {
await handle403Response(response);
@@ -37,7 +37,7 @@ async function restore_snapshot(target) {
async function remove_snapshot(target) {
if(SnapshotManager.instance) {
try {
const response = await api.fetchApi(`/snapshot/remove?target=${target}`, { cache: "no-store" });
const response = await api.fetchApi('/snapshot/remove', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ target: target }), cache: "no-store" });
if(response.status == 403) {
await handle403Response(response);
@@ -63,7 +63,7 @@ async function remove_snapshot(target) {
async function save_current_snapshot() {
try {
const response = await api.fetchApi('/snapshot/save', { cache: "no-store" });
const response = await api.fetchApi('/snapshot/save', { method: 'POST', cache: "no-store" });
app.ui.dialog.close();
return true;
}