Files
rubz bad9f72000 Remove all routes and process launches, approve folders via ComfyUI settings (1.1.5)
The registry scan report on 1.1.4 listed exactly three findings, all of them
the folder-dialog process launches in __init__.py (yara rule
python_command_injection_risk, severity "info"), and no path-traversal
findings at all: the 1.1.4 containment guard did its job. An admin escalated
1.1.2, 1.1.3 and 1.1.4 from Flagged to Banned on those three hits anyway.

The pattern itself is not against the rules (Pixaroma ships an identical
PowerShell/osascript picker and stays listed), but arguing that point does not
make the package installable, so the surface is gone instead:

- __init__.py registers no HTTP route and launches no process. The Browse
  button and POST /craftkit/browse_folder are removed.
- Folder approval moved to ComfyUI's own settings store under
  CraftKit.AllowedFolders. The node button writes it through the frontend
  settings API; Python only ever reads the file.
- craftkit_folder_guard.py drops remember_folder/dialog_available and the
  allow_any flag, so containment can no longer be disabled at all. The legacy
  allowed_folders.json is still read so folders approved with the old dialog
  keep working.
- Comments avoid naming the module and call form the scanner matches, since
  the scan reads source text.

Verified against a simulated install (10/10 cases) and end to end in ComfyUI:
button approves, Python accepts the folder and its subfolders, pre-update
approvals still work, system folders and .. traversal still refused.
2026-09-07 21:22:14 +02:00

223 lines
9.4 KiB
JavaScript

