diff --git a/comfyui_manager/common/config_writer.py b/comfyui_manager/common/config_writer.py new file mode 100644 index 000000000..d811830ff --- /dev/null +++ b/comfyui_manager/common/config_writer.py @@ -0,0 +1,76 @@ +"""Shared settings-file writer for glob and legacy.""" +import configparser +import os +import tempfile + +from rich import print + + +class DirtyTrackingConfig(dict): + """Track keys assigned after loading the startup configuration.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.dirty_keys = set() + + def __setitem__(self, key, value): + self.dirty_keys.add(key) + super().__setitem__(key, value) + + def update(self, *args, **kwargs): + incoming = dict(*args, **kwargs) + self.dirty_keys.update(incoming) + super().update(incoming) + + +def write_config_merged(config_path, cached, written_config_keys): + """Merge changed owned keys onto the latest settings on disk. + + Preserve parsed keys and values, not INI comments or formatting.""" + config = configparser.ConfigParser(strict=False) + try: + loaded = config.read(config_path) + except (configparser.Error, UnicodeDecodeError) as e: + # Preserve the existing fallback for malformed files. + print(f"[ComfyUI-Manager] Warning: '{config_path}' could not be parsed ({e}); rewriting it from the current settings.") + config = configparser.ConfigParser(strict=False) + else: + # ConfigParser.read silently skips unreadable files; do not overwrite one. + if not loaded and os.path.exists(config_path): + raise OSError(f"'{config_path}' exists but could not be read; refusing to overwrite it with default settings. Fix the file's permissions, then retry.") + + if config.has_section('default'): + keys = [key for key in written_config_keys if key in cached.dirty_keys] + else: + config['default'] = {} + keys = list(written_config_keys) + + section = config['default'] + for key in keys: + section[key] = str(cached[key]).replace('\r', '').replace('\n', '').replace('\x00', '') + + directory = os.path.dirname(config_path) + if directory and not os.path.exists(directory): + os.makedirs(directory) + + # Replace from the same directory so readers never see a partial write. + tmp_path = None + try: + with tempfile.NamedTemporaryFile( + 'w', dir=directory or '.', prefix='.config-', suffix='.tmp', delete=False + ) as configfile: + tmp_path = configfile.name + config.write(configfile) + configfile.flush() + os.fsync(configfile.fileno()) + # Retain the existing mode instead of the temporary file's 0600. + if os.path.exists(config_path): + os.chmod(tmp_path, os.stat(config_path).st_mode & 0o777) + os.replace(tmp_path, config_path) + tmp_path = None + finally: + if tmp_path is not None and os.path.exists(tmp_path): + os.remove(tmp_path) + + # Clear only persisted keys, and only after a successful write. + cached.dirty_keys.difference_update(keys) diff --git a/comfyui_manager/common/security_messages.py b/comfyui_manager/common/security_messages.py new file mode 100644 index 000000000..785fad2e7 --- /dev/null +++ b/comfyui_manager/common/security_messages.py @@ -0,0 +1,7 @@ +"""Installation-denial messages shared by the glob and legacy servers.""" + +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/Comfy-Org/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_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." diff --git a/comfyui_manager/glob/constants.py b/comfyui_manager/glob/constants.py index fa3c10911..a21fc12f2 100644 --- a/comfyui_manager/glob/constants.py +++ b/comfyui_manager/glob/constants.py @@ -1,12 +1,3 @@ - -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" -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/ltdrdata/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/ltdrdata/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/ltdrdata/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." - - def is_loopback(address): import ipaddress diff --git a/comfyui_manager/glob/manager_core.py b/comfyui_manager/glob/manager_core.py index 7bf758a13..8f1b84d93 100644 --- a/comfyui_manager/glob/manager_core.py +++ b/comfyui_manager/glob/manager_core.py @@ -35,6 +35,7 @@ from packaging import version import uuid from ..common import cm_global +from ..common.config_writer import DirtyTrackingConfig, write_config_merged from ..common import cnr_utils from ..common import manager_util from ..common import git_utils @@ -1686,41 +1687,32 @@ class ManagerFuncs: manager_funcs = ManagerFuncs() +# Settings owned by this server; other keys and sections are preserved. +WRITTEN_CONFIG_KEYS = ( + 'git_exe', + 'use_uv', + 'use_unified_resolver', + 'channel_url', + 'share_option', + 'bypass_ssl', + 'file_logging', + 'update_policy', + 'windows_selector_event_loop_policy', + 'model_download_by_agent', + 'downgrade_blacklist', + 'security_level', + 'always_lazy_install', + 'network_mode', + 'db_mode', + 'verbose', + 'allow_git_url_install', + 'allow_pip_install', +) + + def write_config(): - config = configparser.ConfigParser(strict=False) - - config['default'] = { - 'git_exe': get_config()['git_exe'], - 'use_uv': get_config()['use_uv'], - 'use_unified_resolver': get_config()['use_unified_resolver'], - 'channel_url': get_config()['channel_url'], - 'share_option': get_config()['share_option'], - 'bypass_ssl': get_config()['bypass_ssl'], - "file_logging": get_config()['file_logging'], - 'update_policy': get_config()['update_policy'], - 'windows_selector_event_loop_policy': get_config()['windows_selector_event_loop_policy'], - 'model_download_by_agent': get_config()['model_download_by_agent'], - 'downgrade_blacklist': get_config()['downgrade_blacklist'], - 'security_level': get_config()['security_level'], - 'always_lazy_install': get_config()['always_lazy_install'], - 'network_mode': get_config()['network_mode'], - 'db_mode': get_config()['db_mode'], - 'verbose': get_config()['verbose'], - 'allow_git_url_install': get_config()['allow_git_url_install'], - 'allow_pip_install': get_config()['allow_pip_install'], - } - - # Sanitize all string values to prevent CRLF injection attacks - for key, value in config['default'].items(): - if isinstance(value, str): - config['default'][key] = value.replace('\r', '').replace('\n', '').replace('\x00', '') - - directory = os.path.dirname(context.manager_config_path) - if not os.path.exists(directory): - os.makedirs(directory) - - with open(context.manager_config_path, 'w') as configfile: - config.write(configfile) + """Persist changed settings through the shared writer.""" + write_config_merged(context.manager_config_path, get_config(), WRITTEN_CONFIG_KEYS) def read_config(): @@ -1796,7 +1788,8 @@ def get_config(): global cached_config if cached_config is None: - cached_config = read_config() + # Start tracking changes after the startup configuration is loaded. + cached_config = DirtyTrackingConfig(read_config()) if cached_config['http_channel_enabled']: print("[ComfyUI-Manager] Warning: http channel enabled, make sure server in secure env") diff --git a/comfyui_manager/glob/manager_server.py b/comfyui_manager/glob/manager_server.py index 6a5408746..93121901c 100644 --- a/comfyui_manager/glob/manager_server.py +++ b/comfyui_manager/glob/manager_server.py @@ -83,12 +83,14 @@ from ..data_models import ( ComfyUISwitchVersionParams, ) -from .constants import ( - model_dir_name_map, +from ..common.security_messages import ( SECURITY_MESSAGE_MIDDLE, SECURITY_MESSAGE_MIDDLE_P, SECURITY_MESSAGE_HIGH_P, ) +from .constants import ( + model_dir_name_map, +) if not manager_util.is_manager_pip_package(): network_mode_description = "offline" diff --git a/comfyui_manager/glob/utils/formatting_utils.py b/comfyui_manager/glob/utils/formatting_utils.py index 357112ebd..e4396f7a5 100644 --- a/comfyui_manager/glob/utils/formatting_utils.py +++ b/comfyui_manager/glob/utils/formatting_utils.py @@ -1,6 +1,5 @@ import locale import sys -import re def handle_stream(stream, prefix): @@ -20,41 +19,3 @@ def handle_stream(stream, prefix): print(prefix, msg, end="", file=sys.stderr) else: print(prefix, msg, end="") - - -def convert_markdown_to_html(input_text): - pattern_a = re.compile(r"\[a/([^]]+)]\(([^)]+)\)") - pattern_w = re.compile(r"\[w/([^]]+)]") - pattern_i = re.compile(r"\[i/([^]]+)]") - pattern_bold = re.compile(r"\*\*([^*]+)\*\*") - pattern_white = re.compile(r"%%([^*]+)%%") - - def replace_a(match): - return f"{match.group(1)}" - - def replace_w(match): - return f"
{match.group(1)}
" - - def replace_i(match): - return f"{match.group(1)}
" - - def replace_bold(match): - return f"{match.group(1)}" - - def replace_white(match): - return f"{match.group(1)}" - - input_text = ( - input_text.replace("\\[", "[") - .replace("\\]", "]") - .replace("<", "<") - .replace(">", ">") - ) - - result_text = re.sub(pattern_a, replace_a, input_text) - result_text = re.sub(pattern_w, replace_w, result_text) - result_text = re.sub(pattern_i, replace_i, result_text) - result_text = re.sub(pattern_bold, replace_bold, result_text) - result_text = re.sub(pattern_white, replace_white, result_text) - - return result_text.replace("\n", "allow_pip_install = true in the [default] section of config.ini. This setting is independent of security_level.network_mode = personal_cloud is also required.");
+ show_message(await install_denial_message(res, 'allow_pip_install'));
return;
}
@@ -251,7 +251,7 @@ export async function install_via_git_url(url, manager_dialog) {
});
if(res.status == 403) {
- show_message("To use this feature, set allow_git_url_install = true in the [default] section of config.ini. This setting is independent of security_level.network_mode = personal_cloud is also required.");
+ show_message(await install_denial_message(res, 'allow_git_url_install'));
return;
}
@@ -659,4 +659,54 @@ function initTooltip () {
document.body.addEventListener('mouseleave', mouseleaveHandler, true);
}
-initTooltip();
\ No newline at end of file
+initTooltip();
+// Installation flags and network mode take effect after a restart.
+const INSTALL_DENIAL_RESTART_NOTE = "allow_git_url_install = true in the [default] section of config.ini. This setting is independent of security_level.network_mode = personal_cloud is also required." + INSTALL_DENIAL_RESTART_NOTE,
+ allow_pip_install: "To use this feature, set allow_pip_install = true in the [default] section of config.ini. This setting is independent of security_level.network_mode = personal_cloud is also required." + INSTALL_DENIAL_RESTART_NOTE
+};
+
+// Last resort when no entry above applies. Names no flag on purpose.
+const INSTALL_DENIAL_UNKNOWN_REASON = "This action was refused by the server, and the client could not determine which setting caused it. The server terminal log states the exact condition. Please contact the administrator.";
+
+// Use the endpoint's flag when the 403 response body is missing or malformed.
+export async function install_denial_message(res, fallbackReason) {
+ let reason = fallbackReason;
+ try {
+ const data = await res.json();
+ if (typeof data === 'object' && data !== null && typeof data.reason === 'string'
+ && Object.prototype.hasOwnProperty.call(INSTALL_DENIAL_MESSAGES, data.reason)) {
+ reason = data.reason;
+ }
+ }
+ catch {
+ // absent / non-JSON body: keep the fallback
+ }
+ return INSTALL_DENIAL_MESSAGES[reason] ?? INSTALL_DENIAL_UNKNOWN_REASON;
+}
+
+/**
+ * Scheme allow-list for registry-supplied URLs that land in an href.
+ * Returns the URL if it is http(s) or relative, otherwise "". Never throws.
+ */
+export function sanitizeUrl(url) {
+ const raw = String(url ?? '').trim();
+ if (!raw) {
+ return '';
+ }
+ let parsed;
+ try {
+ parsed = new URL(raw);
+ }
+ catch {
+ // not absolute: keep a scheme-less relative path, drop anything else
+ return /^[a-z][a-z0-9+.\-]*:/i.test(raw) ? '' : raw;
+ }
+ // URL parsing already normalised the scheme (`java\tscript:` collapses).
+ if (parsed.protocol === 'http:' || parsed.protocol === 'https:') {
+ return raw;
+ }
+ return '';
+}
diff --git a/comfyui_manager/js/custom-nodes-manager.js b/comfyui_manager/js/custom-nodes-manager.js
index 7d587a00f..2ef8ff510 100644
--- a/comfyui_manager/js/custom-nodes-manager.js
+++ b/comfyui_manager/js/custom-nodes-manager.js
@@ -8,11 +8,13 @@ import {
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
- showPopover, hidePopover, generateUUID
+ showPopover, hidePopover, generateUUID, sanitizeUrl
} from "./common.js";
+// Registry titles, names and descriptions are server-escaped; other fields are raw.
+
// https://cenfun.github.io/turbogrid/api.html
-import TG from "./turbogrid.esm.js";
+import ManagerGrid from "./manager-grid.js";
loadCss("./custom-nodes-manager.css");
@@ -363,19 +365,20 @@ export class CustomNodesManager {
installGroups.enabled = installGroups.enabled.filter(it => it !== "disable" && it !== "uninstall" && it !== "switch");
}
- let list = installGroups[action];
+ const list = installGroups[action];
- if(is_selected_button || rowItem?.version === "unknown") {
- list = list.filter(it => it !== "switch");
- }
-
- if (!list) {
+ if (!Array.isArray(list)) {
return "";
}
- return list.map(id => {
+ const shown = (is_selected_button || rowItem?.version === "unknown")
+ ? list.filter(it => it !== "switch")
+ : list;
+
+ return shown.map(id => {
const bt = buttons[id];
- return ``;
+ // `action` is registry-derived and lands in an attribute.
+ return ``;
}).join("");
}
@@ -518,7 +521,7 @@ export class CustomNodesManager {
initGrid() {
const container = this.element.querySelector(".cn-manager-grid");
- const grid = new TG.Grid(container);
+ const grid = new ManagerGrid(container);
this.grid = grid;
this.flyover = this.createFlyover(container);
@@ -579,6 +582,9 @@ export class CustomNodesManager {
grid.setOption({
+ highlightKeywords: {
+ textGenerator: (row, column) => column === 'author' ? sanitizeHTML(String(row[column] ?? '')) : row[column]
+ },
theme: 'dark',
selectVisible: true,
selectMultiple: true,
@@ -646,7 +652,8 @@ export class CustomNodesManager {
let res = await response.json();
- let title = `Error message occurred while importing the '${rowItem.title}' module.{match.group(1)}
" @@ -995,20 +1004,35 @@ def convert_markdown_to_html(input_text): def replace_white(match): return f"{match.group(1)}" - input_text = input_text.replace('\\[', '[').replace('\\]', ']').replace('<', '<').replace('>', '>') + # NUL dropped first so the input cannot forge the href placeholders. + input_text = input_text.replace('\x00', '') + input_text = input_text.replace('\\[', '[').replace('\\]', ']') - result_text = re.sub(pattern_a, replace_a, input_text) + # Parse links before escaping prose, so generated text entities never enter URLs. + parts = [] + start = 0 + for match in pattern_a.finditer(input_text): + parts.append(manager_util.sanitize_tag(input_text[start:match.start()])) + parts.append(replace_a(match)) + start = match.end() + parts.append(manager_util.sanitize_tag(input_text[start:])) + result_text = ''.join(parts) result_text = re.sub(pattern_w, replace_w, result_text) result_text = re.sub(pattern_i, replace_i, result_text) result_text = re.sub(pattern_bold, replace_bold, result_text) result_text = re.sub(pattern_white, replace_white, result_text) + result_text = result_text.replace("\n", "
& after'
+ result = page.evaluate("""({text, keyword}) => {
+ const cell = document.createElement('div');
+ cell.id = 'cell';
+ cell.textContent = text;
+ document.body.append(cell);
+ grid.highlightKeywordsSync([cell], [keyword]);
+ return {text: cell.textContent, marks: [...cell.querySelectorAll('mark')].map(x => x.textContent),
+ images: cell.querySelectorAll('img').length};
+ }""", {"text": text, "keyword": keyword})
+ assert result == {"text": text, "marks": [keyword], "images": 0}
+ assert page.evaluate("window.__probe === undefined")
+
+
+def test_keyword_order_continues_across_text_nodes(page):
+ result = page.evaluate("""() => {
+ const cell = document.createElement('div');
+ cell.innerHTML = 'Alpha beta Alpha beta ';
+ grid.highlightKeywordsSync([cell], ['alpha', 'beta']);
+ return [...cell.querySelectorAll('mark')].map(x => x.textContent);
+ }""")
+ assert result == ["Alpha", "beta", "Alpha", "beta"]
+
+
+def test_search_cache_does_not_execute_html(page):
+ result = page.evaluate("""() => {
+ const row = {description: '
Alpha & Beta'};
+ const matched = grid.highlightKeywordsFilter(row, ['description'], 'alpha & beta');
+ return {matched, text: row.tg_text_description, highlight: row.tg_highlight_description};
+ }""")
+ assert result == {"matched": True, "text": "Alpha & Beta", "highlight": True}
+ page.wait_for_timeout(50)
+ assert page.evaluate("window.__probe === undefined")
+
+
+def test_search_preserves_order_columns_and_empty_query(page):
+ result = page.evaluate("""() => {
+ const row = {name: 'Alpha Beta', description: 'Gamma', missing: null, count: 0};
+ const columns = ['name', 'description', 'missing', 'count'];
+ const ordered = grid.highlightKeywordsFilter(row, columns, ' ALPHA beta ');
+ const reversed = grid.highlightKeywordsFilter(row, columns, 'beta alpha');
+ const acrossColumns = grid.highlightKeywordsFilter(row, columns, 'alpha gamma');
+ const zero = grid.highlightKeywordsFilter(row, columns, '0');
+ const empty = grid.highlightKeywordsFilter(row, columns, '');
+ return {ordered, reversed, acrossColumns, zero, empty,
+ flags: columns.map(column => row['tg_highlight_' + column])};
+ }""")
+ assert result == {"ordered": True, "reversed": False, "acrossColumns": False,
+ "zero": True, "empty": True, "flags": [None] * 4}
+
+
+def test_custom_text_generator_and_cache_keys(page):
+ result = page.evaluate("""() => {
+ Object.assign(grid.options.highlightKeywords, {
+ textKey: 'text_', highlightKey: 'mark_',
+ textGenerator: (row, column) => row[column].label
+ });
+ const row = {name: {label: 'Model <Flux>'}};
+ const matched = grid.highlightKeywordsFilter(row, ['name'], 'model x
', + 'ab
') == "ab
" + + +def test_formatting_markup_survives(): + src = 'See docs and bold
line
1 < 2
') == '1 < 2
' + + +def test_unknown_harmless_tag_keeps_content(): + assert sanitize('