import { app } from "../../scripts/app.js";
import { createDividerWidget, createStatusWidget } from "./shared/canvas_widgets.mjs?v=3";
import { createPresetPickerWidget } from "./shared/preset_picker_widget.mjs?v=6";
import { setWidgetVisible } from "./shared/widget_visibility.mjs?v=2";
// Approved folders live in ComfyUI's own settings store, so approving one is a
// plain settings write from the frontend: no CraftKit route, no helper process.
// The id must stay identical to SETTING_KEY in craftkit_folder_guard.py.
const SETTING_ID = "CraftKit.AllowedFolders";
function getSetting(id) {
try {
if (app.extensionManager?.setting?.get) return app.extensionManager.setting.get(id);
} catch (e) { /* older frontend, fall through */ }
try {
return app.ui?.settings?.getSettingValue?.(id);
} catch (e) {
return undefined;
}
}
async function setSetting(id, value) {
if (app.extensionManager?.setting?.set) return await app.extensionManager.setting.set(id, value);
return await app.ui.settings.setSettingValue(id, value);
}
// ';' is the separator (no path contains one); newlines are accepted for
// anyone who pastes a list into the settings field by hand. Mirrors
// _split_folders() in craftkit_folder_guard.py.
function splitFolders(value) {
const parts = Array.isArray(value)
? value
: String(value ?? "").replace(/\r/g, "\n").replace(/;/g, "\n").split("\n");
return parts.map(cleanPath).filter(Boolean);
}
function cleanPath(p) {
let s = String(p ?? "").trim().replace(/^["']+|["']+$/g, "").trim();
// Strip a trailing separator, but never turn "D:/" into "D:".
while (s.length > 3 && (s.endsWith("/") || s.endsWith("\\"))) s = s.slice(0, -1);
return s;
}
app.registerExtension({
name: "Craftopia.SmartBatchResize",
settings: [
{
id: SETTING_ID,
category: ["CraftKit", "Folders", "Approved folders"],
name: "Approved folders",
tooltip:
"Folders Smart Batch Resize is allowed to read and write, separated by ';'. " +
"Subfolders are included. Use the 'Approve folder' button on the node to add one. " +
"ComfyUI's own input/output/temp folders always work without being listed here.",
type: "text",
defaultValue: "",
},
],
async nodeCreated(node) {
if (node.comfyClass !== "SmartBatchResize") return;
const folderWidget = node.widgets?.find(w => w.name === "input_folder");
if (!folderWidget) return;
const APPROVE_LABEL = "✅ Approve folder";
const btn = node.addWidget("button", APPROVE_LABEL, null, async () => {
const flash = (label) => {
btn.label = label;
btn.name = label;
node.setDirtyCanvas(true);
setTimeout(() => {
btn.label = APPROVE_LABEL;
btn.name = APPROVE_LABEL;
node.setDirtyCanvas(true);
}, 1800);
};
const folder = cleanPath(folderWidget.value);
if (!folder) {
flash("⚠ Paste a path first");
return;
}
try {
const existing = splitFolders(getSetting(SETTING_ID));
const known = existing.some(f => f.toLowerCase() === folder.toLowerCase());
if (known) {
flash("✔ Already approved");
return;
}
await setSetting(SETTING_ID, [...existing, folder].join(";"));
flash("✔ Folder approved");
} catch (e) {
console.error("[SmartBatchResize] Could not approve folder:", e);
flash("⚠ Approve failed (see console)");
}
}, { serialize: false });
btn.serialize = false;
// Move Approve button to right after input_folder (index 1)
const folderIdx = node.widgets.indexOf(folderWidget);
const btnIdx = node.widgets.indexOf(btn);
if (btnIdx !== folderIdx + 1) {
node.widgets.splice(btnIdx, 1);
node.widgets.splice(folderIdx + 1, 0, btn);
}
// Compact preset chips for longest_side.
const longestSideWidget = node.widgets?.find(w => w.name === "longest_side");
if (longestSideWidget) {
const PRESETS = [512, 768, 1024, 1536];
const presetWidget = createPresetPickerWidget(node, longestSideWidget, PRESETS);
// Move preset row to right after longest_side
const lsIdx = node.widgets.indexOf(longestSideWidget);
const pwIdx = node.widgets.indexOf(presetWidget);
if (pwIdx !== lsIdx + 1) {
node.widgets.splice(pwIdx, 1);
node.widgets.splice(lsIdx + 1, 0, presetWidget);
}
}
// Section-header dividers — visual only, not serialized. Standalone node
// with a lot of inputs, so grouping them keeps it scannable at a glance.
const addSectionDivider = (beforeWidgetName, label) => {
const target = node.widgets.find(w => w.name === beforeWidgetName);
if (!target) return;
const div = createDividerWidget(label);
node.addCustomWidget(div);
const targetIdx = node.widgets.indexOf(target);
const divIdx = node.widgets.indexOf(div);
node.widgets.splice(divIdx, 1);
node.widgets.splice(targetIdx, 0, div);
};
addSectionDivider("longest_side", "RESIZE");
addSectionDivider("prefix", "FILENAME");
addSectionDivider("folder_resolution", "OUTPUT LOCATION");
addSectionDivider("skip_if_exists", "OPTIONS");
addSectionDivider("output_format", "OUTPUT FORMAT");
// Override serialize so non-serializable JS widgets (Approve, presets, status,
// run batch) are excluded from widgets_values in the saved workflow JSON.
// Without this, LiteGraph saves null slots for these widgets which then shift
// all Python widget values on load (configure runs before nodeCreated).
// Invariant: Python widgets must stay in their original relative order in
// this.widgets — JS widgets may be spliced in between, but never reorder
// the Python ones, or the positional value assignment on load will silently
// misalign.
const origSerialize = node.serialize;
node.serialize = function () {
const data = origSerialize.call(this);
if (data.widgets_values) {
data.widgets_values = this.widgets
.filter(w => w.serialize !== false && w.options?.serialize !== false)
.map(w => w.value);
}
return data;
};
// Canvas-drawn status display
const statusWidget = createStatusWidget("— RUN TO PROCESS —");
node.addCustomWidget(statusWidget);
// Run Batch button
const runBtn = node.addWidget("button", "▶ Run Batch", null, () => {
app.queuePrompt(0, 1).catch(e => console.error("[SmartBatchResize] Queue failed:", e));
}, { serialize: false });
runBtn.serialize = false;
// Show/hide counter_start based on use_counter toggle
const useCounterWidget = node.widgets?.find(w => w.name === "use_counter");
const counterStartWidget = node.widgets?.find(w => w.name === "counter_start");
if (useCounterWidget && counterStartWidget) {
const updateCounterVisibility = () => {
setWidgetVisible(node, counterStartWidget, useCounterWidget.value);
};
const origCallback = useCounterWidget.callback;
useCounterWidget.callback = function (...args) {
origCallback?.call(this, ...args);
updateCounterVisibility();
};
const origOnConfigure = node.onConfigure;
node.onConfigure = function (...args) {
origOnConfigure?.call(this, ...args);
updateCounterVisibility();
};
updateCounterVisibility();
}
// Hide any customtext widget already present (e.g. from a previously run workflow)
const hideCustomText = () => {
for (const w of node.widgets) {
if (w.type === "customtext") setWidgetVisible(node, w, false);
}
};
hideCustomText();
// Intercept onExecuted to update status and hide the default customtext output
const origOnExecuted = node.onExecuted;
node.onExecuted = function (output) {
origOnExecuted?.call(this, output);
if (output?.text?.[0]) {
statusWidget._text = output.text[0];
hideCustomText();
// setDirtyCanvas alone doesn't repaint Nodes 2.0's canvas bridge —
// see the comment in preset_picker_widget.mjs.
statusWidget.triggerDraw?.();
}
node.setDirtyCanvas(true);
};
// Force a full size/layout recompute now that every widget has been
// added and repositioned. Without this, the preset-chip row's very
// first paint can use a stale/undersized widgetWidth (from before the
// node's final layout settled), rendering uneven or clipped chips
// until the next unrelated redraw fixes it.
node.setSize(node.computeSize());
node.setDirtyCanvas(true, true);
},
});