Compare commits

...
Author SHA1 Message Date
Dr.Lt.Data 946ef8fe65 bump version to 3.42 2026-09-19 08:38:42 +09:00
Dr.Lt.Data 572f363696 v3: Prevent HTML and URL injection in the Manager UI (#3296) 2026-09-19 08:22:26 +09:00
Dr.Lt.Data ea7534fae8 v3: Restrict Flagged CNR installs on non-local listeners (#3298) 2026-09-19 07:47:20 +09:00
Dr.Lt.Data 21ab2b78c2 update DB 2026-09-15 23:48:43 +09:00
Dr.Lt.Data 97f123ef44 fix(custom-node-list): relocate merged git-clone entries above copy/unzip zone (Expert-Wildcard, PreFlight, LoadImageCrop, LoadVideoCrop; author-adjacent for existing authors) + node_db/new sync 2026-09-13 20:01:25 +09:00
Dr.Lt.Data fe43918863 Merge pull request #3271 from MajoorWaldi/feat/add-majoor-h3-guidemaster
Add Majoor H3 GuideMaster to custom-node-list.json
2026-09-13 19:55:02 +09:00
Dr.Lt.Data b4eebf2b8e Merge pull request #3267 from 0xBeycan/main
added PreFlight
2026-09-13 19:54:56 +09:00
Dr.Lt.Data 41600906a5 Merge pull request #3265 from kaan-trusapien/add-comfyui-trusapien
Add ComfyUI-Trusapien custom node entry
2026-09-13 19:54:50 +09:00
Dr.Lt.Data 87258e4465 Merge pull request #3240 from v3rm1ll1on/main
feat(custom-node-list): add ComfyUI Expert Wildcard Prompt
2026-09-13 19:54:35 +09:00
Dr.Lt.Data eed2d54b8f update DB 2026-09-13 18:50:58 +09:00
Ewald c8fb99ff5f Add Majoor H3 GuideMaster to custom-node-list.json
Registers ComfyUI-Majoor-H3-GuideMaster (comfyui-majoor-h3-guidemaster on
the Comfy Registry) so it appears in the Manager install dialog.
2026-09-11 16:52:23 +02:00
BeycanDeveloper 53daaef541 added PreFlight 2026-09-11 13:38:49 +08:00
Dr.Lt.Data 9d4cceea13 feat(custom-node-list): relocate MSCH Nodes (#3247) above copy/unzip zone + node_db/new sync 2026-09-11 07:42:42 +09:00
Dr.Lt.Data 1c8a34d1a7 Merge pull request #3247 from mariobilly/register-msch-nodes
Add MSCH Nodes as one unified custom-node pack
2026-09-11 07:41:39 +09:00
Dr.Lt.Data d0fd6da37d feat(custom-node-list): relocate Autocomplete++ (#3241) above copy/unzip zone + node_db/new sync (Autocomplete++, Perfect Video Resolution) 2026-09-11 07:41:06 +09:00
Dr.Lt.Data 9ef6d2ec83 Merge pull request #3241 from michikora/add-node
Add Autocomplete++ to custom node list
2026-09-11 07:39:09 +09:00
Dr.Lt.Data b62e30f2d0 Merge pull request #3251 from huchukato/update-huchukato-nodes
Add PerfectVideoResolution node, rename TagComplete to TagForge, and align entries with Comfy Registry
2026-09-11 07:37:49 +09:00
kaan-trusapien 1048c958b9 Add ComfyUI-Trusapien custom node entry
### Custom Node Details

* **Title**: ComfyUI-Trusapien
* **Repository**: https://github.com/trusapien/ComfyUI-Trusapien
* **Author**: trusapien
* **Install Type**: `git-clone`
* **Description**: Enterprise creative validation, consumer-neuroscience attention heatmaps, and campaign impact scoring for generated creative assets.

### Verification Checklist

- [x] Node repository contains `__init__.py` with valid `NODE_CLASS_MAPPINGS`.
- [x] Dependencies listed in `requirements.txt`.
- [x] Tested locally on ComfyUI Desktop / standard installation.
- [x] Open-source MIT License included.
2026-09-10 17:58:15 +03:00
Dr.Lt.Data 6c120969f1 fix(custom-node-list): dedup ComfyUI-FEnodes (#3248) — remove merge-inserted duplicate at array head, keep single entry at author position with the PR's description 2026-09-10 22:10:38 +09:00
Dr.Lt.Data 32fed18c89 Merge pull request #3248 from FugitiveExpert01/add-comfyui-fenodes
Register ComfyUI-FEnodes custom node
2026-09-10 22:09:07 +09:00
Dr.Lt.Data 6833ab2b6e feat(custom-node-list): register 5 verified-safe nodepacks (DiffSynth-ComfyUI, Smooth Organizer, Prompt-Weaver, CFG-Sculptor, MiniMax H3 Novel Pipeline) + relocate DiffSynth above copy/unzip zone + node_db/new sync 2026-09-10 18:28:53 +09:00
Dr.Lt.Data 4064519adc Merge pull request #3253 from yjy415/plugin
Add: custom nodes of DiffSynth-Studio
2026-09-10 18:23:53 +09:00
Dr.Lt.Data d972e92c60 update DB 2026-09-10 18:21:35 +09:00
huchukato 85124357c7 Fix PerfectVideoResolution description: list only implemented models (WAN 2.2, MiniMax H3, LTX) 2026-09-08 10:51:44 +02:00
yjy415 f6d38f4ce8 add: custom nodes of DiffSynth-Studio 2026-09-08 16:41:23 +08:00
huchukato 53cd0381b7 Update huchukato nodes: align with CNR registry, rename TagComplete to TagForge, add PerfectVideoResolution 2026-09-08 00:11:35 +02:00
FugitiveExpert01 d000c1173a Register ComfyUI-FEnodes custom node 2026-09-07 12:08:04 +02:00
mariobilly 9c2ceda51f Keep existing Manager entries byte-for-byte unchanged 2026-09-06 19:14:04 +03:00
mariobilly 05b84d270c Preserve unrelated Manager database formatting 2026-09-06 19:11:34 +03:00
mariobilly 8f91f5791f Replace separate MSCH entries with one unified node pack 2026-09-06 19:06:32 +03:00
mariobilly e704f26391 Add MSCH custom node packages 2026-09-06 16:08:22 +03:00
michikora 8c3a210c91 chore: add autocomplete++ 2026-09-04 21:29:28 +09:00
Dr.Lt.Data f82970b7cb feat(custom-node-list): register 5 verified-safe nodepacks (WeeLLM, Meta SAM-Audio, PixelPanda, Free Model and Node Cache, ComfyUI-Auth) + node_db/new sync 2026-09-04 19:41:22 +09:00
Dr.Lt.Data db65fda766 update DB 2026-09-04 19:23:41 +09:00
Dr.Lt.Data 6e7fdb7802 fix(custom-node-list): place BatchPromptLoader entry in git-clone body above copy/unzip zone + node_db/new sync 2026-09-04 19:08:43 +09:00
Dr.Lt.Data e252bb97b8 Merge branch 'main' of github.com:Comfy-Org/ComfyUI-Manager 2026-09-04 19:07:12 +09:00
Dr.Lt.Data 896151d8dd Merge pull request #3183 from ReSerendipity/add-batchpromptloader
Add ComfyUI-BatchPromptLoader to custom node list
2026-09-04 19:07:08 +09:00
Dr.Lt.Data ee63fdc136 fix(custom-node-list): place SSG Smart Suite entry in git-clone body above copy/unzip zone + node_db/new sync 2026-09-04 19:06:56 +09:00
Dr.Lt.Data a62c4e561b Merge pull request #3233 from SgtSauv/Add-ComfyUI-SSG-Smart_Suite
Add SSG Smart Suite to custom-node-list
2026-09-04 19:04:03 +09:00
V3rm1ll1on 4caa942c5f feat(custom-node-list): add ComfyUI Expert Wildcard Prompt 2026-09-04 08:40:08 +02:00
SgtSauv f9b9d846ff Add SSG Smart Suite to custom-node-list 2026-09-01 20:36:57 -04:00
Dr.Lt.Data b75fc664ec feat(custom-node-list): register 5 verified-safe nodepacks + relocate H3 Prompt IDE
Register artttaku/ComfyUI-BatchVideoLoader (#3179),
rattskr/ComfyUI_CivitaiAdvanceLoader (#3218),
Winlensky/comfyui_compvis_ldm (#3229) and two entropicnoise MiniMax-H3
packs (#3185, #3202) at the end of the git-clone body, above the
copy/unzip tail zone. Relocate the already-merged ethanfel H3 Prompt IDE
(#3186) from array head to the ethanfel author group. Sync all six into
node_db/new. All six passed verify-security (SAFE), locale (English UI),
and quality gates.
2026-09-01 21:39:04 +09:00
Dr.Lt.Data 3b165d265f fix(custom-node-list): relocate 600 mis-appended git-clone entries above the copy/unzip tail zone
The array tail is the copy/unzip special zone; over many sessions new
git-clone entries were wrongly appended after it, burying the zone under
~600 entries. Re-place each into the git-clone body (author-adjacent for
existing authors, grouped-at-body-end for new authors) so the array ends
with the copy/unzip zone again. Pure reorder: entry count, reference set,
and per-entry formatting all preserved.
2026-09-01 21:36:46 +09:00
Dr.Lt.Data a73048da1e Merge pull request #3186 from ethanfel/register-h3-prompt-ide
Add H3 Prompt IDE
2026-09-01 21:13:32 +09:00
Dr.Lt.Data f6dd36ed9e fix(custom-node-list): adopt canonical title/description for Newaiguy/ComfyUI-h3_sage_amd (#3144)
Base carried the scanner-abbreviated title 'h3_sage_amd' and a one-line
description; replace with the author's canonical title and description
from PR #3144 in both the root list and node_db/new.
2026-09-01 21:09:01 +09:00
Dr.Lt.Data d81cc6bf0d Merge pull request #3214 from Zoltar358-ComfyUI/update-universal-model-loader-107
Update Universal Model Loader listing for metadata hotfix
2026-09-01 21:06:24 +09:00
Dr.Lt.Data 36b878b192 update DB 2026-09-01 19:47:40 +09:00
Dariusz Dębicki fc052d4a5f Update Universal Model Loader 1.0.8 listing 2026-08-26 19:11:46 +02:00
Dariusz Dębicki 5feefbaa21 Update Universal Model Loader metadata fix listing 2026-08-26 17:50:28 +02:00
ethanfel 40d96759f1 Add H3 Prompt IDE 2026-08-22 11:43:04 +02:00
ReSerendipity 8d1d221aa7 Add ComfyUI-BatchPromptLoader to custom node list 2026-08-21 19:58:54 +08:00
Dr.Lt.Data f39cbd56fe fix(custom-node-list): demote mickmumpitz/ComfyUI-SplatKit from default to dev channel (security hold)
Post-registration full re-verify (§14.7) found an unauthenticated CSRF-reachable
arbitrary-directory delete: DatasetProject.make(reset=True) runs shutil.rmtree on an
uncontained os.path.join(output_dir, dataset_name) (nodes/common.py:169-173) — an
absolute or ..-containing name escapes the output dir. Removed from default + node_db/new,
added to node_db/dev with [SECURITY ISSUE] suffix (reversible install-block, §4.7-H mechanic).
Restore to default on author fix (realpath+commonpath containment) + re-verify pass.
2026-08-19 08:43:25 +09:00
Dr.Lt.Data d243fda858 update DB 2026-08-19 08:29:05 +09:00
Dr.Lt.Data abaad8e28b fix(custom-node-list): register ketle-man/comfyui-chat-te (Chat TE, #3177) after last git-clone block + node_db/new sync
PR conflicted on the list tail; manually registered. Deep-verify SAFE (unauth routes are inference/model-load only, no side-effect sink), Non-English PASS.
2026-08-19 07:27:17 +09:00
Dr.Lt.Data d5992a117e update DB 2026-08-18 12:49:34 +09:00
Dr.Lt.Data 4f56cf3dfa Merge pull request #3163 from Zoltar358-ComfyUI/update-universal-model-loader-106
Update Universal Model Loader listing for MiniMax Music3
2026-08-15 13:28:36 +09:00
Dr.Lt.Data 8970a942a8 fix(custom-node-list): register DenRakEiw/ComfyGotchi (#3166) after last git-clone block + node_db/new sync
PR conflicted on the list tail; manually registered. Weak sink (fixed-path
game-state only, no data-loss), SAFE per deep-verify.
2026-08-15 13:22:24 +09:00
Dr.Lt.Data 1b765ac340 fix(custom-node-list): register H3-wave nodepacks after last git-clone block + node_db/new sync
Relocate ANe5s/ComfyUI-MiniMax-H3-Hybrid (#3154) from array head to tail.
Manually register Flow-Wrangler (#3142), minimax-h3-latent (#3151),
buqi-minimax-h3-multigpu (#3158), H3-Continuum (#3159) — their PRs
conflicted on the list tail after the batch merges. buqi-minimax-h3-multigpu
description trimmed to English-only (author's zh restatement dropped, §4.7-D desc policy).
Sync all 7 into node_db/new (newest-first).
2026-08-15 13:06:57 +09:00
Dr.Lt.Data fe4d5d434c Merge pull request #3154 from ANe5s/codex/add-minimax-h3-hybrid
Add ComfyUI MiniMax H3 Hybrid
2026-08-15 13:00:05 +09:00
Dr.Lt.Data 67ec5f5c36 Merge pull request #3152 from CocyNoric/main
Add ComfyUI-ToriiGate-Reforged
2026-08-15 12:59:58 +09:00
Dr.Lt.Data d7c376bb88 Merge pull request #3162 from mickmumpitz/main
Add ComfyUI-SplatKit
2026-08-15 12:59:48 +09:00
Dariusz Dębicki 8950d89648 Update Universal Model Loader listing for MiniMax Music3 2026-08-14 16:47:58 +02:00
mickmumpitz 00c273dbdc Add ComfyUI-SplatKit 2026-08-14 11:59:30 +02:00
{name} 8c46ea2921 Add MiniMax H3 Hybrid node 2026-08-12 20:55:48 +08:00
CauchyInno ae31058747 Add ComfyUI-ToriiGate-Reforged 2026-08-12 13:44:59 +08:00
46 changed files with 22997 additions and 12026 deletions
+3 -1
View File
@@ -16,5 +16,7 @@ comfyworkflows_sharekey
github-stats-cache.json
pip_overrides.json
*.json
# Track JSON test fixtures.
!tests/cases/*.json
check2.sh
/venv/
/venv/
+45
View File
@@ -458,6 +458,51 @@ Changes take effect after a **restart** (no hot reload).
> outdated ComfyUI deployments.
### Flagged CNR versions: `allow_flagged_nodepack_install`
Manager checks the version status in the existing CNR install response; no
additional Registry request is needed. Active versions continue to install
normally, subject to the existing security policy. Registry refusals (including
HTTP 404) still prevent installation.
Flagged versions must also satisfy the existing security policy and Registry
checks. In addition, they require either **all** `--listen` addresses to be
loopback, such as `127.0.0.1` or `::1`, or the explicit override below.
A mixed loopback/non-loopback listener is non-local.
For a **trusted private network**, administrators can explicitly allow flagged
versions on non-loopback listeners:
```ini
[default]
allow_flagged_nodepack_install = true
```
The default is `false`; only the case-insensitive value `true` enables it.
Restart ComfyUI after editing the setting. This option does not detect private
networks and is independent of `network_mode`. It does not bypass
`security_level`, `allow_git_url_install`, or `allow_pip_install`.
When the flagged-version check denies an action, the UI reports that the current
security configuration does not allow it and directs users to the terminal.
Only the terminal provides the loopback-only `--listen` examples, the private
network override setting, and the instruction to restart ComfyUI after changes.
Enabling an already installed version does not query the Registry or reinstall it.
Older deferred switches without a stored status require loopback-only listeners
or the override. Otherwise they are rejected without changing the installed pack;
request the installation again so Manager can check its current Registry status.
The check applies to server-initiated installation, reinstallation, version switches, updates,
and snapshot restores. A policy refusal preserves the existing nodepack.
Scheduled version switches save the returned status and check the current
listener and setting again at startup, before downloading or running scripts.
Server-scheduled snapshot restores inherit the permission computed from the
server's current listener and setting. Standalone `cm-cli` commands remain local
administrator operations and do not use the server listener policy.
Saved statuses are not refreshed from the Registry.
# Disclaimer
* This extension simply provides the convenience of installing custom nodes and does not guarantee their proper functioning.
+1214 -709
View File
File diff suppressed because it is too large Load Diff
+2533 -611
View File
File diff suppressed because it is too large Load Diff
+8444 -8144
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -129,6 +129,7 @@ class NodeVersion:
id: str
version: str
download_url: str
status: str = ''
def map_node_version(api_node_version):
@@ -160,6 +161,7 @@ def map_node_version(api_node_version):
download_url=api_node_version.get(
"downloadUrl", ""
), # Provide a default value if 'downloadUrl' is missing
status=api_node_version.get('status', ''),
)
@@ -181,7 +183,6 @@ def install_node(node_id, version=None):
response = requests.get(url, verify=not manager_util.bypass_ssl)
if response.status_code == 200:
# Convert the API response to a NodeVersion object
return map_node_version(response.json())
else:
return None
+139 -69
View File
@@ -44,7 +44,7 @@ import manager_migration
from node_package import InstalledNodePackage
version_code = [3, 41]
version_code = [3, 42]
version_str = f"V{version_code[0]}.{version_code[1]}" + (f'.{version_code[2]}' if len(version_code) > 2 else '')
@@ -953,10 +953,10 @@ class UnifiedManager:
return res
def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps):
def reserve_cnr_switch(self, target, zip_url, from_path, to_path, no_deps, status=''):
script_path = os.path.join(manager_startup_script_path, "install-scripts.txt")
with open(script_path, "a") as file:
obj = [target, "#LAZY-CNR-SWITCH-SCRIPT", zip_url, from_path, to_path, no_deps, get_default_custom_nodes_path(), sys.executable]
obj = [target, "#LAZY-CNR-SWITCH-SCRIPT", zip_url, from_path, to_path, no_deps, get_default_custom_nodes_path(), sys.executable, status]
file.write(f"{obj}\n")
print(f"Installation reserved: {target}")
@@ -982,35 +982,45 @@ class UnifiedManager:
return result
def _get_cnr_install_info(self, node_id, version_spec):
node_info = cnr_utils.install_node(node_id, version_spec)
if node_info is not None and node_info.status == 'NodeVersionStatusFlagged' and not manager_funcs.is_flagged_install_allowed():
logging.error(manager_util.FLAGGED_NODEPACK_INSTALL_GUIDANCE)
raise PermissionError(manager_util.FLAGGED_NODEPACK_INSTALL_ERROR)
return node_info
def cnr_switch_version(self, node_id, version_spec=None, instant_execution=False, no_deps=False, return_postinstall=False):
if instant_execution:
return self.cnr_switch_version_instant(node_id, version_spec, instant_execution, no_deps, return_postinstall)
else:
return self.cnr_switch_version_lazy(node_id, version_spec, no_deps, return_postinstall)
def cnr_switch_version_lazy(self, node_id, version_spec=None, no_deps=False, return_postinstall=False):
"""
switch between cnr version (lazy mode)
"""
result = ManagedResult('switch-cnr')
node_info = cnr_utils.install_node(node_id, version_spec)
try:
node_info = self._get_cnr_install_info(node_id, version_spec)
except PermissionError as exc:
return result.fail(str(exc))
if node_info is None or not node_info.download_url:
return result.fail(f'not available node: {node_id}@{version_spec}')
version_spec = node_info.version
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
if self.active_nodes[node_id][0] == version_spec:
def _cnr_switch_version(self, node_id, node_info, instant_execution=False, no_deps=False, return_postinstall=False):
"""Execute a switch using the checked CNR response."""
if self.active_nodes[node_id][0] == node_info.version:
return ManagedResult('skip').with_msg("Up to date")
if instant_execution:
return self._cnr_switch_version_instant(node_id, node_info, no_deps, return_postinstall)
return self._cnr_switch_version_lazy(node_id, node_info, no_deps, return_postinstall)
def _cnr_switch_version_lazy(self, node_id, node_info, no_deps=False, return_postinstall=False):
"""Reserve a switch using the validated install response."""
result = ManagedResult('switch-cnr')
version_spec = node_info.version
zip_url = node_info.download_url
from_path = self.active_nodes[node_id][1]
target = node_id
to_path = os.path.join(get_default_custom_nodes_path(), target)
def postinstall():
return self.reserve_cnr_switch(target, zip_url, from_path, to_path, no_deps)
return self.reserve_cnr_switch(target, zip_url, from_path, to_path, no_deps, node_info.status)
if return_postinstall:
return result.with_postinstall(postinstall)
@@ -1020,23 +1030,12 @@ class UnifiedManager:
return result
def cnr_switch_version_instant(self, node_id, version_spec=None, instant_execution=True, no_deps=False, return_postinstall=False):
"""
switch between cnr version
"""
# 1. download
def _cnr_switch_version_instant(self, node_id, node_info, no_deps=False, return_postinstall=False):
"""Apply a switch using the validated install response."""
result = ManagedResult('switch-cnr')
node_info = cnr_utils.install_node(node_id, version_spec)
if node_info is None or not node_info.download_url:
return result.fail(f'not available node: {node_id}@{version_spec}')
version_spec = node_info.version
if self.active_nodes[node_id][0] == version_spec:
return ManagedResult('skip').with_msg("Up to date")
# 1. download
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
download_path = os.path.join(get_default_custom_nodes_path(), archive_name)
manager_downloader.basic_download_url(node_info.download_url, get_default_custom_nodes_path(), archive_name)
@@ -1081,7 +1080,7 @@ class UnifiedManager:
result.target = version_spec
def postinstall():
res = self.execute_install_script(f"{node_id}@{version_spec}", install_path, instant_execution=instant_execution, no_deps=no_deps)
res = self.execute_install_script(f"{node_id}@{version_spec}", install_path, instant_execution=True, no_deps=no_deps)
return res
if return_postinstall:
@@ -1298,10 +1297,24 @@ class UnifiedManager:
if 'comfyui-manager' in node_id.lower():
return result.fail(f"ignored: installing '{node_id}'")
node_info = cnr_utils.install_node(node_id, version_spec)
try:
node_info = self._get_cnr_install_info(node_id, version_spec)
except PermissionError as exc:
return result.fail(str(exc))
if node_info is None or not node_info.download_url:
return result.fail(f'not available node: {node_id}@{version_spec}')
result = self._cnr_install(node_id, node_info, instant_execution, no_deps, return_postinstall)
# Keep the requested version in the public result.
if result.target is not None:
result.target = version_spec
return result
def _cnr_install(self, node_id, node_info, instant_execution=False, no_deps=False, return_postinstall=False):
"""Install using the checked CNR response."""
result = ManagedResult('install-cnr')
version_spec = node_info.version
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
download_path = os.path.join(get_default_custom_nodes_path(), archive_name)
@@ -1490,21 +1503,13 @@ class UnifiedManager:
else:
version_spec = self.resolve_unspecified_version(node_id)
if version_spec == 'unknown' or version_spec == 'nightly':
if version_spec in ('unknown', 'nightly'):
try:
custom_nodes = await self.get_custom_nodes(channel, mode)
except InvalidChannel as e:
return ManagedResult('fail').fail(f'Invalid channel is used: {e.channel}')
the_node = custom_nodes.get(node_id)
if the_node is not None:
if version_spec == 'unknown':
repo_url = the_node['files'][0]
else: # nightly
repo_url = the_node['repository']
else:
result = ManagedResult('install')
return result.fail(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
repo_url = await self._get_git_install_url(node_id, version_spec, channel, mode)
except InvalidChannel as exc:
return ManagedResult('fail').fail(f'Invalid channel is used: {exc.channel}')
except LookupError as exc:
return ManagedResult('install').fail(str(exc))
if self.is_enabled(node_id, version_spec):
return ManagedResult('skip').with_target(f"{node_id}@{version_spec}")
@@ -1512,24 +1517,17 @@ class UnifiedManager:
elif self.is_disabled(node_id, version_spec):
return self.unified_enable(node_id, version_spec)
elif version_spec == 'unknown' or version_spec == 'nightly':
if version_spec == 'nightly':
# disable cnr nodes
if self.is_enabled(node_id, 'cnr'):
self.unified_disable(node_id, False)
elif version_spec in ('unknown', 'nightly'):
return self._install_git(node_id, version_spec, repo_url, instant_execution, no_deps, return_postinstall)
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall)
if res.result:
if version_spec == 'unknown':
self.unknown_active_nodes[node_id] = repo_url, to_path
elif version_spec == 'nightly':
cnr_utils.generate_cnr_id(to_path, node_id)
self.active_nodes[node_id] = 'nightly', to_path
else:
return res
return res.with_target(version_spec)
# Check before changing the existing pack, then reuse this response.
try:
node_info = self._get_cnr_install_info(node_id, version_spec)
except PermissionError as exc:
return ManagedResult('install-cnr').fail(str(exc))
if node_info is None or not node_info.download_url:
return ManagedResult('install-cnr').fail(f'not available node: {node_id}@{version_spec}')
version_spec = node_info.version
if self.is_enabled(node_id, 'nightly'):
# disable nightly nodes
@@ -1542,17 +1540,77 @@ class UnifiedManager:
if self.is_disabled(node_id, "cnr"):
# enable and switch version if cnr is disabled (not specified version)
self.unified_enable(node_id, "cnr")
return self.cnr_switch_version(node_id, version_spec, no_deps=no_deps, return_postinstall=return_postinstall)
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
if self.is_enabled(node_id, "cnr"):
return self.cnr_switch_version(node_id, version_spec, no_deps=no_deps, return_postinstall=return_postinstall)
return self._cnr_switch_version(node_id, node_info, instant_execution, no_deps, return_postinstall)
res = self.cnr_install(node_id, version_spec, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall)
res = self._cnr_install(node_id, node_info, instant_execution, no_deps, return_postinstall)
if res.result:
self.active_nodes[node_id] = version_spec, res.to_path
return res
async def _get_git_install_url(self, node_id, version_spec, channel, mode):
custom_nodes = await self.get_custom_nodes(channel, mode)
node = custom_nodes.get(node_id)
if node is None:
raise LookupError(f"Node '{node_id}@{version_spec}' not found in [{channel}, {mode}]")
return node['files'][0] if version_spec == 'unknown' else node['repository']
def _install_git(self, node_id, version_spec, repo_url, instant_execution=False, no_deps=False, return_postinstall=False):
if version_spec == 'nightly':
# disable cnr nodes
if self.is_enabled(node_id, 'cnr'):
self.unified_disable(node_id, False)
to_path = os.path.abspath(os.path.join(get_default_custom_nodes_path(), node_id))
res = self.repo_install(repo_url, to_path, instant_execution=instant_execution, no_deps=no_deps, return_postinstall=return_postinstall)
if res.result:
if version_spec == 'unknown':
self.unknown_active_nodes[node_id] = repo_url, to_path
elif version_spec == 'nightly':
cnr_utils.generate_cnr_id(to_path, node_id)
self.active_nodes[node_id] = 'nightly', to_path
else:
return res
return res.with_target(version_spec)
async def reinstall_by_id(self, node_id, version_spec, channel=None, mode=None):
if 'comfyui-manager' in node_id.lower():
return ManagedResult('skip').fail(f"ignored: installing '{node_id}'")
if version_spec in ('unknown', 'nightly'):
try:
repo_url = await self._get_git_install_url(node_id, version_spec, channel, mode)
except InvalidChannel as exc:
return ManagedResult('fail').fail(f'Invalid channel is used: {exc.channel}')
except LookupError as exc:
return ManagedResult('install').fail(str(exc))
else:
try:
node_info = self._get_cnr_install_info(node_id, version_spec)
except PermissionError as exc:
return ManagedResult('install-cnr').fail(str(exc))
if node_info is None or not node_info.download_url:
return ManagedResult('install-cnr').fail(f'not available node: {node_id}@{version_spec}')
removed = self.unified_uninstall(node_id, version_spec == 'unknown')
if not removed.result:
return removed
for item in removed.items:
path = item if version_spec == 'unknown' else item[1]
self.processed_install.discard(os.path.join(path, 'install.py'))
if version_spec in ('unknown', 'nightly'):
return self._install_git(node_id, version_spec, repo_url)
result = self._cnr_install(node_id, node_info)
if result.result:
self.active_nodes[node_id] = node_info.version, result.to_path
return result
unified_manager = UnifiedManager()
@@ -1663,6 +1721,10 @@ class ManagerFuncs:
def __init__(self):
pass
def is_flagged_install_allowed(self):
# Server-scheduled restore children inherit only the resolved permission.
return os.environ.get('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', 'true') == 'true'
def get_current_preview_method(self):
return "none"
@@ -1703,6 +1765,7 @@ WRITTEN_CONFIG_KEYS = (
'db_mode',
'allow_git_url_install',
'allow_pip_install',
'allow_flagged_nodepack_install',
)
@@ -1877,6 +1940,7 @@ def read_config():
'db_mode': default_conf.get('db_mode', 'cache').lower(),
'allow_git_url_install': get_bool('allow_git_url_install', False),
'allow_pip_install': get_bool('allow_pip_install', False),
'allow_flagged_nodepack_install': get_bool('allow_flagged_nodepack_install', False),
}
manager_migration.force_security_level_if_needed(result)
return result
@@ -1908,6 +1972,7 @@ def read_config():
'db_mode': 'cache', # local | cache | remote
'allow_git_url_install': False,
'allow_pip_install': False,
'allow_flagged_nodepack_install': False,
}
manager_migration.force_security_level_if_needed(result)
return result
@@ -3315,15 +3380,20 @@ async def restore_snapshot(snapshot_path, git_helper_extras=None):
skip_node_packs.append(f"{x[0]}@{x[1]}")
elif not ps.result:
failed.append(f"{x[0]}@{x[1]}")
logging.error(ps.msg)
# install listed cnr nodes
# Do not retry processed switches as fresh installs.
checked_cnr = {node_id for node_id, _ in todo_checkout}
for k, v in cnr_info.items():
if 'comfyui-manager' in k:
if 'comfyui-manager' in k or k in checked_cnr:
continue
ps = await unified_manager.install_by_id(k, version_spec=v, instant_execution=True, return_postinstall=True)
if ps.action == 'install-cnr' and ps.result:
installed_node_packs.append(f"{k}@{v}")
elif not ps.result:
failed.append(f"{k}@{v}")
logging.error(ps.msg)
if ps is not None and ps.result:
if hasattr(ps, 'postinstall'):
+49 -33
View File
@@ -162,6 +162,9 @@ async def get_risky_level(files, pip_packages):
class ManagerFuncsInComfyUI(core.ManagerFuncs):
def is_flagged_install_allowed(self):
return core.get_config()['allow_flagged_nodepack_install'] or manager_util.is_loopback_listener(args.listen)
def get_current_preview_method(self):
if args.preview_method == latent_preview.LatentPreviewMethod.Auto:
return "auto"
@@ -304,7 +307,6 @@ print_comfyui_version()
core.check_invalid_nodes()
def setup_environment():
git_exe = core.get_config()['git_exe']
@@ -518,7 +520,7 @@ async def task_worker():
global model_result
global tasks_in_progress
async def do_install(item) -> str:
async def do_install(item, operation) -> str:
ui_id, node_spec_str, channel, mode, skip_post_install = item
try:
@@ -528,7 +530,10 @@ async def task_worker():
return f"Cannot resolve install target: '{node_spec_str}'"
node_name, version_spec, is_specified = node_spec
res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install)
if operation == 'reinstall':
res = await core.unified_manager.reinstall_by_id(node_name, version_spec, channel, mode)
else:
res = await core.unified_manager.install_by_id(node_name, version_spec, channel, mode, return_postinstall=skip_post_install)
# discard post install if skip_post_install mode
if res.action not in ['skip', 'enable', 'install-git', 'install-cnr', 'switch-cnr']:
@@ -575,7 +580,7 @@ async def task_worker():
base_res['msg'] = 'success'
return base_res
base_res['msg'] = f"An error occurred while updating '{node_name}'."
base_res['msg'] = res.msg or f"An error occurred while updating '{node_name}'."
logging.error(f"\nERROR: An error occurred while updating '{node_name}'. (res.result={res.result}, res.action={res.action})")
return base_res
except Exception:
@@ -728,8 +733,8 @@ async def task_worker():
tasks_in_progress.add((kind, item[0]))
try:
if kind == 'install':
msg = await do_install(item)
if kind in ('install', 'reinstall'):
msg = await do_install(item, kind)
elif kind == 'install-model':
msg = await do_install_model(item)
elif kind == 'update':
@@ -969,14 +974,22 @@ async def update_all(request):
def convert_markdown_to_html(input_text):
"""Parse source markdown, escaping text and URLs at their HTML boundaries."""
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'%%([^*]+)%%')
# Format link labels separately from URLs; escape URLs when inserted.
hrefs = []
def replace_a(match):
return f"<a href='{match.group(2)}' target='blank'>{match.group(1)}</a>"
# Entities written in the source URL (e.g. &amp;) retain their meaning.
url = manager_util.unescape_html_entities(match.group(2))
hrefs.append(manager_util.sanitize_url(url))
text = manager_util.escape_html_text(match.group(1))
return f"<a href='\x00H{len(hrefs) - 1}\x00' target='_blank' rel='noopener noreferrer'>{text}</a>"
def replace_w(match):
return f"<p class='cm-warn-note'>{match.group(1)}</p>"
@@ -990,20 +1003,35 @@ def convert_markdown_to_html(input_text):
def replace_white(match):
return f"<font color='white'>{match.group(1)}</font>"
input_text = input_text.replace('\\[', '&#91;').replace('\\]', '&#93;').replace('<', '&lt;').replace('>', '&gt;')
# NUL dropped first so the input cannot forge the href placeholders.
input_text = input_text.replace('\x00', '')
input_text = input_text.replace('\\[', '&#91;').replace('\\]', '&#93;')
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", "<BR>")
return result_text.replace("\n", "<BR>")
return re.sub(
r'\x00H(\d+)\x00',
lambda m: manager_util.escape_html_attribute(hrefs[int(m.group(1))]),
result_text,
)
def populate_markdown(x):
if 'description' in x:
x['description'] = convert_markdown_to_html(manager_util.sanitize_tag(x['description']))
x['description'] = convert_markdown_to_html(x['description'])
if 'name' in x:
x['name'] = manager_util.sanitize_tag(x['name'])
@@ -1391,8 +1419,7 @@ async def import_fail_info(request):
@routes.post("/manager/queue/reinstall")
async def reinstall_custom_node(request):
await uninstall_custom_node(request)
await install_custom_node(request)
return await _queue_node_install(request, "reinstall")
@routes.post("/manager/queue/reset")
@@ -1422,16 +1449,20 @@ async def queue_count(request):
@routes.post("/manager/queue/install")
async def install_custom_node(request):
return await _queue_node_install(request, "install")
async def _queue_node_install(request, operation):
if not is_allowed_security_level('middle'):
logging.error(SECURITY_MESSAGE_MIDDLE_OR_BELOW)
return web.Response(status=403, text="A security error has occurred. Please check the terminal logs")
json_data = await request.json()
# non-nightly cnr is safe
# CNR version status is checked by the worker before installation.
risky_level = None
cnr_id = json_data.get('id')
skip_post_install = json_data.get('skip_post_install')
skip_post_install = False if operation == 'reinstall' else json_data.get('skip_post_install')
git_url = None
@@ -1504,7 +1535,7 @@ async def install_custom_node(request):
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
install_item = json_data.get('ui_id'), node_spec_str, json_data['channel'], json_data['mode'], skip_post_install
task_queue.put(("install", install_item))
task_queue.put((operation, install_item))
return web.Response(status=200)
@@ -1780,7 +1811,6 @@ async def set_db_mode_handler(request):
return web.Response(status=200)
@routes.get("/manager/policy/component")
async def get_component_policy(request):
return web.Response(text=core.get_config()['component_policy'], status=200)
@@ -1834,19 +1864,6 @@ async def set_channel_url_list(request):
return web.Response(status=200)
def add_target_blank(html_text):
pattern = r'(<a\s+href="[^"]*"\s*[^>]*)(>)'
def add_target(match):
if 'target=' not in match.group(1):
return match.group(1) + ' target="_blank"' + match.group(2)
return match.group(0)
modified_html = re.sub(pattern, add_target, html_text)
return modified_html
@routes.get("/manager/notice")
async def get_notice(request):
url = "github.com"
@@ -1862,7 +1879,8 @@ async def get_notice(request):
match = pattern.search(html_content)
if match:
markdown_content = match.group(1)
# Sanitize remote HTML before appending Manager's version text.
markdown_content = manager_util.sanitize_html_fragment(match.group(1))
version_tag = os.environ.get('__COMFYUI_DESKTOP_VERSION__')
if version_tag is not None:
markdown_content += f"<HR>ComfyUI: {version_tag} [Desktop]"
@@ -1876,8 +1894,6 @@ async def get_notice(request):
# markdown_content += f"<BR>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;()"
markdown_content += f"<BR>Manager: {core.version_str}"
markdown_content = add_target_blank(markdown_content)
try:
if '__COMFYUI_DESKTOP_VERSION__' not in os.environ:
if core.comfy_ui_commit_datetime == datetime(1900, 1, 1, 0, 0, 0):
+127 -1
View File
@@ -3,6 +3,8 @@ description:
`manager_util` is the lightest module shared across the prestartup_script, main code, and cm-cli of ComfyUI-Manager.
"""
import traceback
import ipaddress
import socket
import aiohttp
import json
@@ -16,6 +18,8 @@ import logging
import platform
import shlex
from functools import lru_cache
from html import escape, unescape
from html.entities import html5
cache_lock = threading.Lock()
@@ -26,6 +30,50 @@ cache_dir = os.path.join(comfyui_manager_path, '.cache') # This path is also up
use_uv = False
bypass_ssl = False
FLAGGED_NODEPACK_INSTALL_ERROR = (
'This action is not allowed by the current security configuration. '
'See the terminal for details.'
)
FLAGGED_NODEPACK_INSTALL_GUIDANCE = (
'Installation of this flagged CNR version is blocked. To satisfy the additional '
'flagged-version requirement, choose one of the following:\n'
'1. Run ComfyUI with loopback-only listeners, for example --listen 127.0.0.1 '
'or --listen ::1. All configured listen addresses must be loopback.\n'
' Do not use bare --listen or any non-loopback address, such as 0.0.0.0 or ::.\n'
'2. For a trusted private network, set allow_flagged_nodepack_install = true '
'in the [default] section of ComfyUI-Manager\'s config.ini.\n'
'Restart ComfyUI after changing the listener or configuration. '
'All other installation security checks still apply.'
)
def is_loopback_listener(listen_address: str) -> bool:
"""All addresses bound by --listen must resolve exclusively to loopback."""
if not isinstance(listen_address, str) or not listen_address:
return False
for address in listen_address.split(','):
address = address.strip()
if not address:
return False
try:
if ipaddress.ip_address(address).is_loopback:
continue
except ValueError:
pass
try:
resolved = socket.getaddrinfo(address, None, type=socket.SOCK_STREAM)
except OSError:
return False
if not resolved or any(not ipaddress.ip_address(item[4][0]).is_loopback for item in resolved):
return False
return True
def is_cnr_install_allowed(status: str, allow_flagged: bool, listen_address: str) -> bool:
"""Only flagged versions require loopback or the private-network opt-in."""
return status != 'NodeVersionStatusFlagged' or bool(allow_flagged) or is_loopback_listener(listen_address)
def add_python_path_to_env():
if platform.system() != "Windows":
sep = ':'
@@ -268,6 +316,84 @@ def sanitize_tag(x):
return x.replace('<', '&lt;').replace('>', '&gt;')
SAFE_URL_SCHEMES = frozenset({'http', 'https'})
_URL_SCHEME_NOISE = re.compile(r'[\x00-\x20\x7f]')
_URL_HEAD_DELIMITERS = ('/', '?', '#')
def escape_html_attribute(value):
"""Escape a value for a quoted HTML attribute."""
return escape(str(value), quote=True)
_HTML_ENTITY = re.compile(r'&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z][A-Za-z0-9]*);')
_HTML_TEXT_TOKEN = re.compile(_HTML_ENTITY.pattern + "|[&<>\"']")
def unescape_html_entities(text):
"""Decode complete entities once, leaving query keys such as &notebook intact."""
def replace(match):
entity = match.group(1)
if entity.startswith('#'):
return unescape(match.group(0))
return html5.get(entity + ';', match.group(0))
return _HTML_ENTITY.sub(replace, text)
def escape_html_text(value):
"""Escape HTML text without double-escaping existing entities."""
return _HTML_TEXT_TOKEN.sub(
lambda m: m.group(0) if m.group(1) else escape_html_attribute(m.group(0)),
str(value).replace('\x00', ''),
)
def sanitize_url(url):
"""Allow HTTP(S), relative URLs and anchors.
HTML callers must also escape the result as an attribute."""
raw = '' if url is None else str(url)
probe = _URL_SCHEME_NOISE.sub('', raw)
if not probe:
return '#'
cut = len(probe)
for delimiter in _URL_HEAD_DELIMITERS:
found = probe.find(delimiter)
if found != -1:
cut = min(cut, found)
head = probe[:cut]
if ':' in head:
return raw.strip() if head.split(':', 1)[0].lower() in SAFE_URL_SCHEMES else '#'
return raw.strip()
def sanitize_html_fragment(fragment):
"""Sanitize notice HTML, preserving its formatting and opening links safely."""
# prestartup_script imports this module before installing dependencies.
import nh3
return nh3.clean(
'' if fragment is None else str(fragment),
tags=nh3.ALLOWED_TAGS | {'font'},
attributes={
**nh3.ALLOWED_ATTRIBUTES,
'*': {'class', 'title', 'align', 'width', 'height'},
'font': {'color'},
},
# These elements previously hid their contents from the notice.
clean_content_tags={
'script', 'style', 'iframe', 'object', 'embed', 'svg', 'math',
'template', 'noscript', 'textarea', 'title',
},
url_schemes=SAFE_URL_SCHEMES,
set_tag_attribute_values={'a': {'target': '_blank'}},
)
def extract_package_as_zip(file_path, extract_path):
import zipfile
try:
@@ -630,4 +756,4 @@ def restore_pip_snapshot(pips, options):
if res != 0:
failed.append(x)
print(f"Installation failed for pip packages: {failed}")
print(f"Installation failed for pip packages: {failed}")
+7 -7
View File
@@ -14,7 +14,7 @@ import { OpenArtShareDialog } from "./comfyui-share-openart.js";
import {
free_models, install_pip, install_via_git_url, manager_instance,
rebootAPI, setManagerInstance, show_message, customAlert, customPrompt,
infoToast, showTerminal, setNeedRestart, handle403Response
infoToast, showTerminal, setNeedRestart, handle403Response, sanitizeHTML, safeHref
} from "./common.js";
import { ComponentBuilderDialog, getPureName, load_components, set_component_policy } from "./components-manager.js";
import { CustomNodesManager } from "./custom-nodes-manager.js";
@@ -713,12 +713,12 @@ async function onQueueStatus(event) {
for(let x in success_list) {
let k = success_list[x];
let url = event.detail.nodepack_result[k].url;
let title = event.detail.nodepack_result[k].title;
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
if(url) {
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
msg += `<li><a href='${safeHref(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
}
else {
msg += `<li>${k}</li>`;
msg += `<li>${sanitizeHTML(String(k))}</li>`;
}
}
msg += "</ul>";
@@ -732,12 +732,12 @@ async function onQueueStatus(event) {
for(let x in failed_list) {
let k = failed_list[x];
let url = event.detail.nodepack_result[k].url;
let title = event.detail.nodepack_result[k].title;
let title = sanitizeHTML(String(event.detail.nodepack_result[k].title ?? ''));
if(url) {
msg += `<li><a href='${url}' target='_blank'>${title}</a></li>`;
msg += `<li><a href='${safeHref(url)}' target='_blank' rel='noopener noreferrer'>${title}</a></li>`;
}
else {
msg += `<li>${k}</li>`;
msg += `<li>${sanitizeHTML(String(k))}</li>`;
}
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { $el, ComfyDialog } from "../../scripts/ui.js";
import { CopusShareDialog } from "./comfyui-share-copus.js";
import { OpenArtShareDialog } from "./comfyui-share-openart.js";
import { YouMLShareDialog } from "./comfyui-share-youml.js";
import { customAlert } from "./common.js";
import { customAlert, safeHref, sanitizeHTML } from "./common.js";
export const SUPPORTED_OUTPUT_NODE_TYPES = [
"PreviewImage",
@@ -937,7 +937,7 @@ export class ShareDialog extends ComfyDialog {
const response_json = await response.json();
if (response_json.comfyworkflows.url) {
this.final_message.innerHTML = "Your art has been shared: <a href='" + response_json.comfyworkflows.url + "' target='_blank'>" + response_json.comfyworkflows.url + "</a>";
this.final_message.innerHTML = "Your art has been shared: <a href='" + safeHref(response_json.comfyworkflows.url) + "' target='_blank' rel='noopener noreferrer'>" + sanitizeHTML(response_json.comfyworkflows.url) + "</a>";
if (response_json.matrix.success) {
this.final_message.innerHTML += "<br>Your art has been shared in the ComfyUI Matrix server's #share channel!";
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { $el, ComfyDialog } from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, safeHref } from "./common.js";
const env = "prod";
@@ -883,7 +883,7 @@ export class CopusShareDialog extends ComfyDialog {
const { data } = res.data;
if (data) {
const url = `${DEFAULT_HOMEPAGE_URL}/work/${data}`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
this.previewImage.src = "";
this.previewImage.style.display = "none";
this.uploadedImages = [];
+2 -2
View File
@@ -1,7 +1,7 @@
import {app} from "../../scripts/app.js";
import {api} from "../../scripts/api.js";
import {ComfyDialog, $el} from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, safeHref } from "./common.js";
const LOCAL_STORAGE_KEY = "openart_comfy_workflow_key";
const DEFAULT_HOMEPAGE_URL = "https://openart.ai/workflows/dev?developer=true";
@@ -511,7 +511,7 @@ export class OpenArtShareDialog extends ComfyDialog {
const {workflow_id} = response.data;
if (workflow_id) {
const url = `https://openart.ai/workflows/-/-/${workflow_id}`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${url}" target="_blank">Click here to view it.</a>`;
this.message.innerHTML = `Workflow has been shared successfully. <a href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">Click here to view it.</a>`;
this.previewImage.src = "";
this.previewImage.style.display = "none";
this.uploadedImages = [];
+2 -2
View File
@@ -1,7 +1,7 @@
import {app} from "../../scripts/app.js";
import {api} from "../../scripts/api.js";
import {ComfyDialog, $el} from "../../scripts/ui.js";
import { customAlert } from "./common.js";
import { customAlert, safeHref } from "./common.js";
const BASE_URL = "https://youml.com";
//const BASE_URL = "http://localhost:3000";
@@ -420,7 +420,7 @@ export class YouMLShareDialog extends ComfyDialog {
}
}
this.message.innerHTML = `${messagePrefix} To turn your workflow into an interactive app, ` +
`<a href="${recipePageUrl}" target="_blank">visit it on YouML</a>`;
`<a href="${safeHref(recipePageUrl)}" target="_blank" rel="noopener noreferrer">visit it on YouML</a>`;
this.uploadedImages = [];
this.nameInput.value = "";
+42 -6
View File
@@ -427,6 +427,40 @@ export function sanitizeHTML(str) {
.replace(/'/g, "&#039;");
}
// Keep URL handling aligned with glob/manager_util.py; covered by shared cases.
export const SAFE_URL_SCHEMES = ['http', 'https'];
const URL_SCHEME_NOISE = /[\x00-\x20\x7f]/g;
const URL_HEAD_DELIMITERS = ['/', '?', '#'];
// Match Python str.strip(); JavaScript trim() uses different Unicode whitespace.
const URL_TRIM = /^[\t-\r\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\t-\r\x1c-\x20\x85\xa0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/g;
const trimUrl = (value) => value.replace(URL_TRIM, '');
export function sanitizeUrl(url) {
const raw = (url === null || url === undefined) ? '' : String(url);
const probe = raw.replace(URL_SCHEME_NOISE, '');
if (!probe) {
return '#';
}
let cut = probe.length;
for (const delimiter of URL_HEAD_DELIMITERS) {
const found = probe.indexOf(delimiter);
if (found !== -1) {
cut = Math.min(cut, found);
}
}
const head = probe.slice(0, cut);
if (head.includes(':')) {
return SAFE_URL_SCHEMES.includes(head.split(':', 1)[0].toLowerCase()) ? trimUrl(raw) : '#';
}
return trimUrl(raw);
}
// Validate the URL, then escape it for a quoted href attribute.
export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));
export function showTerminal() {
try {
const panel = app.extensionManager.bottomPanel;
@@ -646,7 +680,9 @@ export async function uninstallNodes(nodeList, options = {}) {
for (const nodeItem of nodeList) {
target_items.push(nodeItem);
onProgress(`Uninstall ${nodeItem.title || nodeItem.name} ...`);
// Titles are escaped by the caller; the name fallback is a raw pack key.
const displayTitle = nodeItem.title || sanitizeHTML(String(nodeItem.name));
onProgress(`Uninstall ${displayTitle} ...`);
const data = nodeItem.originalData || nodeItem;
data.channel = channel;
@@ -659,7 +695,7 @@ export async function uninstallNodes(nodeList, options = {}) {
});
if (res.status != 200) {
errorMsg = `'${sanitizeHTML(String(nodeItem.title || nodeItem.name))}': `;
errorMsg = `'${displayTitle}': `;
if (res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
@@ -988,14 +1024,14 @@ export function createFlyover(container, options = {}) {
return flyover;
}
// Shared UI State Methods - consolidated from multiple managers
// Message methods accept HTML; callers escape raw data and preserve formatted content.
export function createUIStateManager(element, selectors) {
return {
showSelection: (msg) => {
const el = element.querySelector(selectors.selection);
if (el) el.innerHTML = msg;
},
showError: (err) => {
const el = element.querySelector(selectors.message);
if (el) {
@@ -1003,7 +1039,7 @@ export function createUIStateManager(element, selectors) {
el.innerHTML = msg;
}
},
showMessage: (msg, color) => {
const el = element.querySelector(selectors.message);
if (el) {
@@ -1013,7 +1049,7 @@ export function createUIStateManager(element, selectors) {
el.innerHTML = msg;
}
},
showStatus: (msg, color) => {
const el = element.querySelector(selectors.status);
if (el) {
+42 -28
View File
@@ -6,13 +6,13 @@ import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-b
import {
manager_instance, rebootAPI, install_via_git_url,
fetchData, md5, icons, show_message, customConfirm, customAlert, customPrompt,
sanitizeHTML, infoToast, showTerminal, setNeedRestart,
sanitizeHTML, sanitizeUrl, infoToast, showTerminal, setNeedRestart,
storeColumnWidth, restoreColumnWidth, getTimeAgo, copyText, loadCss,
showPopover, hidePopover, getWorkflowNodeTypes, findPackageByCnrId, analyzeWorkflowUsage, createFlyover
} from "./common.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
import ManagerGrid from "./manager-grid.js";
loadCss("./custom-nodes-manager.css");
@@ -374,18 +374,17 @@ export class CustomNodesManager {
}
let list = installGroups[action];
if (!Array.isArray(list)) {
return "";
}
if(is_selected_button || rowItem?.version === "unknown") {
list = list.filter(it => it !== "switch");
}
if (!list) {
return "";
}
return list.map(id => {
const bt = buttons[id];
return `<button class="cn-btn-${id} p-button p-component" group="${action}" mode="${bt.mode}">${bt.label}</button>`;
return `<button class="cn-btn-${id} p-button p-component" group="${sanitizeHTML(String(action))}" mode="${bt.mode}">${bt.label}</button>`;
}).join("");
}
@@ -528,7 +527,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 = createFlyover(container, {
@@ -593,6 +592,9 @@ export class CustomNodesManager {
grid.setOption({
highlightKeywords: {
textGenerator: (row, column) => column === 'author' ? sanitizeHTML(String(row[column] ?? '')) : row[column]
},
theme: 'dark',
selectVisible: true,
selectMultiple: true,
@@ -698,7 +700,8 @@ export class CustomNodesManager {
id: 'id',
name: 'ID',
width: 50,
align: 'center'
align: 'center',
formatter: (value) => (value === null || value === undefined) ? value : sanitizeHTML(String(value))
}, {
id: 'title',
name: 'Title',
@@ -724,11 +727,13 @@ export class CustomNodesManager {
}
const link = document.createElement('a');
// DOM href takes a validated raw URL, without HTML attribute escaping.
if(rowItem.originalData.repository)
link.href = rowItem.originalData.repository;
link.href = sanitizeUrl(rowItem.originalData.repository);
else
link.href = rowItem.reference;
link.href = sanitizeUrl(rowItem.reference);
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.innerHTML = `<b>${title}</b>`;
link.title = rowItem.originalData.id;
container.appendChild(link);
@@ -746,13 +751,15 @@ export class CustomNodesManager {
if(!version) {
return;
}
const safeVersion = sanitizeHTML(String(version));
if(rowItem.cnr_latest && version != rowItem.cnr_latest) {
const safeLatest = sanitizeHTML(String(rowItem.cnr_latest));
if(version == 'nightly') {
return `<div>${version}</div><div>[${rowItem.cnr_latest}]</div>`;
return `<div>${safeVersion}</div><div>[${safeLatest}]</div>`;
}
return `<div>${version}</div><div>[↑${rowItem.cnr_latest}]</div>`;
return `<div>${safeVersion}</div><div>[↑${safeLatest}]</div>`;
}
return version;
return safeVersion;
}
}, {
id: 'action',
@@ -804,10 +811,11 @@ export class CustomNodesManager {
width: 120,
classMap: "cn-pack-author",
formatter: (author, rowItem, columnItem) => {
const safeAuthor = sanitizeHTML(String(author ?? ''));
if (rowItem.trust) {
return `<span title="This author has been active for more than six months in GitHub">✅ ${author}</span>`;
return `<span title="This author has been active for more than six months in GitHub">✅ ${safeAuthor}</span>`;
}
return author;
return safeAuthor;
}
}, {
id: 'stars',
@@ -821,7 +829,7 @@ export class CustomNodesManager {
if (typeof stars === 'number') {
return stars.toLocaleString();
}
return stars;
return (stars === null || stars === undefined) ? stars : sanitizeHTML(String(stars));
}
}, {
id: 'last_update',
@@ -835,7 +843,7 @@ export class CustomNodesManager {
return 'N/A';
}
const ago = getTimeAgo(last_update);
const short = `${last_update}`.split(' ')[0];
const short = sanitizeHTML(String(last_update).split(' ')[0]);
return `<span title="${ago}">${short}</span>`;
}
}];
@@ -1145,6 +1153,7 @@ export class CustomNodesManager {
const rowItem = d.rowItem;
const isNotInstalled = rowItem.action == "not-installed";
// Pack titles are already escaped by the server.
let titleHtml = `<div class="cn-nodes-pack" hash="${rowItem.hash}">${rowItem.title}</div>`;
if (isNotInstalled) {
titleHtml += '<div class="cn-pack-badge">Not Installed</div>'
@@ -1161,7 +1170,8 @@ export class CustomNodesManager {
list.push(`<div class="${rowClass}">`);
list.push(`<div class="cn-nodes-sn">${i+1}</div>`);
list.push(`<div class="cn-nodes-name">${it.name}</div>`);
// extName via /customnode/getmappings, no server sanitize: escaped at the sink.
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(it.name))}</div>`);
if (it.conflicts) {
list.push(`<div class="cn-conflicts-list"><div class="cn-nodes-conflict cn-icon">${icons.conflicts}</div><b>Conflict with</b>${it.conflicts.map(c => {
@@ -1283,7 +1293,7 @@ export class CustomNodesManager {
return;
}
const selectedMap = {};
const selectedMap = Object.create(null);
selectedList.forEach(item => {
let type = item.action;
if (item.restart) {
@@ -1302,7 +1312,7 @@ export class CustomNodesManager {
Object.keys(selectedMap).forEach(v => {
const filterItem = this.getFilterItem(v);
list.push(`<div class="cn-selected-buttons">
<span>Selected <b>${selectedMap[v].length}</b> ${filterItem ? filterItem.label : v}</span>
<span>Selected <b>${selectedMap[v].length}</b> ${sanitizeHTML(String(filterItem ? filterItem.label : v))}</span>
${this.grid.hasMask ? "" : this.getActionButtons(v, null, true)}
</div>`);
});
@@ -1467,7 +1477,8 @@ export class CustomNodesManager {
});
if (res.status != 200) {
errorMsg = `'${sanitizeHTML(String(item.title))}': `;
// The title is already escaped by populate_markdown.
errorMsg = `'${item.title}': `;
if(res.status == 403) {
try {
@@ -1561,7 +1572,8 @@ export class CustomNodesManager {
for (let hash in result) {
let v = result[hash];
if (v != 'success' && v != 'skip') {
errorMsg += v + '\n';
// Escape raw task errors for both HTML message destinations.
errorMsg += sanitizeHTML(String(v)) + '\n';
}
}
@@ -1753,7 +1765,7 @@ export class CustomNodesManager {
this.showStatus(`Loading missing nodes (${mode}) ...`);
const res = await fetchData(`/customnode/getmappings?mode=${mode}`);
if (res.error) {
this.showError(`Failed to get custom node mappings: ${res.error}`);
this.showError(`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);
return;
}
@@ -1871,7 +1883,7 @@ export class CustomNodesManager {
this.showStatus(`Loading alternatives (${mode}) ...`);
const res = await fetchData(`/customnode/alternatives?mode=${mode}`);
if (res.error) {
this.showError(`Failed to get alternatives: ${res.error}`);
this.showError(`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);
return [];
}
@@ -1887,8 +1899,9 @@ export class CustomNodesManager {
continue;
}
// tags (alter-list.json) is not server-transformed, so escape before innerHTML.
const tags = `${item.tags}`.split(",").map(tag => {
return `<div>${tag.trim()}</div>`;
return `<div>${sanitizeHTML(tag.trim())}</div>`;
}).join("");
hashMap[custom_node.hash] = {
@@ -1906,7 +1919,7 @@ export class CustomNodesManager {
const result = await analyzeWorkflowUsage(this.custom_nodes);
if (!result.success) {
this.showError(`Failed to get workflow data: ${result.error}`);
this.showError(`Failed to get workflow data: ${sanitizeHTML(String(result.error))}`);
return {};
}
@@ -1977,7 +1990,7 @@ export class CustomNodesManager {
this.custom_nodes = node_packs;
if(this.channel !== 'default') {
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${this.channel} (Incomplete list)`;
this.element.querySelector(".cn-manager-channel").innerHTML = `Channel: ${sanitizeHTML(String(this.channel))} (Incomplete list)`;
}
for (const k in node_packs) {
@@ -2109,6 +2122,7 @@ export class CustomNodesManager {
// ===========================================================================================
// Messages accept HTML; see createUIStateManager in common.js.
showSelection(msg) {
this.element.querySelector(".cn-manager-selection").innerHTML = msg;
}
+67
View File
@@ -0,0 +1,67 @@
import { Grid } from "./turbogrid.esm.js";
// Keep Manager's search hardening outside the vendored TurboGrid bundle.
export default class ManagerGrid extends Grid {
highlightKeywordsFilter(rowItem, columns, value) {
const { textKey, textGenerator, highlightKey } = this.options.highlightKeywords;
for (const column of columns) {
rowItem[`${highlightKey}${column}`] = null;
}
const keywords = value ? String(value).trim().toLowerCase().split(/\s+/).filter(Boolean) : [];
if (!keywords.length) return true;
let matched = false;
for (const column of columns) {
const value = typeof textGenerator === "function" ? textGenerator(rowItem, column) : rowItem[column];
if (value === null || value === undefined) continue;
let text = String(value).trim();
if (!text) continue;
const key = `${textKey}${column}`;
if (rowItem[key] === null || rowItem[key] === undefined) {
// A detached element's innerHTML can still run image handlers.
rowItem[key] = new DOMParser().parseFromString(text, "text/html").body.textContent;
}
text = rowItem[key].toLowerCase();
let offset = 0;
const found = keywords.every(keyword => {
const index = text.indexOf(keyword, offset);
if (index === -1) return false;
offset = index + keyword.length;
return true;
});
if (found) {
rowItem[`${highlightKey}${column}`] = true;
this.highlightKeywords = keywords;
matched = true;
}
}
return matched;
}
highlightTextNodes(nodes, keywords) {
if (!keywords.length) return;
let keywordIndex = 0;
for (const node of nodes) {
const text = node.textContent;
const lower = text.toLowerCase();
const wrapper = document.createElement("span");
let offset = 0;
while (offset < text.length) {
const keyword = keywords[keywordIndex];
const index = lower.indexOf(keyword, offset);
if (index === -1) break;
wrapper.append(text.slice(offset, index));
const mark = document.createElement("mark");
mark.textContent = text.slice(index, index + keyword.length);
wrapper.append(mark);
offset = index + keyword.length;
keywordIndex = (keywordIndex + 1) % keywords.length;
}
if (wrapper.childNodes.length) {
// Decoded cell text stays in text nodes, including inside <mark>.
wrapper.append(text.slice(offset));
node.replaceWith(wrapper);
}
}
}
}
+36 -23
View File
@@ -3,18 +3,28 @@ import { $el } from "../../scripts/ui.js";
import {
manager_instance, rebootAPI,
fetchData, md5, icons, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, formatSize, sizeToBytes
storeColumnWidth, restoreColumnWidth, loadCss, formatSize, sizeToBytes,
sanitizeHTML, safeHref
} from "./common.js";
import { api } from "../../scripts/api.js";
// https://cenfun.github.io/turbogrid/api.html
import TG from "./turbogrid.esm.js";
import ManagerGrid from "./manager-grid.js";
import { buildGuiFrameCustomHeader, createSettingsCombo } from "./comfyui-gui-builder.js";
loadCss("./model-manager.css");
const gridId = "model";
// Escape raw cells while preserving empty values.
const escapeCell = (value) => (value === null || value === undefined) ? value : sanitizeHTML(String(value));
// Escape option markup while comparing the original values.
const renderOptions = (list, current) => list.map(item => {
const selected = item.value === current ? " selected" : "";
return `<option value="${sanitizeHTML(String(item.value))}"${selected}>${sanitizeHTML(String(item.label))}</option>`;
}).join("");
const pageHtml = `
<div class="cmm-manager cmm-manager-dark">
<div class="cmm-manager-grid"></div>
@@ -101,22 +111,13 @@ export class ModelManager {
updateFilter() {
const $filter = this.element.querySelector(".cmm-manager-filter");
$filter.innerHTML = this.filterList.map(item => {
const selected = item.value === this.filter ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
$filter.innerHTML = renderOptions(this.filterList, this.filter);
const $type = this.element.querySelector(".cmm-manager-type");
$type.innerHTML = this.typeList.map(item => {
const selected = item.value === this.type ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
$type.innerHTML = renderOptions(this.typeList, this.type);
const $base = this.element.querySelector(".cmm-manager-base");
$base.innerHTML = this.baseList.map(item => {
const selected = item.value === this.base ? " selected" : "";
return `<option value="${item.value}"${selected}>${item.label}</option>`
}).join("");
$base.innerHTML = renderOptions(this.baseList, this.base);
}
@@ -199,7 +200,7 @@ export class ModelManager {
initGrid() {
const container = this.element.querySelector(".cmm-manager-grid");
const grid = new TG.Grid(container);
const grid = new ManagerGrid(container);
this.grid = grid;
grid.bind('onUpdated', (e, d) => {
@@ -227,6 +228,9 @@ export class ModelManager {
});
grid.setOption({
highlightKeywords: {
textGenerator: (row, column) => ['name', 'description'].includes(column) ? row[column] : escapeCell(row[column])
},
theme: 'dark',
selectVisible: true,
@@ -325,7 +329,8 @@ export class ModelManager {
maxWidth: 500,
classMap: 'cmm-node-name',
formatter: function(name, rowItem, columnItem, cellNode) {
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
// Names are server-escaped; raw references need URL and attribute handling.
return `<a href="${safeHref(rowItem.reference)}" target="_blank" rel="noopener noreferrer"><b>${name}</b></a>`;
}
}, {
id: 'installed',
@@ -351,7 +356,7 @@ export class ModelManager {
sortable: false,
align: 'center',
formatter: (url, rowItem, columnItem) => {
return `<a class="cmm-btn-download" title="Download file" href="${url}" target="_blank">${icons.download}</a>`;
return `<a class="cmm-btn-download" title="Download file" href="${safeHref(url)}" target="_blank" rel="noopener noreferrer">${icons.download}</a>`;
}
}, {
id: 'size',
@@ -366,11 +371,14 @@ export class ModelManager {
}, {
id: 'type',
name: 'Type',
width: 100
width: 100,
formatter: escapeCell
}, {
id: 'base',
name: 'Base'
name: 'Base',
formatter: escapeCell
}, {
// Descriptions are formatted HTML from the server.
id: 'description',
name: 'Description',
width: 400,
@@ -379,11 +387,13 @@ export class ModelManager {
}, {
id: "save_path",
name: 'Save Path',
width: 200
width: 200,
formatter: escapeCell
}, {
id: 'filename',
name: 'Filename',
width: 200
width: 200,
formatter: escapeCell
}];
restoreColumnWidth(gridId, columns);
@@ -458,6 +468,7 @@ export class ModelManager {
});
}
// Model names are already escaped by the server.
this.showStatus(`Install ${item.name} ...`);
const data = item.originalData;
@@ -483,7 +494,7 @@ export class ModelManager {
errorMsg += `This action is not allowed with this security level configuration.\n`;
}
} else {
errorMsg += await res.text() + '\n';
errorMsg += sanitizeHTML(await res.text()) + '\n';
}
break;
@@ -552,8 +563,9 @@ export class ModelManager {
for(let hash in result){
let v = result[hash];
// Escape raw task errors for both HTML message destinations.
if(v != 'success')
errorMsg += v + '\n';
errorMsg += sanitizeHTML(String(v)) + '\n';
}
for(let k in self.install_context.targets) {
@@ -670,6 +682,7 @@ export class ModelManager {
// ===========================================================================================
// Messages accept HTML; see createUIStateManager in common.js.
showSelection(msg) {
this.element.querySelector(".cmm-manager-selection").innerHTML = msg;
}
+17 -9
View File
@@ -4,7 +4,8 @@ import {
manager_instance,
fetchData, md5, show_message, customAlert, infoToast, showTerminal,
storeColumnWidth, restoreColumnWidth, loadCss, uninstallNodes,
analyzeWorkflowUsage, sizeToBytes, createFlyover, createUIStateManager
analyzeWorkflowUsage, sizeToBytes, createFlyover, createUIStateManager,
sanitizeHTML, safeHref
} from "./common.js";
import { api } from "../../scripts/api.js";
@@ -220,7 +221,8 @@ export class NodeUsageAnalyzer {
maxWidth: 500,
classMap: 'nu-pack-name',
formatter: function (name, rowItem, columnItem, cellNode) {
return `<a href=${rowItem.reference} target="_blank"><b>${name}</b></a>`;
// Names are escaped during loadData; references need URL and attribute handling.
return `<a href="${safeHref(rowItem.reference)}" target="_blank" rel="noopener noreferrer"><b>${name}</b></a>`;
}
}, {
id: 'used_in_count',
@@ -283,7 +285,8 @@ export class NodeUsageAnalyzer {
workflowList.forEach((workflow, i) => {
list.push(`<div class="cn-nodes-row">`);
list.push(`<div class="cn-nodes-sn">${i + 1}</div>`);
list.push(`<div class="cn-nodes-name">${workflow.filename}</div>`);
// Workflow filenames are raw server data.
list.push(`<div class="cn-nodes-name">${sanitizeHTML(String(workflow.filename))}</div>`);
list.push(`<div class="cn-nodes-details">${workflow.nodeCount} node${workflow.nodeCount > 1 ? 's' : ''}</div>`);
list.push(`</div>`);
});
@@ -346,7 +349,8 @@ export class NodeUsageAnalyzer {
target_items.push(item);
this.ui.showStatus(`Install ${item.name} ...`);
// Here item.name is a raw pack key, not a server-escaped title.
this.ui.showStatus(`Install ${sanitizeHTML(String(item.name))} ...`);
const data = item.originalData;
data.ui_id = item.hash;
@@ -357,12 +361,13 @@ export class NodeUsageAnalyzer {
});
if (res.status != 200) {
errorMsg = `'${item.name}': `;
// Escape raw task errors for both HTML message destinations.
errorMsg = `'${sanitizeHTML(String(item.name))}': `;
if (res.status == 403) {
errorMsg += `This action is not allowed with this security level configuration.\n`;
} else {
errorMsg += await res.text() + '\n';
errorMsg += sanitizeHTML(await res.text()) + '\n';
}
break;
@@ -466,8 +471,9 @@ export class NodeUsageAnalyzer {
for (let hash in result) {
let v = result[hash];
// Escape raw task errors for both HTML message destinations.
if (v != 'success' && v != 'skip')
errorMsg += v + '\n';
errorMsg += sanitizeHTML(String(v)) + '\n';
}
for (let k in self.install_context.targets) {
@@ -591,7 +597,7 @@ export class NodeUsageAnalyzer {
if (result.error.toString().includes('204')) {
this.showMessage("No workflows were found for analysis.");
} else {
this.showError(result.error);
this.showError(sanitizeHTML(String(result.error)));
this.hideLoading();
return;
}
@@ -612,7 +618,8 @@ export class NodeUsageAnalyzer {
const workflowDetails = result.workflowDetailsMap?.get(packKey) || [];
models.push({
title: pack.title || packKey,
// Preserve server-escaped titles; escape only the raw pack-key fallback.
title: pack.title || sanitizeHTML(String(packKey)),
reference: pack.reference || pack.files?.[0] || '#',
used_in_count: usedCount,
workflowDetails: workflowDetails,
@@ -639,6 +646,7 @@ export class NodeUsageAnalyzer {
// ===========================================================================================
// Messages accept HTML; see createUIStateManager in common.js.
showSelection(msg) {
this.element.querySelector(".nu-manager-selection").innerHTML = msg;
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js"
import { ComfyDialog, $el } from "../../scripts/ui.js";
import { manager_instance, rebootAPI, show_message, handle403Response, loadCss } from "./common.js";
import { manager_instance, rebootAPI, show_message, handle403Response, loadCss, sanitizeHTML } from "./common.js";
import { buildGuiFrame } from "./comfyui-gui-builder.js";
loadCss("./snapshot.css");
@@ -133,7 +133,7 @@ export class SnapshotManager extends ComfyDialog {
startRestore(target) {
const self = SnapshotManager.instance;
self.updateMessage(`<BR><font color="green">Restore snapshot '${target.name}'</font>`);
self.updateMessage(`<BR><font color="green">Restore snapshot '${sanitizeHTML(target.name)}'</font>`);
for(let i in self.restore_buttons) {
self.restore_buttons[i].disabled = true;
@@ -214,7 +214,7 @@ export class SnapshotManager extends ComfyDialog {
data1.style.textAlign = "center";
data1.innerHTML = i+1;
var data2 = document.createElement('td');
data2.innerHTML = `&nbsp;${data}`;
data2.innerHTML = `&nbsp;${sanitizeHTML(data)}`;
var data_button = document.createElement('td');
data_button.style.textAlign = "center";
data_button.className = "data-btns";
+21 -50
View File
@@ -1,5 +1,26 @@
{
"custom_nodes": [
{
"author": "trusapien",
"title": "ComfyUI-Trusapien",
"reference": "https://github.com/trusapien/ComfyUI-Trusapien",
"files": [
"https://github.com/trusapien/ComfyUI-Trusapien"
],
"install_type": "git-clone",
"description": "Enterprise creative validation, attention heatmaps, and campaign impact scoring by Trusapien."
},
{
"author": "mickmumpitz",
"title": "ComfyUI-SplatKit [SECURITY ISSUE]",
"id": "splatkit",
"reference": "https://github.com/mickmumpitz/ComfyUI-SplatKit",
"files": [
"https://github.com/mickmumpitz/ComfyUI-SplatKit"
],
"install_type": "git-clone",
"description": "Build 3D Gaussian Splat training datasets from a single 360 panorama, inside ComfyUI."
},
{
"author": "yitao2020",
"title": "ComfyUI-GPT-Image-2 [NAME CONFLICT]",
@@ -1140,16 +1161,6 @@
"install_type": "git-clone",
"description": "Collection of useful custom nodes for ComfyUI including Save Image with Caption node for saving images and prompts."
},
{
"author": "wxytjustb",
"title": "ComfyUI-CustomNodes [NAME CONFLICT]",
"reference": "https://github.com/wxytjustb/ComfyUI-CustomNodes",
"files": [
"https://github.com/wxytjustb/ComfyUI-CustomNodes"
],
"install_type": "git-clone",
"description": "ComfyUI AI node system supporting multiple API providers including Google Gemini, OpenAI, OpenRouter, and VectorEngine with configuration-driven provider and model management."
},
{
"author": "romandev-codex",
"title": "ComfyUI-Downloader [NAME CONFLICT]",
@@ -4475,16 +4486,6 @@
"install_type": "git-clone",
"description": "This repository contains custom nodes designed for the ComfyUI framework, focusing on simplification improvements. These nodes aim to make tasks easier and simple"
},
{
"author": "SushantSingh-23-01",
"title": "comfyui_flowrider_nodes [UNSAFE]",
"reference": "https://github.com/SushantSingh-23-01/comfyui_flowrider_nodes",
"files": [
"https://github.com/SushantSingh-23-01/comfyui_flowrider_nodes"
],
"install_type": "git-clone",
"description": "Collection of nodes performing random utility functions like: JPG Saver - Save images in jpg format. Resolution Selector - Selects from standard resolutions with aspect ratios.[w/This extension has a vulnerability that allows arbitrary access to local files from remote.]"
},
{
"author": "YokoYokoTEC",
"title": "comfyui-sequential-prompt-file [UNSAFE]",
@@ -4815,16 +4816,6 @@
"install_type": "git-clone",
"description": "Collect Vram ram and Time\nNOTE: The files in the repo are not organized."
},
{
"author": "z604159435g",
"title": "comfyui_random_prompt_plugin [WIP]",
"reference": "https://github.com/z604159435g/comfyui_random_prompt_plugin",
"files": [
"https://github.com/z604159435g/comfyui_random_prompt_plugin"
],
"install_type": "git-clone",
"description": "A ComfyUI natural language prompt plugin specifically designed for generating realistic photos of Caucasian women. Completely restructured to produce smooth natural language paragraphs instead of CLIP-style formatting.\nNOTE: The files in the repo are not organized."
},
{
"author": "edisonchan",
"title": "ComfyUI-Sysinfo",
@@ -7649,16 +7640,6 @@
"install_type": "git-clone",
"description": "NODES: GagaGetFileList, GagaGetStringListSize, GagaSplitStringToList, GagaTest, GagaBatchStringReplace"
},
{
"author": "ftechmax",
"title": "ComfyUI-NovaKit-Pack",
"reference": "https://github.com/ftechmax/ComfyUI-NovaKit-Pack",
"files": [
"https://github.com/ftechmax/ComfyUI-NovaKit-Pack"
],
"install_type": "git-clone",
"description": "NODES: Count Tokens"
},
{
"author": "jtydhr88",
"title": "ComfyUI Frontend Vue Basic [WIP]",
@@ -8420,16 +8401,6 @@
"description": "NODES: CropTransparent, RestoreCrop, ExpandMultiple, CropReapply",
"install_type": "git-clone"
},
{
"author": "AkiEvansDev",
"title": "ComfyUI-Tools",
"reference": "https://github.com/AkiEvansDev/ComfyUI-Tools",
"files": [
"https://github.com/AkiEvansDev/ComfyUI-Tools"
],
"install_type": "git-clone",
"description": "Custom nodes for basic actions."
},
{
"author": "longzoho",
"title": "ComfyUI-Qdrant-Saver",
File diff suppressed because it is too large Load Diff
+1432 -1452
View File
File diff suppressed because it is too large Load Diff
+380
View File
@@ -1,5 +1,385 @@
{
"custom_nodes": [
{
"author": "mahshid1378",
"title": "ThinkSound_Wrapper [REMOVED]",
"reference": "https://github.com/mahshid1378/ThinkSound_Wrapper",
"files": [
"https://github.com/mahshid1378/ThinkSound_Wrapper"
],
"install_type": "git-clone",
"description": "A ComfyUI wrapper implementation of ThinkSound - an advanced AI model for generating high-quality audio from text descriptions and video content using Chain-of-Thought reasoning."
},
{
"author": "granddyser",
"title": "Wan-video-extender [REMOVED]",
"reference": "https://github.com/Granddyser/wan-video-extender",
"files": [
"https://github.com/Granddyser/wan-video-extender"
],
"install_type": "git-clone",
"description": "Wan Video Extender extends a short video or a single image into a longer, temporally consistent clip using a VACE based control latent. It runs in multiple extension loops. Each loop can have its own prompt and optional LoRA, so you can evolve the scene step by step without rebuilding the graph."
},
{
"author": "Bharanidharan",
"title": "faceExtractor for ComfyUI [REMOVED]",
"reference": "https://github.com/llikethat/ComfyUI-faceExtractor",
"files": [
"https://github.com/llikethat/ComfyUI-faceExtractor"
],
"install_type": "git-clone",
"description": "Custom Node to detect face matching the input reference image."
},
{
"author": "SushantSingh-23-01",
"title": "comfyui_flowrider_nodes [UNSAFE] [REMOVED]",
"reference": "https://github.com/SushantSingh-23-01/comfyui_flowrider_nodes",
"files": [
"https://github.com/SushantSingh-23-01/comfyui_flowrider_nodes"
],
"install_type": "git-clone",
"description": "Collection of nodes performing random utility functions like: JPG Saver - Save images in jpg format. Resolution Selector - Selects from standard resolutions with aspect ratios.[w/This extension has a vulnerability that allows arbitrary access to local files from remote.]"
},
{
"author": "xuxiao305",
"title": "ComfyUI-MultiModel3D [REMOVED]",
"reference": "https://github.com/xuxiao305/ComfyUI-MultiModel3D",
"files": [
"https://github.com/xuxiao305/ComfyUI-MultiModel3D"
],
"install_type": "git-clone",
"description": "ComfyUI custom nodes for merging multiple GLB files and interactively controlling sub-models within a single GLB."
},
{
"author": "xuxiao305",
"title": "ComfyUI-ImageBatchUtils [REMOVED]",
"reference": "https://github.com/xuxiao305/ComfyUI-ImageBatchUtils",
"files": [
"https://github.com/xuxiao305/ComfyUI-ImageBatchUtils"
],
"install_type": "git-clone",
"description": "Lightweight ComfyUI custom nodes for working with image and mask batches. No dependencies beyond ComfyUI itself."
},
{
"author": "kramins",
"title": "Power Prompt [REMOVED]",
"reference": "https://github.com/Kramins/comfyui-power-prompt",
"files": [
"https://github.com/Kramins/comfyui-power-prompt"
],
"install_type": "git-clone",
"description": "ComfyUI node for building rich, conditional prompts with a variable system, weighted random selection, tag-based filtering, and Jinja2 templating."
},
{
"author": "heyzne",
"title": "ComfyUI_Element_high [REMOVED]",
"reference": "https://github.com/heyzne/ComfyUI_Element_high",
"files": [
"https://github.com/heyzne/ComfyUI_Element_high"
],
"install_type": "git-clone",
"description": "Mask-guided precision merge plugin for ComfyUI. Fixes skin misalignment and edge artifacts in crop-and-inpaint workflows by only replacing pixels inside the mask region."
},
{
"author": "ekkonwork",
"title": "ComfyUI-Krea2-FlowLab [REMOVED]",
"reference": "https://github.com/ekkonwork/ComfyUI-Krea2-FlowLab",
"files": [
"https://github.com/ekkonwork/ComfyUI-Krea2-FlowLab"
],
"install_type": "git-clone",
"description": "Experimental Krea-2-specific sampling nodes for ComfyUI with advanced solver using Adams-Bashforth-2 history correction and curvature-aware flow integration. (Description by CC)"
},
{
"author": "bkpaine1",
"title": "Halo-Lipsy [REMOVED]",
"id": "halo-lipsy",
"reference": "https://github.com/bkpaine1/Halo-Lipsy",
"files": [
"https://github.com/bkpaine1/Halo-Lipsy"
],
"install_type": "git-clone",
"description": "AMD unified memory lip sync for ComfyUI. Native Wav2Lip inference with no subprocesses - finally works on Strix Halo, ROCm, and all AMD APUs. Features sync tuning, edge blending, and safe tensor casting for unified memory systems."
},
{
"author": "ArtifyGames",
"title": "comfyui-artify [REMOVED]",
"reference": "https://github.com/ArtifyGames/comfyui-artify",
"files": [
"https://github.com/ArtifyGames/comfyui-artify"
],
"install_type": "git-clone",
"description": "ComfyUI Artify custom nodes. Includes a robust image resize node with single-pass scale_by behavior and configurable divisible rounding."
},
{
"author": "xiaogui8dangjia",
"title": "Comfyui-imagetoSTL [REMOVED]",
"reference": "https://github.com/xiaogui8dangjia/Comfyui-imagetoSTL",
"files": [
"https://github.com/xiaogui8dangjia/Comfyui-imagetoSTL"
],
"install_type": "git-clone",
"description": "comfyui_imagetostl is a simple node for ComfyUI that converts grayscale images to STL."
},
{
"author": "g7b2",
"title": "ComfyUI-MultiCheckpoint-Tester [REMOVED]",
"reference": "https://github.com/g7b2/ComfyUI-MultiCheckpoint-Tester",
"files": [
"https://github.com/g7b2/ComfyUI-MultiCheckpoint-Tester"
],
"install_type": "git-clone",
"description": "A powerful ComfyUI custom node for model comparison testing supporting 1-5 checkpoint models with automatic VRAM management and comparison image generation. (Description by CC)"
},
{
"author": "g7b2",
"title": "ComfyUI-Artist-Tester [REMOVED]",
"reference": "https://github.com/g7b2/ComfyUI-Artist-Tester",
"files": [
"https://github.com/g7b2/ComfyUI-Artist-Tester"
],
"install_type": "git-clone",
"description": "A dedicated suite of custom nodes for batch testing artists, styles, and prompts in ComfyUI, solving the caching problem with anti-cache logic and dynamic filename generation."
},
{
"author": "randomname124290358349",
"title": "comfyui-singlepose-clone [REMOVED]",
"reference": "https://github.com/randomname124290358349/comfyui-singlepose-clone",
"files": [
"https://github.com/randomname124290358349/comfyui-singlepose-clone"
],
"install_type": "git-clone",
"description": "Standalone bundle for the single-pose character clone workflow built on Qwen-Image-Edit."
},
{
"author": "GeneralShan",
"title": "FLUX.2 SDNQ [REMOVED]",
"reference": "https://github.com/GeneralShan/comfyui-flux2-sdnq",
"files": [
"https://github.com/GeneralShan/comfyui-flux2-sdnq"
],
"install_type": "git-clone",
"description": "ComfyUI extension for FLUX.2 with SDNQ quantization."
},
{
"author": "GeneralShan",
"title": "comfyui-zimage-sdnq [REMOVED]",
"reference": "https://github.com/GeneralShan/comfyui-zimage-sdnq",
"files": [
"https://github.com/GeneralShan/comfyui-zimage-sdnq"
],
"install_type": "git-clone",
"description": "Custom ComfyUI nodes for running Z-Image Turbo with SDNQ quantization (q8/q4). Supports text-to-image, image-to-image, inpaint, ControlNet, and LoRA."
},
{
"author": "aarnoatchi",
"title": "comfyui-vto [REMOVED]",
"reference": "https://github.com/aarnoatchi/comfyui-vto",
"files": [
"https://github.com/aarnoatchi/comfyui-vto"
],
"install_type": "git-clone",
"description": "A ComfyUI custom node for photorealistic virtual try-on generation using Google's Gemini API with flexible image inputs and customizable prompts. (Description by CC)"
},
{
"author": "z604159435g",
"title": "comfyui_random_prompt_plugin [WIP] [REMOVED]",
"reference": "https://github.com/z604159435g/comfyui_random_prompt_plugin",
"files": [
"https://github.com/z604159435g/comfyui_random_prompt_plugin"
],
"install_type": "git-clone",
"description": "A ComfyUI natural language prompt plugin specifically designed for generating realistic photos of Caucasian women. Completely restructured to produce smooth natural language paragraphs instead of CLIP-style formatting.\nNOTE: The files in the repo are not organized."
},
{
"author": "ftechmax",
"title": "ComfyUI-NovaKit-Pack [REMOVED]",
"reference": "https://github.com/ftechmax/ComfyUI-NovaKit-Pack",
"files": [
"https://github.com/ftechmax/ComfyUI-NovaKit-Pack"
],
"install_type": "git-clone",
"description": "NODES: Count Tokens"
},
{
"author": "yichengup",
"title": "ComfyUI_Yc_JanusPro [REMOVED]",
"reference": "https://github.com/yichengup/ComfyUI_Yc_JanusPro",
"files": [
"https://github.com/yichengup/ComfyUI_Yc_JanusPro"
],
"install_type": "git-clone",
"description": "About DeepSeek Chat API\nGo here to register and get the api-key [a/https://platform.deepseek.com/](https://platform.deepseek.com/) Then enter api_key in config.json"
},
{
"author": "fxd0h",
"title": "ComfyUI-mflux-AnyModel [REMOVED]",
"reference": "https://github.com/fxd0h/ComfyUI-mflux-AnyModel",
"files": [
"https://github.com/fxd0h/ComfyUI-mflux-AnyModel"
],
"install_type": "git-clone",
"description": "Run any mflux/MLX model in ComfyUI on Apple Silicon, with a capability registry that forwards only the parameters each model accepts."
},
{
"author": "wxytjustb",
"title": "ComfyUI-CustomNodes [NAME CONFLICT] [REMOVED]",
"reference": "https://github.com/wxytjustb/ComfyUI-CustomNodes",
"files": [
"https://github.com/wxytjustb/ComfyUI-CustomNodes"
],
"install_type": "git-clone",
"description": "ComfyUI AI node system supporting multiple API providers including Google Gemini, OpenAI, OpenRouter, and VectorEngine with configuration-driven provider and model management."
},
{
"author": "AkiEvansDev",
"title": "ComfyUI-Tools [REMOVED]",
"reference": "https://github.com/AkiEvansDev/ComfyUI-Tools",
"files": [
"https://github.com/AkiEvansDev/ComfyUI-Tools"
],
"install_type": "git-clone",
"description": "Custom nodes for basic actions."
},
{
"author": "zn123",
"title": "ComfyUI-image-processor-zn123 [REMOVED]",
"reference": "https://github.com/zn123/ComfyUI-image-processor-zn123",
"files": [
"https://github.com/zn123/ComfyUI-image-processor-zn123"
],
"install_type": "git-clone",
"description": "Comprehensive image processing plugin for ComfyUI"
},
{
"author": "Wakapedia",
"title": "WanVideo Wakawave - Advanced LoRA & Prompt Tools [REMOVED]",
"reference": "https://github.com/Wakapedia/ComfyUI-WanVideoWakawave",
"files": [
"https://github.com/Wakapedia/ComfyUI-WanVideoWakawave"
],
"install_type": "git-clone",
"description": "Advanced LoRA management and prompt building tools for WanVideo. Features: Wakawave LoRA Loader (unlimited LoRAs, save/load presets, drag-to-reorder, per-LoRA strength control), Wakawave Prompt Builder (dual positive/negative prompts, weight control, segment mode, visual preset browser with import/export)."
},
{
"author": "ThetaCursed",
"title": "ComfyUI-HiresFix-Ultra-AllInOne [REMOVED]",
"reference": "https://github.com/ThetaCursed/ComfyUI-HiresFix-Ultra-AllInOne",
"files": [
"https://github.com/ThetaCursed/ComfyUI-HiresFix-Ultra-AllInOne"
],
"install_type": "git-clone",
"description": "Ultimate high-performance hires fix solution combining latent/model upscaling, sampling, and advanced histogram-based color correction into one unified node."
},
{
"author": "ShiRuYu",
"title": "comfyui-shiyu-tools [REMOVED]",
"reference": "https://github.com/ShiRuYu/comfyui-shiyu-tools",
"files": [
"https://github.com/ShiRuYu/comfyui-shiyu-tools"
],
"install_type": "git-clone",
"description": "ComfyUI Enterprise Plugin Framework"
},
{
"author": "r-vage",
"title": "ComfyUI SmartLML [REMOVED]",
"reference": "https://github.com/r-vage/ComfyUI_SmartLML",
"files": [
"https://github.com/r-vage/ComfyUI_SmartLML"
],
"install_type": "git-clone",
"description": "Smart Model Loader for ComfyUI — single node for vision-language models, text LLMs, and WD14 taggers across 8 backends (Transformers, GGUF, vLLM, SGLang, Ollama, llama.cpp, YOLO, WD14). Supports QwenVL, Mistral3, Florence-2, LLaVA, YOLO with multi-task chaining, few-shot training, and auto-download. V3 API + Nodes 2.0 compatible. NVIDIA/AMD/ROCm."
},
{
"author": "leewinder",
"title": "Mureka API [REMOVED]",
"reference": "https://github.com/leewinder/comfyui-mureka-api",
"files": [
"https://github.com/leewinder/comfyui-mureka-api"
],
"install_type": "git-clone",
"description": "A set of ComfyUI custom nodes allowing the use of the Mureka API in your workflows"
},
{
"author": "leewinder",
"title": "Crop To Center [REMOVED]",
"reference": "https://github.com/leewinder/comfyui-crop-to-center",
"files": [
"https://github.com/leewinder/comfyui-crop-to-center"
],
"install_type": "git-clone",
"description": "A custom node that crops images to specified dimensions from a center point with optional offset"
},
{
"author": "Haoming02",
"title": "Tab Handler [REMOVED]",
"reference": "https://github.com/Haoming02/comfyui-tab-handler",
"files": [
"https://github.com/Haoming02/comfyui-tab-handler"
],
"install_type": "git-clone",
"description": "Use the Tab key to switch between textfields"
},
{
"author": "HannibalP",
"title": "comfyui-HannibalPack [REMOVED]",
"reference": "https://github.com/HannibalP/comfyui-HannibalPack",
"files": [
"https://github.com/HannibalP/comfyui-HannibalPack"
],
"install_type": "git-clone",
"description": "This node improves the merging of LoRA for movements and physical resemblance when adding multiple LoRA to a model."
},
{
"author": "daraskme",
"title": "DARASK Nodes [REMOVED]",
"reference": "https://github.com/daraskme/comfyui_darask_node",
"files": [
"https://github.com/daraskme/comfyui_darask_node"
],
"install_type": "git-clone",
"description": "DARASK custom nodes: folder-driven image queue + EXIF/PNGinfo-driven model/LoRA/prompt restoration for upscale & hires-fix workflows, plus an aspect-ratio-annotated empty-latent preset."
},
{
"author": "DailyMok",
"title": "dAIly Prompt & Token Utils [REMOVED]",
"id": "daily-prompt-token-utils",
"reference": "https://github.com/DailyMok/ComfyUI-dAIly",
"files": [
"https://github.com/DailyMok/ComfyUI-dAIly"
],
"install_type": "git-clone",
"description": "Smart real-time token counter for CLIP-L, T5-XXL (Flux), Qwen + seed-controlled prompt mixer from custom CSV wildcards. Supports field overrides, flat/paragraph modes, thread-safe caching.",
"tags": [
"prompt",
"token",
"csv",
"wildcard",
"utils",
"text"
]
},
{
"author": "crave33",
"title": "RenesStuffDanboruTagGet [REMOVED]",
"reference": "https://github.com/crave33/RenesStuffDanbooruTagGet",
"files": [
"https://github.com/crave33/RenesStuffDanbooruTagGet"
],
"install_type": "git-clone",
"description": "generate tags / prompt from danboru image_id input"
},
{
"author": "Ben Staniford",
"title": "ComfyUI Image Switch Node [REMOVED]",
"reference": "https://github.com/benstaniford/comfy-image-switch",
"files": [
"https://github.com/benstaniford/comfy-image-switch"
],
"install_type": "git-clone",
"description": "An image source switch node for ComfyUI"
},
{
"author": "rhinoflavored",
"title": "comfyui_QT [REMOVED]",
+954 -1
View File
@@ -1,5 +1,958 @@
{
"custom_nodes": [
{
"author": "domg73",
"title": "ComfyUI-LoadVideoCrop",
"reference": "https://github.com/domg73/ComfyUI-LoadVideoCrop",
"files": [
"https://github.com/domg73/ComfyUI-LoadVideoCrop"
],
"install_type": "git-clone",
"description": "An extended Load Video node with an aspect-ratio-locked, WYSIWYG crop rectangle that outputs the cropped VIDEO."
},
{
"author": "domg73",
"title": "ComfyUI-LoadImageCrop",
"reference": "https://github.com/domg73/ComfyUI-LoadImageCrop",
"files": [
"https://github.com/domg73/ComfyUI-LoadImageCrop"
],
"install_type": "git-clone",
"description": "An extended Load Image node with an aspect-ratio-locked, WYSIWYG crop rectangle that outputs the cropped IMAGE and MASK."
},
{
"author": "MajoorWaldi",
"title": "H3 GuideMaster",
"id": "comfyui-majoor-h3-guidemaster",
"reference": "https://github.com/MajoorWaldi/ComfyUI-Majoor-H3-GuideMaster",
"files": [
"https://github.com/MajoorWaldi/ComfyUI-Majoor-H3-GuideMaster"
],
"install_type": "git-clone",
"description": "A MiniMax H3 guide, local filmstrip, and endpoint-anchor timeline for ComfyUI."
},
{
"author": "0xBeycan",
"title": "ComfyUI-PreFlight",
"reference": "https://github.com/0xBeycan/ComfyUI-PreFlight",
"files": [
"https://github.com/0xBeycan/ComfyUI-PreFlight"
],
"install_type": "git-clone",
"description": "Predict how Instagram, TikTok and X will treat your content before you post it. Local Qwen-VL observation + deterministic rules engine. No API keys, runs fully offline."
},
{
"author": "v3rm1ll1on",
"title": "ComfyUI Expert Wildcard Prompt",
"reference": "https://github.com/v3rm1ll1on/ComfyUI-Expert-Wildcard-Prompt",
"files": [
"https://github.com/v3rm1ll1on/ComfyUI-Expert-Wildcard-Prompt"
],
"install_type": "git-clone",
"description": "AST-based prompt parser with inline negative routing, smart percentage wildcards, and visual debugging"
},
{
"author": "easygoing0114",
"title": "Cliption Integrated",
"reference": "https://github.com/easygoing0114/ComfyUI-cliption-integrated",
"files": [
"https://github.com/easygoing0114/ComfyUI-cliption-integrated"
],
"install_type": "git-clone",
"description": "Integrated beam-search-only fork of pharmapsychotic's ComfyUI-Cliption, combining the CLIPtion loader and beam search into a single node"
},
{
"author": "ssain3d-lgtm",
"title": "Run Once Group Toggle",
"reference": "https://github.com/ssain3d-lgtm/comfyui-run-once-group",
"files": [
"https://github.com/ssain3d-lgtm/comfyui-run-once-group"
],
"install_type": "git-clone",
"description": "Switch chosen groups to Bypass, Mute or Active for one queue submit, then put every node back to the mode it actually had."
},
{
"author": "cpzombie",
"title": "Cpzombie",
"reference": "https://github.com/ConnorPietrasik/ComfyUI-Prompt-Rewriters",
"files": [
"https://github.com/ConnorPietrasik/ComfyUI-Prompt-Rewriters"
],
"install_type": "git-clone",
"description": "A couple nodes to rewrite prompts using your local LLM (or other api I guess) because copy/paste is a lot of work. Currently only for MiniMax H3 Text to Video and Ref to Video."
},
{
"author": "nobutakakuroki",
"title": "ComfyUI-FFT-2D",
"reference": "https://github.com/NobutakaKuroki/ComfyUI-FFT-2D",
"files": [
"https://github.com/NobutakaKuroki/ComfyUI-FFT-2D"
],
"install_type": "git-clone",
"description": "2D frequency-domain image processing nodes for ComfyUI."
},
{
"author": "cephy",
"title": "Cephy Lazy Nodes",
"reference": "https://github.com/Molinist/ComfyUI-Cephy-Lazy-Nodes",
"files": [
"https://github.com/Molinist/ComfyUI-Cephy-Lazy-Nodes"
],
"install_type": "git-clone",
"description": "Small ComfyUI nodes that remove needless workflow friction."
},
{
"author": "vjumpkung",
"title": "ComfyUI SCAIL-2 Loop Sampler",
"reference": "https://github.com/vjumpkung/comfyui-scail-2-loop-sampler",
"files": [
"https://github.com/vjumpkung/comfyui-scail-2-loop-sampler"
],
"install_type": "git-clone",
"description": "Automatic multi-segment SCAIL-2 character replacement sampling for ComfyUI."
},
{
"author": "mariobilly",
"title": "MSCH Nodes",
"reference": "https://github.com/mariobilly/msch-comfyui-nodes",
"files": [
"https://github.com/mariobilly/msch-comfyui-nodes"
],
"install_type": "git-clone",
"description": "One install for the full MSCH collection: 35 video effects, slideshow, typography, lyric-sync and MiniMax H3 nodes, plus theme and editor bridge. Replaces the separate msch-* packs."
},
{
"author": "michikora",
"title": "Autocomplete++",
"reference": "https://github.com/michikora/ComfyUI-Autocomplete-Plus-Plus",
"files": [
"https://github.com/michikora/ComfyUI-Autocomplete-Plus-Plus"
],
"install_type": "git-clone",
"description": "Tag autocomplete, prompt formatting, and dynamic prompt expansion extension for ComfyUI."
},
{
"author": "huchukato",
"title": "Perfect Video Resolution",
"id": "perfect-video-resolution",
"reference": "https://github.com/huchukato/ComfyUI-PerfectVideoResolution",
"files": [
"https://github.com/huchukato/ComfyUI-PerfectVideoResolution"
],
"install_type": "git-clone",
"description": "Automatically calculates and applies the optimal resolution for Wan 2.2, MiniMax H3, and LTX video generation models based on aspect ratio and model requirements."
},
{
"author": "modelscope",
"title": "DiffSynth-ComfyUI",
"reference": "https://github.com/modelscope/DiffSynth-ComfyUI",
"files": [
"https://github.com/modelscope/DiffSynth-ComfyUI"
],
"install_type": "git-clone",
"description": "A ComfyUI custom node plugin for DiffSynth-Studio, enabling low-VRAM inference through configurable VRAM management, NF4 quantization, and model offloading. Supports image, video, and audio generation and editing across 20+ diffusion models."
},
{
"author": "loyal1365s",
"title": "ComfyUI Smooth Organizer",
"reference": "https://github.com/loyal1365s/comfyui-smooth-organizer",
"files": [
"https://github.com/loyal1365s/comfyui-smooth-organizer"
],
"install_type": "git-clone",
"description": "A professional extension to automatically arrange nodes with Grid and Flow layouts, fully respecting groups."
},
{
"author": "qjxmgs",
"title": "ComfyUI-Prompt-Weaver",
"id": "comfyui-prompt-weaver",
"reference": "https://github.com/qjxmgs/ComfyUI-Prompt-Weaver",
"files": [
"https://github.com/qjxmgs/ComfyUI-Prompt-Weaver"
],
"install_type": "git-clone",
"description": "Prompt management extension providing a prompt toggle grid, global archives, tag autocomplete, localization, and a desktop workflow bridge for ComfyUI."
},
{
"author": "Rimor-dev",
"title": "ComfyUI-CFG-Sculptor",
"reference": "https://github.com/Rimor-dev/ComfyUI-CFG-Sculptor",
"files": [
"https://github.com/Rimor-dev/ComfyUI-CFG-Sculptor"
],
"install_type": "git-clone",
"description": "Dynamic CFG sculpting with envelopes, waveforms, hysteresis, and snapshots"
},
{
"author": "yuyuki",
"title": "MiniMax H3 Novel Pipeline",
"id": "comfyui-minimax_h3_novel_pipeline",
"reference": "https://github.com/yuyuki/ComfyUI-Minimax_H3_Novel_Pipeline",
"files": [
"https://github.com/yuyuki/ComfyUI-Minimax_H3_Novel_Pipeline"
],
"install_type": "git-clone",
"description": "ComfyUI nodes that turn novel chapters into continuity-aware MiniMax H3 prompts using an LM Studio OpenAI-compatible server."
},
{
"author": "inspectagadget",
"title": "Character Architect",
"reference": "https://github.com/InspectaGadget/ComfyUI-CharacterArchitect",
"files": [
"https://github.com/InspectaGadget/ComfyUI-CharacterArchitect"
],
"install_type": "git-clone",
"description": "A ComfyUI character prompt builder with high ethnicity adherence for Krea 2, conflict-aware randomization, and Identity Forge compatibility."
},
{
"author": "ComfyUI Palette Toolkit contributors",
"title": "comfyui-palette-toolkit",
"reference": "https://github.com/CrimsonEarth/ComfyUI-Palette-Toolkit",
"files": [
"https://github.com/CrimsonEarth/ComfyUI-Palette-Toolkit"
],
"install_type": "git-clone",
"description": "Model-independent hex palette construction and perceptual image harmonization for ComfyUI."
},
{
"author": "ZeronoFreya",
"title": "comfyui-friendly-string",
"reference": "https://github.com/ZeronoFreya/ComfyUI-Friendly-String",
"files": [
"https://github.com/ZeronoFreya/ComfyUI-Friendly-String"
],
"install_type": "git-clone",
"description": "ComfyUI plugin providing named-field string formatting nodes that support template embedding, allowing flexible field binding by name rather than input order. (Description by CC)"
},
{
"author": "nobutakakuroki",
"title": "ComfyUI-Wavelet-2D",
"reference": "https://github.com/NobutakaKuroki/comfyui-wavelet-2d",
"files": [
"https://github.com/NobutakaKuroki/comfyui-wavelet-2d"
],
"install_type": "git-clone",
"description": "2D Discrete Wavelet Transform (DWT) nodes for ComfyUI. Requires comfyui-signed-image (install separately)."
},
{
"author": "palealloy",
"title": "ComfyUI-DynamicPipe",
"reference": "https://github.com/palealloy2999-prog/ComfyUI-DynamicPipe",
"files": [
"https://github.com/palealloy2999-prog/ComfyUI-DynamicPipe"
],
"install_type": "git-clone",
"description": "Pack and unpack any number of typed values through a dynamic pipe in ComfyUI."
},
{
"author": "local",
"title": "ComfyUI-Spectrum-Anima",
"reference": "https://github.com/Nif00/ComfyUI-Spectrum-Anima",
"files": [
"https://github.com/Nif00/ComfyUI-Spectrum-Anima"
],
"install_type": "git-clone",
"description": "Spectrum spectral feature forecasting for native ComfyUI Anima models."
},
{
"author": "ethanfel",
"title": "Meta SAM-Audio",
"id": "comfyui-meta-sam-audio",
"version": "0.3.0",
"reference": "https://github.com/ethanfel/ComfyUI-SAM-Audio",
"files": [
"https://github.com/ethanfel/ComfyUI-SAM-Audio"
],
"install_type": "git-clone",
"description": "Meta SAM-Audio sound separation with text, time-span, and visual-mask prompts, ComfyUI model management, chunked processing, and optional Comfy Kitchen attention."
},
{
"author": "ivellioscolin",
"title": "ComfyUI-Auth",
"reference": "https://github.com/ivellioscolin/comfyui-auth",
"files": [
"https://github.com/ivellioscolin/comfyui-auth"
],
"install_type": "git-clone",
"description": "ComfyUI custom node for local account and LDAP based authentication"
},
{
"author": "VRAM-Hoarder",
"title": "ComfyUI Free Model and Node Cache",
"reference": "https://github.com/VRAM-Hoarder/ComfyUI-Free_model_and_node_cache",
"files": [
"https://github.com/VRAM-Hoarder/ComfyUI-Free_model_and_node_cache"
],
"install_type": "git-clone",
"description": "A ComfyUI custom node that replicates the function of the -Free model and node cache- toolbar button, usable INLINE in a workflow, so you can force a VRAM/RAM cleanup automatically after every generation instead of clicking a button manually."
},
{
"author": "RyanKramer",
"title": "ComfyUI-PixelPanda",
"reference": "https://github.com/RyanKramer/ComfyUI-PixelPanda",
"files": [
"https://github.com/RyanKramer/ComfyUI-PixelPanda"
],
"install_type": "git-clone",
"description": "PixelPanda AI image tools (cloud API): remove background, AI upscale 2x/4x/8x, enhance, remove text/watermarks, prompt edit. No extra models or VRAM."
},
{
"author": "Jit-Roy",
"title": "ComfyUI-WeeLLM",
"reference": "https://github.com/Jit-Roy/ComfyUI-WeeLLM",
"files": [
"https://github.com/Jit-Roy/ComfyUI-WeeLLM"
],
"install_type": "git-clone",
"description": "Native ComfyUI integration for WeeLLM: Run multi-billion parameter diffusion models (FLUX, SD3, MiniMax) under 4GB VRAM using layer-streaming inference."
},
{
"author": "ReSerendipity",
"title": "ComfyUI-BatchPromptLoader",
"reference": "https://github.com/ReSerendipity/ComfyUI-BatchPromptLoader",
"files": [
"https://github.com/ReSerendipity/ComfyUI-BatchPromptLoader"
],
"install_type": "git-clone",
"description": "ComfyUI custom node that loads and encodes prompts from TXT files with fixed/increment/decrement/random modes for batch image generation."
},
{
"author": "SgtSauv",
"title": "SSG Smart Suite",
"id": "ComfyUI-SSG-Smart-Suite",
"reference": "https://github.com/SgtSauv/ComfyUI-SSG-Smart-Suite",
"files": [
"https://github.com/SgtSauv/ComfyUI-SSG-Smart-Suite"
],
"install_type": "git-clone",
"description": "A native, high-performance signal-routing, workflow-organization, and virtual-memory architecture for ComfyUI"
},
{
"author": "ethanfel",
"title": "H3 Prompt IDE",
"id": "h3-prompt-ide",
"reference": "https://github.com/ethanfel/ComfyUI-H3-Prompt-IDE",
"files": [
"https://github.com/ethanfel/ComfyUI-H3-Prompt-IDE"
],
"install_type": "git-clone",
"description": "VS Code-inspired MiniMax H3 prompt editor with strict prompt sections, completions, reference media previews, and plain STRING output."
},
{
"author": "artttaku",
"title": "ComfyUI Batch Video Loader",
"id": "batchvideoloader",
"reference": "https://github.com/artttaku/ComfyUI-BatchVideoLoader",
"files": [
"https://github.com/artttaku/ComfyUI-BatchVideoLoader"
],
"install_type": "git-clone",
"description": "Batch-processes every video in a folder through a single-video workflow — no loop nodes or external scripts needed. Advances to the next video each time the prompt is queued, so a whole folder can be processed by queuing N times. Also includes an 'Ensure Even Dimensions' node to prevent odd-resolution encoder crashes with yuv420p/H.264 output."
},
{
"author": "rattskr",
"title": "ComfyUI Civitai Advanced Loader",
"reference": "https://github.com/rattskr/ComfyUI_CivitaiAdvanceLoader",
"files": [
"https://github.com/rattskr/ComfyUI_CivitaiAdvanceLoader"
],
"install_type": "git-clone",
"description": "LoRA and Checkpoint loader nodes with integrated Civitai metadata, preview images, trigger word management, and a full-featured model manager UI. Features auto-fetch from Civitai API, multi-threaded downloads, retry logic, and structured metadata storage."
},
{
"author": "Winlensky",
"title": "ComfyUI Custom Nodes for Latent Diffusion (CompVis F8 Large)",
"reference": "https://github.com/Winlensky/comfyui_compvis_ldm",
"files": [
"https://github.com/Winlensky/comfyui_compvis_ldm"
],
"install_type": "git-clone",
"description": "Native custom nodes to initialize and run original Latent Diffusion Model (LDM) F8 Large architecture from CompVis with a BERT text encoder."
},
{
"author": "entropicnoise",
"title": "MiniMax H3 Combined Model Loader",
"reference": "https://github.com/entropicnoise/MiniMax-H3-Combined-Model-Loader",
"files": [
"https://github.com/entropicnoise/MiniMax-H3-Combined-Model-Loader"
],
"install_type": "git-clone",
"description": "A single ComfyUI loader node for MiniMax H3 FL2VA and REF2VA diffusion models, for combined i2i+ref2i inference using ComfyUi MiniMax H3 Image And Reference To Video node."
},
{
"author": "entropicnoise",
"title": "MiniMax H3 Combined Image And Reference to Video",
"reference": "https://github.com/entropicnoise/MiniMax-H3-Combined-Image-And-Reference-to-Video",
"files": [
"https://github.com/entropicnoise/MiniMax-H3-Combined-Image-And-Reference-to-Video"
],
"install_type": "git-clone",
"description": "A ComfyUI custom node that combines MiniMax H3 target-frame image conditioning and Ref2VA reference conditioning in a single node."
},
{
"author": "zzczzcx1",
"title": "ComfyUI-BatchAnimeTimm",
"reference": "https://github.com/zzczzcx1/ComfyUI-BatchAnimeTimm",
"files": [
"https://github.com/zzczzcx1/ComfyUI-BatchAnimeTimm"
],
"install_type": "git-clone",
"description": "Batch-tag a folder of anime images with AnimeTimm and save matching UTF-8 caption files."
},
{
"author": "wama-rohan",
"title": "Garment Recolor",
"reference": "https://github.com/wama-rohan/comfyui-garment-recolor",
"files": [
"https://github.com/wama-rohan/comfyui-garment-recolor"
],
"install_type": "git-clone",
"description": "Post-generation garment recoloring nodes for VTO pipelines. LAB-colorspace recolor with pattern/shading preservation, plus auto-harmony palette remapping for multi-color prints."
},
{
"author": "Marauberry",
"title": "comfyui-conductor",
"reference": "https://github.com/Marauberry/ComfyUI-Conductor",
"files": [
"https://github.com/Marauberry/ComfyUI-Conductor"
],
"install_type": "git-clone",
"description": "A workflow control center for modern ComfyUI."
},
{
"author": "SimpleTuner-io",
"title": "comfyui-anyflow",
"reference": "https://github.com/SimpleTuner-io/ComfyUI-AnyFlow",
"files": [
"https://github.com/SimpleTuner-io/ComfyUI-AnyFlow"
],
"install_type": "git-clone",
"description": "AnyFlow endpoint-conditioned LoRA inference for ComfyUI"
},
{
"author": "luxu1999",
"title": "ComfyUI-ListUnwrap",
"reference": "https://github.com/luxu1999/ComfyUI-ListUnwrap",
"files": [
"https://github.com/luxu1999/ComfyUI-ListUnwrap"
],
"install_type": "git-clone",
"description": "Tiny ComfyUI helper nodes: ListToAudio + ConcatImageBatches for multi-segment video stitching"
},
{
"author": "zerohackz",
"title": "MiniMax H3 - Resolution Selector (ZeroHackz)",
"reference": "https://github.com/ZeroHackz/ComfyUI-ResolutionSelector",
"files": [
"https://github.com/ZeroHackz/ComfyUI-ResolutionSelector"
],
"install_type": "git-clone",
"description": "Quick resolution, orientation, and duration selector for MiniMax H3. Outputs width, height, and frame count as integers."
},
{
"author": "nynxz",
"title": "Nynxz H3",
"reference": "https://github.com/Nynxz/ComfyUI-NynxzH3",
"files": [
"https://github.com/Nynxz/ComfyUI-NynxzH3"
],
"install_type": "git-clone",
"description": "MiniMax H3 for ComfyUI: one drag-and-drop input node covering t2va/fl2va/ref2va, plus step caching, partial decode and profiling."
},
{
"author": "Brioch",
"title": "MiniMax H3 Preview",
"reference": "https://github.com/Brioch/ComfyUI-MiniMaxH3-Preview",
"files": [
"https://github.com/Brioch/ComfyUI-MiniMaxH3-Preview"
],
"install_type": "git-clone",
"description": "Live preview nodes for MiniMax H3 — hear the shot's stereo audio while it denoises, which ComfyUI's own preview discards."
},
{
"author": "benjiyaya",
"title": "M3 SongPlanner",
"reference": "https://github.com/benjiyaya/ComfyUI-MiniMax-M3-SongPlanner",
"files": [
"https://github.com/benjiyaya/ComfyUI-MiniMax-M3-SongPlanner"
],
"install_type": "git-clone",
"description": "Turn creative ideas into Music 3 caption + lyrics via native VLM generation"
},
{
"author": "nobutakakuroki",
"title": "ComfyUI-Latent-Viewer",
"reference": "https://github.com/NobutakaKuroki/comfyui-latent-viewer",
"files": [
"https://github.com/NobutakaKuroki/comfyui-latent-viewer"
],
"install_type": "git-clone",
"description": "Visualize every channel of a LATENT as a tiled IMAGE (or an IMAGE batch) in ComfyUI."
},
{
"author": "nobutakakuroki",
"title": "ComfyUI-Signed-Image",
"reference": "https://github.com/NobutakaKuroki/comfyui-signed-image",
"files": [
"https://github.com/NobutakaKuroki/comfyui-signed-image"
],
"install_type": "git-clone",
"description": "SIGNED_IMAGE - a zero-centered counterpart to ComfyUI's IMAGE - plus conversion and preview nodes."
},
{
"author": "Mistralys",
"title": "comfyui-random-nodes",
"reference": "https://github.com/Mistralys/comfyui-random-nodes",
"files": [
"https://github.com/Mistralys/comfyui-random-nodes"
],
"install_type": "git-clone",
"description": "ComfyUI V3 custom nodes to generate random integer and float values."
},
{
"author": "capitan01r",
"title": "ComfyUI-SigmaSync-LoRA",
"reference": "https://github.com/capitan01R/ComfyUI-SigmaSync-LoRA",
"files": [
"https://github.com/capitan01R/ComfyUI-SigmaSync-LoRA"
],
"install_type": "git-clone",
"description": "Sigma-aware model LoRA strength scheduling for ComfyUI"
},
{
"author": "liconstudio",
"title": "ComfyUI-LTX2.5-MSR",
"reference": "https://github.com/liconstudio/ComfyUI-LTX2.5-MSR",
"files": [
"https://github.com/liconstudio/ComfyUI-LTX2.5-MSR"
],
"install_type": "git-clone",
"description": "Multi-reference image conditioning nodes for LTX-2.5 in ComfyUI, using MSR slot embeddings and negative temporal offsets."
},
{
"author": "gianlucamancuso",
"title": "TiledUpscale",
"reference": "https://github.com/GianlucaMancuso/ComfyUI-TiledUpscale",
"files": [
"https://github.com/GianlucaMancuso/ComfyUI-TiledUpscale"
],
"install_type": "git-clone",
"description": "Tiled upscale and refine for reference-latent edit models (FLUX.2 klein and similar): regenerate detail beyond the resolution a model can handle in one pass, without visible seams."
},
{
"author": "dreevelle",
"title": "ComfyUI-ResolutionTools",
"reference": "https://github.com/dreevelle/ComfyUI-ResolutionTools",
"files": [
"https://github.com/dreevelle/ComfyUI-ResolutionTools"
],
"install_type": "git-clone",
"description": "Resolution maths for latent-space models, done exactly. Real megapixels (10^6, not 1024^2), patch-grid alignment for Krea 2 / MiniMax H3 / Flux / SDXL, and aspect ratios held exact instead of drifting on rounding."
},
{
"author": "local",
"title": "H3 Ref2VA Accelerator",
"reference": "https://github.com/BMB12d3/ComfyUI-H3-Ref2VA-Accelerator",
"files": [
"https://github.com/BMB12d3/ComfyUI-H3-Ref2VA-Accelerator"
],
"install_type": "git-clone",
"description": "Production-oriented quality-first block accelerator for native ComfyUI MiniMax H3 Ref2VA"
},
{
"author": "vjumpkung",
"title": "ComfyUI Wan Animate 2 Loop Sampler",
"reference": "https://github.com/vjumpkung/comfyui-wan-animate-2-loop-sampler",
"files": [
"https://github.com/vjumpkung/comfyui-wan-animate-2-loop-sampler"
],
"install_type": "git-clone",
"description": "Automatic multi-segment Wan Animate 2 sampling for ComfyUI."
},
{
"author": "ethanfel",
"title": "ComfyUI-MiniMaxH3-PerRowMasking",
"reference": "https://github.com/ethanfel/ComfyUI-MiniMaxH3-PerRowMasking",
"files": [
"https://github.com/ethanfel/ComfyUI-MiniMaxH3-PerRowMasking"
],
"install_type": "git-clone",
"description": "Temporary, experimental ComfyUI nodes implementing AbleJones's mixed-timestep generation-mask fix for MiniMax H3 without modifying ComfyUI core files."
},
{
"author": "heyheywoah",
"title": "WGC Nodes",
"reference": "https://github.com/heyheywoah/WGC-Nodes",
"files": [
"https://github.com/heyheywoah/WGC-Nodes"
],
"install_type": "git-clone",
"description": "Pack labeled text through one clean ComfyUI connection and automatically rename unpacked outputs to match."
},
{
"author": "rockerBOO",
"title": "h3-latent-upscaler",
"reference": "https://github.com/rockerBOO/h3-latent-upscaler",
"files": [
"https://github.com/rockerBOO/h3-latent-upscaler"
],
"install_type": "git-clone",
"description": "MiniMax H3 latent spatial upscale nodes for ComfyUI two-pass video workflows"
},
{
"author": "carasibana",
"title": "ComfyUI-H3-FaceRefine",
"reference": "https://github.com/Carasibana/ComfyUI-H3-FaceRefine",
"files": [
"https://github.com/Carasibana/ComfyUI-H3-FaceRefine"
],
"install_type": "git-clone",
"description": "Refine and improve the quality of small faces in MiniMax H3 video: per-frame face tracking, normalised crops, AV-latent injection and feathered stitch-back."
},
{
"author": "iljung1106",
"title": "Krea2 NAG Negative Prompt",
"reference": "https://github.com/iljung1106/ComfyUI-Krea2-NAG",
"files": [
"https://github.com/iljung1106/ComfyUI-Krea2-NAG"
],
"install_type": "git-clone",
"description": "Normalized Attention Guidance for Krea2 and Krea2Edit in ComfyUI."
},
{
"author": "Newaiguy",
"title": "MiniMax H3 Memory Efficient Sage Attention (AMD gfx12)",
"reference": "https://github.com/Newaiguy/ComfyUI-h3_sage_amd",
"files": [
"https://github.com/Newaiguy/ComfyUI-h3_sage_amd"
],
"install_type": "git-clone",
"description": "AMD gfx12 (RX 9070/9070 XT) memory-efficient Sage Attention for MiniMax H3. Uses native HIP kernel, saves 33% VRAM. Mirrors KJNodes NVIDIA patch."
},
{
"author": "bennydaball",
"title": "Latent-Tiled PiD",
"reference": "https://github.com/BennyDaBall930/ComfyUI-Latent-Tiled-PiD",
"files": [
"https://github.com/BennyDaBall930/ComfyUI-Latent-Tiled-PiD"
],
"install_type": "git-clone",
"description": "Tile the latent, not the pixels: latent-native tiled decoding for NVIDIA PiD. Seam-free masters up to 244MP; size-picker dropdowns with render-tested presets for Krea2/qwen AND FLUX.2 Klein 4B/9B; avoids the past-envelope color collapse of single-shot decoding. No VAE re-encode round-trip."
},
{
"author": "jarart",
"title": "ComfyUIFlowFields",
"reference": "https://github.com/javierjarart/ComfyUIFlowFields",
"files": [
"https://github.com/javierjarart/ComfyUIFlowFields"
],
"install_type": "git-clone",
"description": "Flow field + particle trails custom node for ComfyUI"
},
{
"author": "hvostovsky",
"title": "DDRK Omega Sampler",
"reference": "https://github.com/HVOSTOVSKY/DDRK-Omega-Sampler",
"files": [
"https://github.com/HVOSTOVSKY/DDRK-Omega-Sampler"
],
"install_type": "git-clone",
"description": "Universal domain-adaptive sampler for ComfyUI. Flow Matching + EDM with adaptive phase routing, momentum, SABER stabilization and perceptual sharpening"
},
{
"author": "MaxKruse",
"title": "ComfyUI_Image_Dimension_Scaler",
"reference": "https://github.com/MaxKruse/ComfyUI_Image_Dimension_Scaler",
"files": [
"https://github.com/MaxKruse/ComfyUI_Image_Dimension_Scaler"
],
"install_type": "git-clone",
"description": "Scale image dimensions while preserving aspect ratio, with pixel alignment and optional size constraints."
},
{
"author": "mo-akkakk",
"title": "Image Crop By Mask And Resize (Multiple)",
"reference": "https://github.com/mo-akkakk/comfyui-image-crop-by-mask-multiple",
"files": [
"https://github.com/mo-akkakk/comfyui-image-crop-by-mask-multiple"
],
"install_type": "git-clone",
"description": "Crop an image/mask to its bounding box and resize, with an added `multiple` option to force output dimensions to a chosen step size."
},
{
"author": "FFFFFFpy",
"title": "comfyui-minimaxh3-adaptive-cache",
"reference": "https://github.com/FFFFFFpy/ComfyUI-MiniMaxH3-AdaptiveCache",
"files": [
"https://github.com/FFFFFFpy/ComfyUI-MiniMaxH3-AdaptiveCache"
],
"install_type": "git-clone",
"description": "Standalone content-aware tail-block cache nodes for ComfyUI MiniMax H3"
},
{
"author": "fatak98",
"title": "Eulerian Motion Fluid Animator",
"reference": "https://github.com/Fatak98/ComfyUI-Eulerian-Motion",
"files": [
"https://github.com/Fatak98/ComfyUI-Eulerian-Motion"
],
"install_type": "git-clone",
"description": "ComfyUI custom node wrapping Meta AI's Eulerian Motion Fields research model for fluid cinemagraphs."
},
{
"author": "local",
"title": "ComfyUI Force Cleanup",
"reference": "https://github.com/3289004205/ComfyUI-Force-Cleanup",
"files": [
"https://github.com/3289004205/ComfyUI-Force-Cleanup"
],
"install_type": "git-clone",
"description": "Schedule native ComfyUI model and execution-cache cleanup after a prompt."
},
{
"author": "hearmeman24",
"title": "Fast Upscale Model - HearmemanAI",
"reference": "https://github.com/Hearmeman24/ComfyUI-HearmemanAI-Upscale",
"files": [
"https://github.com/Hearmeman24/ComfyUI-HearmemanAI-Upscale"
],
"install_type": "git-clone",
"description": "A fast drop-in replacement for ComfyUI's Upscale Image (using Model). Batches frames, blends tiles on the GPU, and defaults to no tiling."
},
{
"author": "cicalooo",
"title": "Krea2-UniDepth",
"reference": "https://github.com/cicalooo/ComfyUI-Krea2-UniDepth",
"files": [
"https://github.com/cicalooo/ComfyUI-Krea2-UniDepth"
],
"install_type": "git-clone",
"description": "Krea2-UniDepth native functional depth and image-reference conditioning."
},
{
"author": "EternalShade3D",
"title": "Aspect Ratio Size Picker",
"reference": "https://github.com/EternalShade3D/ComfyUI-AspectRatioSizePicker",
"files": [
"https://github.com/EternalShade3D/ComfyUI-AspectRatioSizePicker"
],
"install_type": "git-clone",
"description": "A single, no-nonsense node to pick text-to-image dimensions from an aspect-ratio dropdown, a long-edge slider, and an invert toggle. Outputs width/height (INT) plus an info STRING that previews the resolved size, orientation and common long-edge reference sizes."
},
{
"author": "alexw5702-afk",
"title": "Krea 2 AnyPaint",
"reference": "https://github.com/alexw5702-afk/krea2-anypaint",
"files": [
"https://github.com/alexw5702-afk/krea2-anypaint"
],
"install_type": "git-clone",
"description": "ComfyUI nodes for Krea 2 AnyPaint arbitrary-mask inpainting and outpainting"
},
{
"author": "ketle-man",
"title": "Chat TE",
"id": "chat-te",
"reference": "https://github.com/ketle-man/comfyui-chat-te",
"files": [
"https://github.com/ketle-man/comfyui-chat-te"
],
"install_type": "git-clone",
"description": "Chat with an already-loaded Qwen2.5-VL / Qwen3-VL text encoder instead of loading a separate LLM -- multi-turn, vision-capable chat sharing the same VRAM. Exposes both a sidebar panel and an OpenAI-compatible /v1/chat/completions endpoint."
},
{
"author": "Tutu",
"title": "ComfyUI-TutuKrea2T-Enhancer",
"reference": "https://github.com/zhaotututu/ComfyUI-TutuKrea2T-Enhancer",
"files": [
"https://github.com/zhaotututu/ComfyUI-TutuKrea2T-Enhancer"
],
"install_type": "git-clone",
"description": "Tutu-maintained Krea2 Turbo prompt-adherence enhancer for ComfyUI"
},
{
"author": "lokitsar",
"title": "Krea2 Multi-LoRA Composer",
"reference": "https://github.com/lokitsar/ComfyUI-Krea2-MultiLoRA-Composer",
"files": [
"https://github.com/lokitsar/ComfyUI-Krea2-MultiLoRA-Composer"
],
"install_type": "git-clone",
"description": "Token-aware spatial Multi-LoRA composition and transparent supersampling for Krea 2 in ComfyUI."
},
{
"author": "tritant",
"title": "ModelMerge Krea 2 + Quant (INT8/INT4)",
"reference": "https://github.com/tritant/ComfyUI_Krea2_ModelMerger_Converter_int8_int4",
"files": [
"https://github.com/tritant/ComfyUI_Krea2_ModelMerger_Converter_int8_int4"
],
"install_type": "git-clone",
"description": "This node lets you merge two full Krea 2 bf16 models and up to three LoRAs and quantize to int8/int4"
},
{
"author": "Burgstall-labs",
"title": "ComfyUI-SAM3D-BodyMod",
"reference": "https://github.com/Burgstall-labs/ComfyUI-SAM3D-BodyMod",
"files": [
"https://github.com/Burgstall-labs/ComfyUI-SAM3D-BodyMod"
],
"install_type": "git-clone",
"description": "Fit, stylize, transfer, and camera-drive SAM3D Body MHR pose data in ComfyUI."
},
{
"author": "lvquan",
"title": "CS Hybrid 4K Image-to-Video",
"reference": "https://github.com/lvqunx/comfyui-cs-hybrid",
"files": [
"https://github.com/lvqunx/comfyui-cs-hybrid"
],
"install_type": "git-clone",
"description": "Compressed-sensing hybrid 4K image-to-video: RAFT flow transfer, dual-channel Laplacian recompose, CS tile scheduler, keyframe refresh helpers."
},
{
"author": "redwing",
"title": "ComfyUI-StringKit",
"reference": "https://github.com/luku756/ComfyUI-StringKit",
"files": [
"https://github.com/luku756/ComfyUI-StringKit"
],
"install_type": "git-clone",
"description": "String utility nodes for ComfyUI: attach titles to prompts, loop them through your workflow, assemble save paths, and derive a distinct seed per image."
},
{
"author": "artfat-creator",
"title": "Artfat Resolution",
"reference": "https://github.com/artfat-creator/ComfyUI-Artfat-Resolution",
"files": [
"https://github.com/artfat-creator/ComfyUI-Artfat-Resolution"
],
"install_type": "git-clone",
"description": "Resolution picker for low-base + upscale workflows: preset sizes, custom ratio or exact manual W×H, two upscale stages (base → mid → final), plus an empty LATENT and a live readout. Built for Krea 2 / Selfism."
},
{
"author": "Latentnaut",
"title": "comfyui-311-inpaint",
"reference": "https://github.com/Latentnaut/ComfyUI-311-Inpaint",
"files": [
"https://github.com/Latentnaut/ComfyUI-311-Inpaint"
],
"install_type": "git-clone",
"description": "Inpaint helper nodes for ComfyUI (311 series). First node: Add Margins — pad an image and its mask with extra width/height, centered on a configurable fill color."
},
{
"author": "aoiro0x0",
"title": "Interval Timer",
"reference": "https://github.com/aoiro0X0/ComfyUI-Interval-Timer",
"files": [
"https://github.com/aoiro0X0/ComfyUI-Interval-Timer"
],
"install_type": "git-clone",
"description": "Measure the combined wall-clock time of a section of ComfyUI nodes."
},
{
"author": "kasunmalinda",
"title": "ComfyUI-KM-VideoReview",
"reference": "https://github.com/KasunMalinda/ComfyUI-KM-VideoReview",
"files": [
"https://github.com/KasunMalinda/ComfyUI-KM-VideoReview"
],
"install_type": "git-clone",
"description": "RV-style interactive A/B video review player node for ComfyUI - wipe, side-by-side, difference, frame-accurate transport"
},
{
"author": "sparknight",
"title": "Lazy Fallback Switch",
"reference": "https://github.com/SparknightLLC/ComfyUI-LazyFallbackSwitch",
"files": [
"https://github.com/SparknightLLC/ComfyUI-LazyFallbackSwitch"
],
"install_type": "git-clone",
"description": "Lazy indexed switches with a default route for ComfyUI."
},
{
"author": "wcde",
"title": "Ideogram 4 VAE Coordinate Bridge",
"reference": "https://github.com/wcde/ComfyUI-Ideogram4-VAE-Coordinate-Bridge",
"files": [
"https://github.com/wcde/ComfyUI-Ideogram4-VAE-Coordinate-Bridge"
],
"install_type": "git-clone",
"description": "A standalone ComfyUI node that bridges Ideogram 4 model latents to the connected Flux.2 VAE normalization."
},
{
"author": "DenRakEiw",
"title": "ComfyGotchi",
"reference": "https://github.com/DenRakEiw/ComfyGotchi",
"files": [
"https://github.com/DenRakEiw/ComfyGotchi"
],
"install_type": "git-clone",
"description": "A Tamagotchi that lives inside ComfyUI — feeds on your AI slop, evolves, feels love from API nodes."
},
{
"author": "ANe5s",
"title": "ComfyUI MiniMax H3 Hybrid",
"id": "comfyui-minimax-h3-hybrid",
"reference": "https://github.com/ANe5s/ComfyUI-MiniMax-H3-Hybrid",
"files": [
"https://github.com/ANe5s/ComfyUI-MiniMax-H3-Hybrid"
],
"install_type": "git-clone",
"description": "ComfyUI node for loading a MiniMax H3 FL2VA/Ref2VA hybrid model with ComfyUI DynamicVRAM/AIMDO-aware loading and matched checkpoint memory behavior."
},
{
"author": "CocyNoric",
"title": "ComfyUI-ToriiGate-Reforged",
"reference": "https://github.com/CocyNoric/ComfyUI-ToriiGate-Reforged",
"files": [
"https://github.com/CocyNoric/ComfyUI-ToriiGate-Reforged"
],
"install_type": "git-clone",
"description": "A ComfyUI custom node pack for image captioning and text generation with ToriiGate-0.5, a fine-tuned Qwen3.5-4B model."
},
{
"author": "Andy294753951",
"title": "ComfyUI-Flow-Wrangler",
"reference": "https://github.com/Andy294753951/ComfyUI-Flow-Wrangler",
"files": [
"https://github.com/Andy294753951/ComfyUI-Flow-Wrangler"
],
"install_type": "git-clone",
"description": "Frontend-only workflow productivity extension providing Alt-right-click smart linking, selection-wide smart connection, input swapping, reroute insertion, data-flow arrangement, and bypass controls."
},
{
"author": "JerryZRic",
"title": "ComfyUI MiniMax H3 Latent",
"id": "minimax-h3-latent",
"reference": "https://github.com/JerryZRic/comfyui-minimax-h3-latent",
"files": [
"https://github.com/JerryZRic/comfyui-minimax-h3-latent"
],
"install_type": "git-clone",
"description": "Save and load MiniMax H3 AV NestedTensor latents, avoiding the built-in Save Latent limitation for H3 video/audio latent pairs."
},
{
"author": "buqi-code",
"title": "buqi-minimax-h3-multigpu",
"reference": "https://github.com/buqi-code/buqi-minimax-h3-multigpu",
"files": [
"https://github.com/buqi-code/buqi-minimax-h3-multigpu"
],
"nodename_pattern": "MiniMaxH3SP.*",
"install_type": "git-clone",
"description": "Multi-GPU (Ulysses sequence-parallel) inference for MiniMax-H3: drop-in UNETLoader replacement sharding the packed video+audio sequence across 2/4/7/8 GPUs, bit-identical output. Requires ComfyUI >= 0.30.0 and Linux or WSL2 (NCCL; no native Windows)."
},
{
"author": "ukr8b3g-cmyk",
"title": "H3 Continuum",
"reference": "https://github.com/ukr8b3g-cmyk/ComfyUI-H3-Continuum",
"files": [
"https://github.com/ukr8b3g-cmyk/ComfyUI-H3-Continuum"
],
"install_type": "git-clone",
"description": "Long-form MiniMax H3 video generation for ComfyUI with AV latent continuation, reference conditioning, chunked sampling, and resume support."
},
{
"author": "JerryZRic",
"title": "ComfyUI MiniMax H3 Context IR Agent",
@@ -176,7 +1129,7 @@
"https://github.com/Zoltar358-ComfyUI/ComfyUI-Universal-Model-Loader"
],
"install_type": "git-clone",
"description": "Universal ComfyUI model loader for checkpoints, diffusers, diffusion models, UNet, GGUF, dual CLIP outputs and dual VAE outputs with dynamic UI, Registry Node Pack Info previews, and auto CLIP type detection for latest model families."
"description": "Universal ComfyUI model loader for checkpoints, diffusers, diffusion models, UNet, GGUF, dual CLIP outputs and dual VAE outputs with dynamic UI, Registry Node Pack Info previews, selected-model metadata cleanup, and auto CLIP type detection for latest model families including MiniMax Music3/MM3."
},
{
"author": "kkukshtel",
File diff suppressed because it is too large Load Diff
+42 -8
View File
@@ -455,16 +455,32 @@ except Exception as e:
def ensure_dependencies():
requirements_path = os.path.join(os.path.dirname(__file__), "requirements.txt")
try:
import git # noqa: F401
import toml # noqa: F401
import rich # noqa: F401
import chardet # noqa: F401
except ModuleNotFoundError:
my_path = os.path.dirname(__file__)
requirements_path = os.path.join(my_path, "requirements.txt")
from importlib.metadata import version
from packaging.requirements import Requirement
print("## ComfyUI-Manager: installing dependencies. (GitPython)")
# Check the declared version before loading the native extension, so an
# older nh3 can be upgraded without loading its binary into this process.
with open(requirements_path) as requirements:
for line in requirements:
line = re.split(r'\s+#', line.strip(), maxsplit=1)[0]
if not line or line.startswith(('#', '-')):
continue
nh3_requirement = Requirement(line)
if nh3_requirement.name.lower() == 'nh3':
break
else:
raise ValueError('requirements.txt does not declare nh3')
if version('nh3') not in nh3_requirement.specifier:
raise ModuleNotFoundError(str(nh3_requirement))
import nh3 # noqa: F401
except ModuleNotFoundError:
print("## ComfyUI-Manager: installing dependencies.")
try:
subprocess.check_output(manager_util.make_pip_cmd(['install', '-r', requirements_path]))
except subprocess.CalledProcessError:
@@ -472,7 +488,7 @@ def ensure_dependencies():
try:
subprocess.check_output(manager_util.make_pip_cmd(['install', '--user', '-r', requirements_path]))
except subprocess.CalledProcessError:
print("## [ERROR] ComfyUI-Manager: Failed to install the GitPython package in the correct Python environment. Please install it manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
print("## [ERROR] ComfyUI-Manager: Failed to install required packages in the correct Python environment. Please install requirements.txt manually in the appropriate environment. (You can seek help at https://app.element.io/#/room/%23comfyui_space%3Amatrix.org)")
try:
print("## ComfyUI-Manager: installing dependencies done.")
@@ -613,6 +629,12 @@ if os.path.exists(restore_snapshot_path):
if 'COMFYUI_FOLDERS_BASE_PATH' not in new_env:
new_env["COMFYUI_FOLDERS_BASE_PATH"] = comfy_path
from comfy.cli_args import args
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
new_env['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = str(
allow_flagged or manager_util.is_loopback_listener(args.listen)
).lower()
cmd_str = [sys.executable, cm_cli_path, 'restore-snapshot', restore_snapshot_path]
exit_code = process_wrap(cmd_str, custom_nodes_base_path, handler=msg_capture, env=new_env)
@@ -661,9 +683,18 @@ def execute_lazy_install_script(repo_path, executable):
process_wrap(install_cmd, repo_path, env=new_env)
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path):
def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom_nodes_path, status=''):
import uuid
import shutil
from comfy.cli_args import args
allow_flagged = default_conf.get('allow_flagged_nodepack_install', '').lower() == 'true'
if not manager_util.is_cnr_install_allowed(status or 'NodeVersionStatusFlagged', allow_flagged, args.listen):
if not status:
logging.error("Cannot execute reserved CNR switch for '%s': stored Registry status is missing. Request the installation again so its current status can be checked.", target)
else:
logging.error(manager_util.FLAGGED_NODEPACK_INSTALL_GUIDANCE)
return False
# 1. download
archive_name = f"CNR_temp_{str(uuid.uuid4())}.zip" # should be unpredictable name - security precaution
@@ -711,6 +742,8 @@ def execute_lazy_cnr_switch(target, zip_url, from_path, to_path, no_deps, custom
with open(tracking_info_file, "w", encoding='utf-8') as file:
file.write('\n'.join(list(extracted)))
return True
script_executed = False
@@ -766,8 +799,9 @@ def execute_startup_script():
execute_lazy_install_script(script[0], script[2])
elif script[1] == "#LAZY-CNR-SWITCH-SCRIPT":
execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6])
execute_lazy_install_script(script[3], script[7])
status = script[8] if len(script) > 8 else ''
if execute_lazy_cnr_switch(script[0], script[2], script[3], script[4], script[5], script[6], status):
execute_lazy_install_script(script[3], script[7])
elif script[1] == "#LAZY-DELETE-NODEPACK":
execute_lazy_delete(script[2])
+2 -2
View File
@@ -1,10 +1,10 @@
[project]
name = "comfyui-manager"
description = "ComfyUI-Manager provides features to install and manage custom nodes for ComfyUI, as well as various functionalities to assist with ComfyUI."
version = "3.41"
version = "3.42"
license = { file = "LICENSE.txt" }
requires-python = ">=3.9"
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet"]
dependencies = ["GitPython", "PyGithub", "matrix-nio", "transformers", "huggingface-hub>0.20", "typer", "rich", "typing-extensions", "toml", "uv", "chardet", "packaging", "nh3>=0.3.7"]
[project.urls]
Repository = "https://github.com/ltdrdata/ComfyUI-Manager"
+2
View File
@@ -9,3 +9,5 @@ typing-extensions
toml
uv
chardet
packaging
nh3>=0.3.7
+153
View File
@@ -0,0 +1,153 @@
{
"note": "JS<->Python sanitize_url differential case matrix. STRING inputs only: Python str() and JS String() disagree for non-strings, so feeding them would report divergence for something that is not a defect. See the docstring of tests/test_url_sanitize_parity.py.",
"provenance": "Handed over by gm3-review: 35 cases built to clear the WI-112 model-manager port, plus 22 built for the WI-120 review (scheme grammar, protocol-relative with userinfo, colons after a delimiter, unicode spaces and RTL override, degenerate schemes, a 500-char path). All 57 agreed on both implementations when handed over -- they are coverage, not known failures.",
"type_coercion_warning": {
"why": "Python str() and JS String() are NOT equivalent for non-strings. Feeding non-strings into the differential matrix produces FALSE divergence - a guard RED for a non-bug.",
"measured": {
"1.0": {
"py": "1.0",
"js": "1"
},
"True": {
"py": "True",
"js": "true"
},
"False": {
"py": "False",
"js": "false"
},
"[1,2]": {
"py": "[1, 2]",
"js": "1,2"
},
"{}": {
"py": "{}",
"js": "[object Object]"
}
},
"aligned": {
"0": "both '0'",
"None/null": "both '#' - explicit null handling in both impls"
},
"recommendation": "Scope the matrix to STRING inputs and say so in the guard docstring; url is always a string off the JSON payload in practice."
},
"shared_matrix": [
"javascript:a",
"JaVaScRiPt:a",
"java\tscript:a",
"java\nscript:a",
"\u0001javascript:a",
" javascript:a",
"data:text/html,x",
"vbscript:a",
"file:///etc/passwd",
"about:blank",
"blob:http://x",
"javascript&#58;a",
"javascript&colon;a",
"&#x6a;avascript:a",
"&#106;avascript:a",
"http://e.com",
"https://e.com/a?b=1&c=2",
"HTTPS://E.COM",
"/rel/path",
"rel",
"#anchor",
"//proto/x",
"a&b/c",
"x&y?z=1",
"e.com:8080/p",
"foo:bar",
":alert(1)",
"",
" ",
"https://e.com/x\" onmouseover=\"y",
"https://evil.example/a onmouseover=alert(1) x",
"\\\\evil.com/x",
"/\\evil.com",
"\u0000javascript:a",
"j\u0000avascript:a",
"coap+tcp://h/x",
"x-scheme://h",
"a.b://h",
"a-b://h",
"HTTP+S://h",
"//user:pass@evil.example/x",
"#a:b",
"?a=b:c",
"/p:q",
"http://h/#f:g",
"http ://h",
"javascript :a",
"ja​vascript:a",
"http://h",
"http://h/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"http://h/‮evil",
"http:",
"https:",
"http:/h",
":",
"::",
"a::b",
" /rel/path ",
" rel ",
"\t#anchor\n",
" http://h/x ",
"\thttps://h/x\t",
"http://h/x",
"/rel/path",
"https://h/x",
"",
" /rel ",
"http://h/x…",
"…http://h/x",
"http://h/x\u001c",
"http://h/x\u001d",
"http://h/x\u001e",
"http://h/x\u001f",
"/rel/path…",
"/rel/path\u001c",
"https://h/x…",
"http://h/x ",
" http://h/x",
"http://h/x ",
"http://h/x ",
"http://h/x ",
"http://h/x
",
"http://h/x
",
"http://h/x ",
"http://h/x ",
"http://h/x ",
" /rel",
"  /rel  "
],
"gap_cases_found_by_enforcement": {
"why": "Found by tests/test_url_sanitize_parity.py's own perturbation test, not by review: perturbing the JS scheme-less branch from `return raw.trim()` to `return raw` produced ZERO divergences against the handed-over 57, because every scheme-less case in that set is already trim-clean. The two trim call sites in sanitize_url were therefore unpinned on both sides.",
"cases": [
" /rel/path ",
" rel ",
"\t#anchor\n",
" http://h/x ",
"\thttps://h/x\t"
]
},
"unicode_whitespace_divergence": {
"why": "The 62-case matrix had no BOM or unicode-whitespace input, so the two trim call sites in sanitize_url were compared only over inputs whose edges are ASCII. They are NOT equivalent: String.prototype.trim() and str.strip() disagree on six codepoints.",
"measured": {
"js_strips_python_keeps": [
"U+FEFF"
],
"python_strips_js_keeps": [
"U+001C",
"U+001D",
"U+001E",
"U+001F",
"U+0085"
],
"method": "enumerated every codepoint below U+110000 on both sides: chr(c).isspace() in Python vs /\\s/-equivalent String.trim() in node; 24 shared, 6 divergent"
},
"example": "'http://h/x<U+FEFF>' -> python 'http://h/x<U+FEFF>', js 'http://h/x'",
"resolution": "ALIGNED, not accepted. glob/manager_util.py is the declared source of truth for this pair (see test_the_guard_goes_red_when_either_allow_list_gains_a_scheme, which names it), so js/common.js stopped delegating to trim() and spells Python's str.strip() set out as URL_TRIM. The divergence is therefore a REGRESSION SHAPE this matrix now catches, not a gap_case.",
"scope": "The trim only shapes the RETURNED string for an already-accepted URL. Scheme acceptance is decided on `probe`, which both sides build identically, so no case here changes what is allowed."
}
}
+400
View File
@@ -0,0 +1,400 @@
"""Real ComfyUI HTTP/queue/install tests with a local CNR service and harmless ZIPs.
Run with E2E_ROOT pointing to the existing ComfyUI + venv fixture. Each server
uses an isolated base directory; no installed nodes or user config are changed.
"""
import asyncio
import ast
import io
import json
import os
import socket
import subprocess
import threading
import time
import uuid
import zipfile
from contextlib import contextmanager
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, urlparse
import aiohttp
import pytest
import requests
E2E_ROOT = Path(os.environ.get('E2E_ROOT', '/nonexistent'))
REPO_ROOT = Path(__file__).resolve().parents[2]
pytestmark = pytest.mark.skipif(
not (E2E_ROOT / 'venv/bin/python').is_file(), reason='E2E_ROOT is required',
)
PACKS = ['fixture-active', 'fixture-flagged', 'fixture-pending', 'fixture-banned', 'fixture-latest', 'fixture-switch', 'fixture-reinstall', 'fixture-update', 'fixture-snapshot']
@pytest.fixture(scope='module')
def registry():
calls = []
class Handler(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
parsed = urlparse(self.path)
calls.append(parsed.path + ('?' + parsed.query if parsed.query else ''))
parts = parsed.path.strip('/').split('/')
version = parse_qs(parsed.query).get('version', ['2.0.0'])[0]
node = parts[1] if len(parts) > 1 else ''
base = f'http://127.0.0.1:{self.server.server_port}'
def metadata(node, version):
status = {'1.0.0': 'Active', '1.1.0': 'Active', '2.0.0': 'Flagged', '3.0.0': 'Pending'}[version]
return {'id': f'{node}-{version}', 'node_id': node, 'version': version,
'status': 'NodeVersionStatus' + status, 'dependencies': [],
'downloadUrl': f'{base}/zip/{node}/{version}'}
status = 200
if parts[0] == 'zip':
node, version = parts[1:]
archive = io.BytesIO()
with zipfile.ZipFile(archive, 'w') as z:
z.writestr('__init__.py', 'NODE_CLASS_MAPPINGS = {}\n')
z.writestr('pyproject.toml', f'[project]\nname = "{node}"\nversion = "{version}"\n')
z.writestr('install.py', 'from pathlib import Path\n'
f'Path("installed.txt").write_text("{version}")\n')
body = archive.getvalue()
elif parsed.path == '/nodes':
body = json.dumps({'nodes': [
{'id': name, 'name': name, 'description': '', 'status': 'NodeStatusActive',
'repository': f'https://example.invalid/{name}',
'publisher': {'id': 'fixture', 'name': 'Fixture'},
'latest_version': metadata(name, '2.0.0')}
for name in PACKS], 'totalPages': 1}).encode()
elif len(parts) == 3 and parts[0] == 'nodes' and parts[2] == 'install':
if node == 'fixture-banned':
status, body = 404, b'{"message":"Not found"}'
else:
body = json.dumps(metadata(node, version)).encode()
else:
status, body = 404, b'{}'
self.send_response(status)
self.send_header('Content-Length', str(len(body)))
self.send_header('Content-Type', 'application/zip' if parts[0] == 'zip' else 'application/json')
self.end_headers()
self.wfile.write(body)
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
yield SimpleNamespace(url=f'http://127.0.0.1:{server.server_port}', calls=calls)
server.shutdown()
server.server_close()
thread.join()
# Redirect only the external Registry in both ComfyUI and its restore subprocess.
REGISTRY_BOOTSTRAP = '''
import asyncio, json, os, sys, urllib.request
sys.path.insert(0, os.environ['CM_E2E_MANAGER_GLOB'])
import manager_core, cnr_utils, manager_util
cnr_utils.base_url = os.environ['CM_E2E_REGISTRY']
with urllib.request.urlopen(cnr_utils.base_url + '/nodes') as response:
manager_util.save_to_cache(cnr_utils.base_url + '/nodes', json.load(response))
asyncio.run(manager_core.unified_manager.reload('cache', dont_wait=False))
manager_core.unified_manager.custom_node_map_cache[(manager_core.normalize_channel('default'), 'cache')] = manager_core.NormalizedKeyDict()
'''
BOOTSTRAP = '''
import os, runpy, sys
sys.argv = [os.environ['CM_E2E_MAIN'], *sys.argv[1:]]
import comfy.options
comfy.options.enable_args_parsing()
''' + REGISTRY_BOOTSTRAP + "\nrunpy.run_path(sys.argv[0], run_name='__main__')\n"
@contextmanager
def running_server(root, listen, override, registry, security='normal'):
custom_nodes = root / 'custom_nodes'
custom_nodes.mkdir(exist_ok=True)
mount = custom_nodes / 'comfyui-manager'
if not mount.exists():
mount.symlink_to(REPO_ROOT, target_is_directory=True)
config_dir = root / 'user/__manager'
config_dir.mkdir(parents=True, exist_ok=True)
(config_dir / 'config.ini').write_text(
'[default]\nnetwork_mode = offline\nuse_uv = false\n'
f'security_level = {security}\nallow_flagged_nodepack_install = {override}\n'
)
# cm-cli.py inherits this test-only IO redirection from its real parent.
hook = root / 'sitecustomize.py'
hook.write_text("import os, sys\nif os.path.basename(sys.argv[0]) == 'cm-cli.py':\n"
" from comfy.cli_args import args\n"
" args.base_directory = os.environ['CM_E2E_BASE']\n"
+ '\n'.join(' ' + line for line in REGISTRY_BOOTSTRAP.splitlines()))
with socket.socket() as sock:
sock.bind(('127.0.0.1', 0))
port = sock.getsockname()[1]
base = f'http://127.0.0.1:{port}'
env = dict(os.environ, PYTHONUNBUFFERED='1',
PYTHONPATH=os.pathsep.join([str(root), str(E2E_ROOT / 'comfyui')]),
COMFYUI_PATH=str(E2E_ROOT / 'comfyui'), COMFYUI_FOLDERS_BASE_PATH=str(root),
CM_E2E_BASE=str(root), CM_E2E_MAIN=str(E2E_ROOT / 'comfyui/main.py'),
CM_E2E_MANAGER_GLOB=str(REPO_ROOT / 'glob'), CM_E2E_REGISTRY=registry.url)
command = [str(E2E_ROOT / 'venv/bin/python'), '-c', BOOTSTRAP, '--cpu',
'--base-directory', str(root), '--listen', listen, '--port', str(port),
'--database-url', f'sqlite:///{root / "comfyui.db"}']
log_path = root / ('server-' + uuid.uuid4().hex + '.log')
with log_path.open('w') as log:
process = subprocess.Popen(command, env=env, cwd=E2E_ROOT / 'comfyui', stdout=log, stderr=subprocess.STDOUT)
try:
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
assert process.poll() is None, log_path.read_text()[-6000:]
try:
if requests.get(base + '/system_stats', timeout=1).status_code == 200:
break
except requests.RequestException:
pass
time.sleep(.2)
else:
pytest.fail(log_path.read_text()[-6000:])
assert requests.get(base + '/manager/version', timeout=5).status_code == 200, log_path.read_text()[-6000:]
assert 'PRESTARTUP FAILED' not in log_path.read_text(), log_path.read_text()
yield SimpleNamespace(root=root, base=base, listen=listen, override=override,
security=security, registry=registry, log=log_path, env=env)
finally:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
@pytest.mark.parametrize('operation', ['install', 'restore-snapshot'])
def test_direct_cli_installs_flagged_without_server_options(tmp_path, registry, operation):
with running_server(tmp_path, '0.0.0.0', False, registry) as server:
env = server.env
command = [str(E2E_ROOT / 'venv/bin/python'), str(REPO_ROOT / 'cm-cli.py'), operation]
if operation == 'install':
command += ['fixture-flagged@2.0.0', '--mode', 'cache']
else:
snapshot = tmp_path / 'direct-snapshot.json'
snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-flagged': '2.0.0'},
'git_custom_nodes': {}, 'file_custom_nodes': []}))
command.append(str(snapshot))
command += ['--user-directory', str(tmp_path / 'user')]
before = len(registry.calls)
result = subprocess.run(command, env=env, cwd=E2E_ROOT / 'comfyui',
capture_output=True, text=True, timeout=60)
output = result.stdout + result.stderr
assert result.returncode == 0, output
installed = tmp_path / 'custom_nodes/fixture-flagged'
assert 'version = "2.0.0"' in (installed / 'pyproject.toml').read_text(), output
if operation == 'install':
assert (installed / 'installed.txt').read_text() == '2.0.0', output
assert sum('/install' in call for call in registry.calls[before:]) == 1
assert 'Installation of this flagged CNR version is blocked' not in output
@pytest.fixture(scope='module', params=[
('127.0.0.1', False, 'normal'), ('0.0.0.0', False, 'normal'),
('0.0.0.0', True, 'normal'), ('0.0.0.0', True, 'strong'),
('127.0.0.1', False, 'strong'),
], ids=lambda row: '-'.join(map(str, row)))
def comfy_server(request, tmp_path_factory, registry):
listen, override, security = request.param
root = tmp_path_factory.mktemp('flagged-v3')
with running_server(root, listen, override, registry, security) as server:
yield server
def install(server, node, version, operation='install'):
async def request_and_wait():
ui_id = uuid.uuid4().hex
params = {'id': node, 'version': '1.0.0', 'selected_version': version,
'mode': 'cache', 'channel': 'default', 'ui_id': ui_id,
'repository': 'https://example.invalid/fixture'}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=40)) as session:
# Subscribe first: a fast worker can emit completion before POST returns.
async with session.ws_connect(server.base + '/ws?clientId=' + ui_id) as ws:
async with session.post(server.base + '/manager/queue/' + operation, json=params) as response:
if response.status != 200:
return {'http': response.status, 'message': await response.text()}
async with session.post(server.base + '/manager/queue/start') as response:
response.raise_for_status()
while True:
event = await ws.receive_json(timeout=30)
data = event.get('data', {})
if event.get('type') == 'cm-queue-status' and data.get('status') == 'done':
if ui_id in data.get('nodepack_result', {}):
return {'http': 200, 'message': data['nodepack_result'][ui_id]}
return asyncio.run(request_and_wait())
def assert_terminal_guidance(log):
for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]',
'allow_flagged_nodepack_install = true', 'config.ini',
'trusted private network', 'Restart ComfyUI'):
assert detail in log
def assert_flagged_denial(result, log):
assert result['message'] == ('This action is not allowed by the current security configuration. '
'See the terminal for details.'), result
assert_terminal_guidance(log)
@pytest.mark.parametrize('node,version,flagged', [
('fixture-active', '1.0.0', False), ('fixture-flagged', '2.0.0', True),
('fixture-pending', '3.0.0', False), ('fixture-banned', '1.0.0', False),
('fixture-latest', 'latest', True),
])
def test_install_policy(comfy_server, node, version, flagged):
server = comfy_server
log_before = len(server.log.read_text())
before = len(server.registry.calls)
result = install(server, node, version)
calls = server.registry.calls[before:]
strong = server.security == 'strong'
allowed = not strong and node != 'fixture-banned' and (
not flagged or server.override or server.listen == '127.0.0.1')
marker = server.root / 'custom_nodes' / node / 'installed.txt'
assert marker.exists() is allowed, (result, server.log.read_text()[-6000:])
assert sum('/install' in call for call in calls) == (0 if strong else 1), calls
assert any(call.startswith('/zip/') for call in calls) is allowed
if allowed:
assert result['message'] == 'success', result
assert marker.read_text() == ('2.0.0' if version == 'latest' else version)
elif strong:
assert result['http'] == 403, result
elif flagged:
assert_flagged_denial(result, server.log.read_text()[log_before:])
@pytest.mark.parametrize('operation', ['install', 'reinstall', 'update'])
def test_existing_pack_policy(comfy_server, operation):
server = comfy_server
if server.security == 'strong':
pytest.skip('Existing-pack transitions require an installed pack')
node = {'install': 'fixture-switch', 'reinstall': 'fixture-reinstall', 'update': 'fixture-update'}[operation]
assert install(server, node, '1.0.0')['message'] == 'success'
marker = server.root / 'custom_nodes' / node / 'installed.txt'
log_before = len(server.log.read_text())
before = len(server.registry.calls)
result = install(server, node, '2.0.0', operation)
calls = server.registry.calls[before:]
allowed = server.override or server.listen == '127.0.0.1'
assert sum('/install' in call for call in calls) == 1, calls
if allowed:
assert result['message'] == 'success', result
if operation == 'reinstall':
assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:]
else:
assert marker.read_text() == '1.0.0'
assert 'NodeVersionStatusFlagged' in (server.root / 'user/__manager/startup-scripts/install-scripts.txt').read_text()
else:
assert_flagged_denial(result, server.log.read_text()[log_before:])
assert marker.read_text() == '1.0.0'
assert any(call.startswith('/zip/') for call in calls) is (allowed and operation == 'reinstall')
@pytest.mark.parametrize('restart_listen,restart_override,allowed', [
('0.0.0.0', False, False), ('0.0.0.0', True, True), ('127.0.0.1', False, True),
])
@pytest.mark.parametrize('stored_status', [True, False])
def test_restart_uses_current_policy(tmp_path, registry, restart_listen, restart_override, allowed, stored_status):
with running_server(tmp_path, '127.0.0.1', False, registry) as server:
assert install(server, 'fixture-switch', '1.0.0')['message'] == 'success'
assert install(server, 'fixture-switch', '2.0.0')['message'] == 'success'
if not stored_status:
reservation = tmp_path / 'user/__manager/startup-scripts/install-scripts.txt'
record = ast.literal_eval(reservation.read_text().strip())
assert record[1] == '#LAZY-CNR-SWITCH-SCRIPT'
assert len(record) == 9
reservation.write_text(repr(record[:8]) + '\n')
marker = tmp_path / 'custom_nodes/fixture-switch/installed.txt'
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
before = len(registry.calls)
with running_server(tmp_path, restart_listen, restart_override, registry) as server:
calls = registry.calls[before:]
assert not any('/install' in call for call in calls), calls
assert any(call.startswith('/zip/') for call in calls) is allowed
if allowed:
assert marker.read_text() == '2.0.0', server.log.read_text()[-6000:]
else:
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
if stored_status:
assert_terminal_guidance(server.log.read_text())
else:
assert 'stored Registry status is missing' in server.log.read_text()
assert 'Request the installation again' in server.log.read_text()
@pytest.mark.parametrize('surface,payload,flag', [
('git_url', {'url': 'https://example.invalid/fixture'}, 'allow_git_url_install'),
('pip', {'packages': 'fixture-do-not-install'}, 'allow_pip_install'),
])
def test_other_install_flags_remain_required(comfy_server, surface, payload, flag):
server = comfy_server
before = len(server.registry.calls)
response = requests.post(server.base + '/customnode/install/' + surface, json=payload, timeout=10)
assert response.status_code == 403, response.text
assert response.json() == {'error': flag}
assert server.registry.calls[before:] == []
@pytest.mark.parametrize('existing', [False, True])
@pytest.mark.parametrize('version,listen,override,allowed', [
('1.1.0', '0.0.0.0', False, True),
('2.0.0', '0.0.0.0', False, False),
('2.0.0', '0.0.0.0', True, True),
('2.0.0', '127.0.0.1', False, True),
])
def test_scheduled_snapshot_inherits_parent_policy(tmp_path, registry, existing, version, listen, override, allowed):
installed = tmp_path / 'custom_nodes/fixture-snapshot'
marker = installed / 'installed.txt'
reservation = tmp_path / 'user/__manager/startup-scripts/restore-snapshot.json'
with running_server(tmp_path, '127.0.0.1', False, registry) as server:
if existing:
assert install(server, 'fixture-snapshot', '1.0.0')['message'] == 'success'
assert marker.read_text() == '1.0.0'
original = (marker.read_bytes(), marker.stat().st_mtime_ns)
snapshot = tmp_path / 'user/__manager/snapshots/flagged-policy.json'
snapshot.write_text(json.dumps({'cnr_custom_nodes': {'fixture-snapshot': version},
'git_custom_nodes': {}, 'file_custom_nodes': []}))
before = len(registry.calls)
response = requests.post(server.base + '/snapshot/restore',
json={'target': snapshot.stem}, timeout=10)
assert response.status_code == 200, response.text
assert reservation.read_bytes() == snapshot.read_bytes()
assert registry.calls[before:] == []
if existing:
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
else:
assert not installed.exists()
before = len(registry.calls)
with running_server(tmp_path, listen, override, registry) as server:
log = server.log.read_text()
calls = registry.calls[before:]
assert 'Restore snapshot done.' in log, log[-6000:]
assert 'Restore snapshot failed.' not in log, log[-6000:]
assert 'Error in sitecustomize' not in log, log[-6000:]
assert not reservation.exists()
assert sum('/install' in call for call in calls) == 1, (calls, log[-6000:])
assert any(call.startswith('/zip/') for call in calls) is allowed
if allowed:
assert f'version = "{version}"' in (installed / 'pyproject.toml').read_text(), log[-6000:]
assert 'allow_flagged_nodepack_install' not in log
else:
assert_terminal_guidance(log)
if existing:
assert (marker.read_bytes(), marker.stat().st_mtime_ns) == original
assert '1.0.0' in (installed / 'pyproject.toml').read_text()
else:
assert not installed.exists()
+159
View File
@@ -0,0 +1,159 @@
"""Lift real production JS out of the shipped files and execute it in node.
WHY LIFT RATHER THAN IMPORT: the manager's front-end modules pull in
``../../scripts/app.js`` and touch ``document`` at module scope, so node cannot
import them. WHY LIFT RATHER THAN RETYPE: a retyped copy of a formatter stops
tracking the formatter the moment someone edits it, and then the guard passes
while the shipped code regresses. Lifting keeps the executed bytes the shipped
bytes — if the production function changes, so does the text under test.
This mirrors tests/test_manager_markdown_escaping.py, which AST-extracts
``convert_markdown_to_html`` rather than importing manager_server.
Consumers point ``JsSource`` at a directory of .js files; an override directory
(e.g. one populated with ``git show <base>:js/foo.js``) makes the RED half of a
fix reproducible in one command.
"""
import json
import shutil
import subprocess
from html.parser import HTMLParser
from pathlib import Path
NODE = shutil.which("node")
def _skip_literal(source: str, pos: int) -> int:
"""If a literal or comment starts at pos, return the index just past it.
Returns pos unchanged when nothing starts there. Template literals are
consumed whole, which is safe for brace counting because a `${...}`
substitution is itself balanced. A lone `/` is read as a regex literal —
sanitizeHTML's own `.replace(/'/g, ...)` hides an apostrophe inside one, and
no scanned range uses `/` as division. Were that to change, the assertion
below fails loudly rather than returning a wrong slice.
"""
ch = source[pos]
nxt = source[pos + 1] if pos + 1 < len(source) else ""
if ch == "/" and nxt == "/":
return source.index("\n", pos)
if ch == "/" and nxt == "*":
return source.index("*/", pos) + 2
if ch not in "\"'`/":
return pos
cursor = pos + 1
while cursor < len(source):
if source[cursor] == "\\":
cursor += 2
continue
if source[cursor] == ch:
return cursor + 1
cursor += 1
raise AssertionError("unterminated literal at offset %d" % (pos,))
def slice_braced(source: str, start_marker: str) -> str:
"""Return start_marker plus the balanced {...} block that follows it."""
start = source.index(start_marker)
pos = source.index("{", start + len(start_marker))
depth = 0
while pos < len(source):
skipped = _skip_literal(source, pos)
if skipped != pos:
pos = skipped
continue
if source[pos] == "{":
depth += 1
elif source[pos] == "}":
depth -= 1
if depth == 0:
return source[start:pos + 1]
pos += 1
raise AssertionError("unbalanced braces after %r" % (start_marker,))
def slice_object_entry(source: str, marker: str, indent: str = "\t\t") -> str:
"""Return one object literal out of a `}, {`-chained array, by id marker.
These objects are literals inside an array, so the first `{` after the
marker belongs to the NEXT entry — brace matching cannot be used. The entry
instead ends at the first `}` sitting at the array's own indent.
"""
start = source.index(marker)
end = source.index("\n%s}" % indent, start)
return source[start:end]
def line_containing(source: str, marker: str) -> str:
"""Return the single source line that holds `marker` (fails if absent)."""
start = source.index(marker)
line_start = source.rfind("\n", 0, start) + 1
return source[line_start:source.index("\n", start)]
class JsSource:
"""The shipped .js files under test, read from `js_dir`."""
def __init__(self, js_dir):
self.js_dir = Path(js_dir)
self._cache = {}
def text(self, filename: str) -> str:
if filename not in self._cache:
self._cache[filename] = (self.js_dir / filename).read_text(encoding="utf-8")
return self._cache[filename]
def lift_declaration(self, filename: str, marker: str) -> str:
"""Lift an `export function`/`export const` declaration, minus `export`."""
return slice_braced(self.text(filename), marker).replace("export ", "", 1)
def lift_formatter(self, filename: str, anchor: str) -> str:
"""Lift the `formatter: ...` at `anchor` as a bare function expression."""
block = slice_braced(self.text(filename), anchor)
marker = "formatter:"
return block[block.index(marker) + len(marker):].strip()
def lift_span(self, filename: str, start_marker: str, end_marker: str) -> str:
"""Lift the source between two markers, end_marker included."""
source = self.text(filename)
start = source.index(start_marker)
end = source.index(end_marker, start) + len(end_marker)
return source[start:end]
def run_node(script: str) -> dict:
"""Execute an ES-module script and parse the single JSON object it prints."""
assert NODE is not None, "node is required to execute the lifted production JS"
result = subprocess.run(
[NODE, "--input-type=module", "-e", script],
capture_output=True, text=True, timeout=60,
)
assert result.returncode == 0, "node failed:\n%s" % (result.stderr,)
return json.loads(result.stdout)
class Collector(HTMLParser):
"""Collects the tags and attributes a browser would actually build."""
def __init__(self):
super().__init__()
self.tags = []
self.attrs = []
def handle_starttag(self, tag, attrs):
self.tags.append(tag)
self.attrs.extend(attrs)
handle_startendtag = handle_starttag
def parse(markup: str) -> Collector:
collector = Collector()
collector.feed(markup)
collector.close()
return collector
def event_handlers(markup: str):
"""Every on* attribute a browser would attach for this markup."""
return [name for name, _ in parse(markup).attrs if name.lower().startswith("on")]
+32
View File
@@ -0,0 +1,32 @@
"""Load Python escaping code without importing the ComfyUI server.
MANAGER_GLOB_DIR can point to another revision for regression checks. Loading
manager_util by path avoids shadowing the standard library's glob module.
"""
import ast
import importlib.util
import os
import re
from pathlib import Path
GLOB_DIR = Path(os.environ.get("MANAGER_GLOB_DIR") or (Path(__file__).resolve().parent.parent / "glob"))
def load_manager_util():
path = GLOB_DIR / "manager_util.py"
spec = importlib.util.spec_from_file_location("_manager_util_under_test", path)
assert spec is not None and spec.loader is not None, f"cannot load {path}"
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def load_markdown_functions(manager_util):
source = (GLOB_DIR / "manager_server.py").read_text(encoding="utf-8")
names = {"convert_markdown_to_html", "populate_markdown"}
functions = [node for node in ast.parse(source).body
if isinstance(node, ast.FunctionDef) and node.name in names]
assert len(functions) == len(names) and {node.name for node in functions} == names
namespace = {"re": re, "manager_util": manager_util}
exec(compile(ast.Module(body=functions, type_ignores=[]), "<markdown-extract>", "exec"), namespace)
return namespace
+325
View File
@@ -0,0 +1,325 @@
"""Behavioral checks against real v3 modules in an isolated ComfyUI runtime."""
import pytest
from test_install_flags_config import _run_child
PRELUDE = '''
import asyncio, io, logging, pathlib, zipfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import cnr_utils, manager_util
from comfy.cli_args import args
args.listen = '0.0.0.0'
core = manager_core
core.manager_funcs.is_flagged_install_allowed = lambda: False
config = core.get_config()
config['allow_flagged_nodepack_install'] = False
nodes = pathlib.Path(tmp) / 'custom_nodes'
nodes.mkdir()
core.get_default_custom_nodes_path = lambda: str(nodes)
manager = core.UnifiedManager()
core.unified_manager = manager
terminal = io.StringIO()
logging.getLogger().addHandler(logging.StreamHandler(terminal))
status = 'NodeVersionStatusFlagged'
http = 200
get = Mock(side_effect=lambda *a, **k: SimpleNamespace(status_code=http, json=lambda: {
'id': 'version-id', 'node_id': 'fixture', 'version': '2.0.0', 'status': status,
'downloadUrl': 'https://example.invalid/fixture.zip',
}))
cnr_utils.requests.get = get
def download(url, directory, name):
with zipfile.ZipFile(pathlib.Path(directory) / name, 'w') as archive:
archive.writestr('__init__.py', 'VERSION = "2.0.0"')
archive.writestr('pyproject.toml', '[project]\\nname = "fixture"\\nversion = "2.0.0"\\n')
core.manager_downloader.download_url = Mock(side_effect=download)
core.manager_downloader.basic_download_url = core.manager_downloader.download_url
manager.execute_install_script = Mock(return_value=True)
'''
def run(body):
stub = '''
comfy = types.ModuleType('comfy')
cli_args = types.ModuleType('comfy.cli_args')
cli_args.args = types.SimpleNamespace(listen='127.0.0.1')
sys.modules['comfy'] = comfy
sys.modules['comfy.cli_args'] = cli_args
'''
return _run_child(stub + PRELUDE + body)
@pytest.mark.parametrize('permission,allowed', [(None, True), ('true', True), ('false', False)])
def test_cli_install_uses_only_parent_permission(permission, allowed):
data = run(f'''
permission = {permission!r}
os.environ.pop('_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED', None)
if permission is not None:
os.environ['_COMFYUI_MANAGER_CNR_ALLOW_FLAGGED'] = permission
core.manager_funcs = core.ManagerFuncs()
args.listen = '127.0.0.1' if permission == 'false' else '0.0.0.0'
config['allow_flagged_nodepack_install'] = permission == 'false'
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
print(json.dumps({{'ok': result.result, 'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count}}))
''')
assert data == {'ok': allowed, 'queries': 1, 'downloads': int(allowed)}
@pytest.mark.parametrize('status,permission,allowed', [
('Active', False, True), ('Pending', False, True),
('Flagged', False, False), ('Flagged', True, True),
])
def test_install_policy_before_download(status, permission, allowed):
data = run(f'''
status = {'NodeVersionStatus' + status!r}
core.manager_funcs.is_flagged_install_allowed = lambda: {permission!r}
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
print(json.dumps({{'ok': result.result, 'message': result.msg,
'terminal': terminal.getvalue(),
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count,
'installed': (nodes / 'fixture/__init__.py').exists()}}))
''')
assert data['ok'] is allowed, data
assert data['installed'] is allowed, data
assert data['downloads'] == int(allowed), data
assert data['queries'] == 1, data
if not allowed:
assert data['message'] == ('This action is not allowed by the current security configuration. '
'See the terminal for details.')
for detail in ('--listen 127.0.0.1', '--listen ::1', '0.0.0.0', '[default]',
'allow_flagged_nodepack_install = true', 'config.ini',
'trusted private network', 'Restart ComfyUI'):
assert detail in data['terminal'], data
else:
assert 'allow_flagged_nodepack_install' not in data['terminal']
@pytest.mark.parametrize('http', [403, 404, 500])
def test_override_does_not_bypass_registry_refusal(http):
data = run(f'''
http = {http}
core.manager_funcs.is_flagged_install_allowed = lambda: True
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
print(json.dumps({{'ok': result.result, 'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count}}))
''')
assert data == {'ok': False, 'queries': 1, 'downloads': 0}
@pytest.mark.parametrize('operation', ['install', 'lazy', 'instant'])
@pytest.mark.parametrize('return_postinstall', [False, True])
def test_direct_cnr_execution_and_postinstall(operation, return_postinstall):
data = run(f'''
status = 'NodeVersionStatusActive'
operation = {operation!r}
return_postinstall = {return_postinstall!r}
installed = nodes / 'fixture'
if operation != 'install':
installed.mkdir()
(installed / '__init__.py').write_text('original')
(installed / '.tracking').write_text('__init__.py')
manager.active_nodes['fixture'] = ('1.0.0', str(installed))
manager.reserve_cnr_switch = Mock(return_value=True)
if operation == 'install':
result = manager.cnr_install('fixture', instant_execution=True,
no_deps=True, return_postinstall=return_postinstall)
else:
result = manager.cnr_switch_version('fixture', instant_execution=operation == 'instant',
no_deps=True, return_postinstall=return_postinstall)
assert result.result, result.msg
assert result.target == ('2.0.0' if operation == 'instant' else None)
effect = manager.reserve_cnr_switch if operation == 'lazy' else manager.execute_install_script
before = effect.call_count
if return_postinstall:
assert result.postinstall()
if operation == 'lazy':
assert effect.call_args.args[1] == 'https://example.invalid/fixture.zip'
assert effect.call_args.args[-2:] == (True, 'NodeVersionStatusActive')
else:
assert effect.call_args.kwargs == {{'instant_execution': True, 'no_deps': True}}
print(json.dumps({{'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count,
'before': before, 'after': effect.call_count,
'content': (installed / '__init__.py').read_text()}}))
''')
assert data == {'queries': 1, 'downloads': int(operation != 'lazy'),
'before': int(not return_postinstall), 'after': 1,
'content': 'original' if operation == 'lazy' else 'VERSION = "2.0.0"'}
@pytest.mark.parametrize('operation', ['install', 'reinstall', 'lazy', 'instant', 'snapshot'])
def test_denial_preserves_installed_version(operation):
data = run(f'''
installed = nodes / 'fixture'
installed.mkdir()
(installed / '__init__.py').write_text('VERSION = "1.0.0"')
(installed / '.tracking').write_text('__init__.py')
manager.active_nodes['fixture'] = ('1.0.0', str(installed))
manager.reserve_cnr_switch = Mock(return_value=True)
operation = {operation!r}
if operation == 'snapshot':
manager.reload = AsyncMock()
manager.get_custom_nodes = AsyncMock(return_value={{}})
core.manager_util.restore_pip_snapshot = Mock()
snapshot = pathlib.Path(tmp) / 'snapshot.json'
snapshot.write_text(json.dumps({{'cnr_custom_nodes': {{'fixture': '2.0.0'}},
'git_custom_nodes': {{}}, 'file_custom_nodes': []}}))
asyncio.run(core.restore_snapshot(str(snapshot)))
elif operation in ('lazy', 'instant'):
result = manager.cnr_switch_version('fixture', '2.0.0', instant_execution=operation == 'instant')
assert not result.result, result.msg
elif operation == 'reinstall':
result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0'))
assert not result.result, result.msg
else:
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
assert not result.result, result.msg
print(json.dumps({{'files': (installed / '__init__.py').read_text(),
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count,
'reservations': manager.reserve_cnr_switch.call_count}}))
''')
assert data == {'files': 'VERSION = "1.0.0"', 'queries': 1, 'downloads': 0, 'reservations': 0}
@pytest.mark.parametrize('listen,allowed', [
('127.0.0.1', True), ('::1', True), ('127.0.0.1,::1', True),
('0.0.0.0', False), ('::', False), ('0.0.0.0,::', False),
('192.168.1.2', False), ('127.0.0.1,0.0.0.0', False), ('', False),
])
def test_all_listener_addresses_must_be_loopback(listen, allowed):
data = run(f'''
print(json.dumps(manager_util.is_cnr_install_allowed('NodeVersionStatusFlagged', False, {listen!r})))
''')
assert data is allowed
@pytest.mark.parametrize('addresses,allowed', [
(['127.0.0.1', '::1'], True), (['127.0.0.1', '192.168.1.2'], False),
([], False), (None, False),
])
def test_hostname_must_resolve_exclusively_to_loopback(addresses, allowed):
data = run(f'''
addresses = {addresses!r}
if addresses is None:
manager_util.socket.getaddrinfo = Mock(side_effect=OSError('unresolvable'))
else:
manager_util.socket.getaddrinfo = Mock(return_value=[
(0, 0, 0, '', (ip, 0)) for ip in addresses])
print(json.dumps(manager_util.is_cnr_install_allowed('NodeVersionStatusFlagged', False, 'host.test')))
''')
assert data is allowed
@pytest.mark.parametrize('state', ['nightly', 'disabled'])
def test_denial_preserves_enabled_state(state):
data = run(f'''
state = {state!r}
installed = nodes / ('fixture' if state == 'nightly' else '.disabled/fixture@1_0_0')
installed.mkdir(parents=True)
(installed / '__init__.py').write_text('original')
if state == 'nightly':
manager.active_nodes['fixture'] = ('nightly', str(installed))
else:
manager.cnr_inactive_nodes['fixture'] = {{'1.0.0': str(installed)}}
before = repr((manager.active_nodes, manager.cnr_inactive_nodes))
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
print(json.dumps({{'ok': result.result, 'original': (installed / '__init__.py').read_text(),
'unchanged': before == repr((manager.active_nodes, manager.cnr_inactive_nodes)),
'queries': get.call_count, 'downloads': core.manager_downloader.download_url.call_count}}))
''')
assert data == {'ok': False, 'original': 'original', 'unchanged': True, 'queries': 1, 'downloads': 0}
def test_exact_installed_version_enables_without_registry_lookup():
data = run('''
installed = nodes / '.disabled/fixture@2_0_0'
installed.mkdir(parents=True)
(installed / '__init__.py').write_text('already installed')
manager.cnr_inactive_nodes['fixture'] = {'2.0.0': str(installed)}
result = asyncio.run(manager.install_by_id('fixture', '2.0.0'))
assert manager.active_nodes['fixture'] == ('2.0.0', str(nodes / 'fixture'))
assert asyncio.run(manager.install_by_id('fixture', '2.0.0')).action == 'skip'
print(json.dumps({'ok': result.result, 'old_path': installed.exists(),
'content': (nodes / 'fixture/__init__.py').read_text(), 'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count,
'scripts': manager.execute_install_script.call_count}))
''')
assert data == {'ok': True, 'old_path': False, 'content': 'already installed',
'queries': 0, 'downloads': 0, 'scripts': 0}
def test_failed_uninstall_stops_reinstall():
data = run('''
status = 'NodeVersionStatusActive'
manager.unified_uninstall = Mock(return_value=core.ManagedResult('uninstall').fail('cannot remove'))
result = asyncio.run(manager.reinstall_by_id('fixture', '2.0.0'))
print(json.dumps({'ok': result.result, 'message': result.msg, 'queries': get.call_count,
'downloads': core.manager_downloader.download_url.call_count}))
''')
assert data == {'ok': False, 'message': 'cannot remove', 'queries': 1, 'downloads': 0}
@pytest.mark.parametrize('version', ['nightly', 'unknown'])
def test_git_reinstall_replaces_pack_and_reruns_script(version):
data = run(f'''
version = {version!r}
installed = nodes / 'fixture'
installed.mkdir()
(installed / 'original.txt').write_text('original')
repo_url = 'https://example.invalid/fixture'
if version == 'nightly':
manager.active_nodes['fixture'] = ('nightly', str(installed))
else:
manager.unknown_active_nodes['fixture'] = (repo_url, str(installed))
manager.get_custom_nodes = AsyncMock(return_value={{
'fixture': {{'repository': repo_url, 'files': [repo_url]}},
}})
get.side_effect = AssertionError('Git needs no CNR install query')
manager.processed_install.add(str(installed / 'install.py'))
manager.execute_install_script = core.UnifiedManager.execute_install_script.__get__(manager)
core.try_install_script = Mock(return_value=True)
def clone(url, path, **kwargs):
assert url == repo_url
path = pathlib.Path(path)
assert not path.exists()
path.mkdir()
(path / 'replacement.txt').write_text('installed')
(path / 'install.py').write_text('')
assert manager.execute_install_script(url, str(path))
return core.ManagedResult('install-git')
manager.repo_install = Mock(side_effect=clone)
result = asyncio.run(manager.reinstall_by_id('fixture', version))
print(json.dumps({{'ok': result.result, 'original': (installed / 'original.txt').exists(),
'replacement': (installed / 'replacement.txt').read_text(),
'clones': manager.repo_install.call_count, 'scripts': core.try_install_script.call_count}}))
''')
assert data == {'ok': True, 'original': False, 'replacement': 'installed', 'clones': 1, 'scripts': 1}
def test_config_defaults_and_manual_edits_survive_settings_save():
data = _run_child('''
write_ini('[default]\\nsecurity_level = normal\\n')
first = fresh_read()['allow_flagged_nodepack_install']
write_ini('[default]\\nsecurity_level = normal\\nallow_flagged_nodepack_install = true\\n')
manager_core.get_config()['db_mode'] = 'local'
manager_core.write_config()
cached = manager_core.get_config()['allow_flagged_nodepack_install']
fresh = fresh_read()['allow_flagged_nodepack_install']
print(json.dumps([first, cached, fresh]))
''')
assert data == [False, False, True]
@pytest.mark.parametrize('raw,expected', [('TRUE', True), ('false', False), ('garbage', False)])
def test_config_value_roundtrip(raw, expected):
data = _run_child(f'''
write_ini('[default]\\nallow_flagged_nodepack_install = {raw}\\n')
loaded = fresh_read()['allow_flagged_nodepack_install']
manager_core.get_config()['allow_flagged_nodepack_install'] = not loaded
manager_core.write_config()
print(json.dumps([loaded, fresh_read()['allow_flagged_nodepack_install']]))
''')
assert data == [expected, not expected]
+189
View File
@@ -0,0 +1,189 @@
"""Exercise Manager's search overrides with the shipped TurboGrid in Chromium.
Browser cases require Playwright and its Chromium install. All requests are
intercepted locally; no ComfyUI server or external services are used.
"""
import os
from pathlib import Path
import pytest
JS = Path(os.environ.get("MANAGER_JS_DIR") or (Path(__file__).resolve().parent.parent / "js"))
@pytest.mark.parametrize("filename", ["custom-nodes-manager.js", "model-manager.js"])
def test_searching_managers_use_the_safe_grid(filename):
source = (JS / filename).read_text()
assert 'import ManagerGrid from "./manager-grid.js"' in source
assert "new ManagerGrid(container)" in source
assert 'from "./turbogrid.esm.js"' not in source
@pytest.fixture(scope="module")
def browser():
playwright = pytest.importorskip("playwright.sync_api")
with playwright.sync_playwright() as driver:
if not Path(driver.chromium.executable_path).exists():
pytest.skip("install Chromium with: playwright install chromium")
instance = driver.chromium.launch()
yield instance
instance.close()
@pytest.fixture
def page(browser):
page = browser.new_page()
def route(request):
name = request.request.url.rsplit("/", 1)[-1]
if name in {"manager-grid.js", "turbogrid.esm.js"}:
request.fulfill(path=str(JS / name), content_type="text/javascript")
elif request.request.url == "http://manager.test/":
request.fulfill(
body='<div id="grid" style="width:800px;height:300px"></div>',
content_type="text/html",
)
else:
request.abort()
page.route("**/*", route)
page.goto("http://manager.test/")
page.evaluate("""async () => {
const {default: ManagerGrid} = await import('/manager-grid.js');
window.grid = new ManagerGrid(document.getElementById('grid'));
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: []});
grid.render();
}""")
page.wait_for_function("window.grid.options !== undefined")
yield page
page.close()
@pytest.mark.parametrize("keyword", ["before", "img", "after"])
def test_highlighting_keeps_all_text_inert(page, keyword):
text = 'before <img src="/bad.png" onerror="window.__probe=1"> & 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 = '<b>Alpha</b> beta Alpha beta <svg><text>Alpha</text></svg><textarea>beta</textarea>';
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: '<img src="/bad.png" onerror="window.__probe=1"><b>Alpha</b> &amp; 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: '<b>Model</b> &lt;Flux&gt;'}};
const matched = grid.highlightKeywordsFilter(row, ['name'], 'model <flux>');
return {matched, text: row.text_name, highlight: row.mark_name};
}""")
assert result == {"matched": True, "text": "Model <Flux>", "highlight": True}
def test_grid_render_search_and_clear_keep_names_readable(page):
page.evaluate("""() => {
window.query = 'flux';
grid.setOption({rowFilter: row => grid.highlightKeywordsFilter(row, ['name'], window.query)});
grid.setData({columns: [{id: 'name', name: 'Name'}], rows: [
{name: 'Model &lt;Flux&gt; &lt;img src=x onerror=window.__probe=1&gt;'},
{name: 'Other Model'}
]});
grid.render();
}""")
page.wait_for_function("document.querySelectorAll('#grid mark').length === 1")
assert page.locator("#grid mark").all_text_contents() == ["Flux"]
assert page.evaluate("grid.viewRows.length") == 1
assert page.locator("#grid img").count() == 0
assert 'Model <Flux> <img src=x onerror=window.__probe=1>' in page.locator("#grid").inner_text()
page.evaluate("window.query = 'other'; grid.update()")
page.wait_for_function("document.querySelector('#grid mark')?.textContent === 'Other'")
assert page.evaluate("grid.viewRows.length") == 1
page.evaluate("window.query = ''; grid.update()")
page.wait_for_function("grid.viewRows.length === 2 && !document.querySelector('#grid mark')")
assert page.locator("#grid img").count() == 0
assert page.evaluate("window.__probe === undefined")
@pytest.mark.parametrize("filename,column,value,query,nonmatching", [
("custom-nodes-manager.js", "title", "Pack &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("custom-nodes-manager.js", "description", "Notes &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("custom-nodes-manager.js", "author", "Writer &lt;Alias&gt;", "&lt;alias&gt;", "<alias>"),
("custom-nodes-manager.js", "author", "Writer <Alias>", "<alias>", "&lt;alias&gt;"),
("model-manager.js", "name", "Model &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("model-manager.js", "description", "<b>Notes</b> &lt;Flux&gt;", "<flux>", "&lt;flux&gt;"),
("model-manager.js", "filename", "model &lt;Flux&gt;", "&lt;flux&gt;", "<flux>"),
("model-manager.js", "type", "Type <Flux>", "<flux>", "&lt;flux&gt;"),
])
def test_manager_search_matches_visible_text(page, filename, column, value, query, nonmatching):
source = (JS / filename).read_text()
init_body = source.split("\tinitGrid() {", 1)[1].split("\n\t}\n", 1)[0]
common = (JS / "common.js").read_text()
sanitizer = common.split("export function sanitizeHTML(str) {", 1)[1].split("\n}", 1)[0]
escape_cell = "undefined"
if filename == "model-manager.js":
escape_cell = source.split("const escapeCell = ", 1)[1].split(";", 1)[0]
page.evaluate("""({init_body, sanitizer, escape_cell, column, value, query}) => {
const sanitizeHTML = new Function('str', sanitizer);
const escapeCell = new Function('sanitizeHTML', `return (${escape_cell})`)(sanitizeHTML);
const initGrid = new Function('ManagerGrid', 'createFlyover', 'gridId', 'sanitizeHTML', 'escapeCell',
`return function initGrid() {${init_body}}`)(grid.constructor, () => ({}), 'review', sanitizeHTML, escapeCell);
const element = document.createElement('div');
element.innerHTML = '<div id="tested-grid" class="cn-manager-grid cmm-manager-grid" style="width:800px;height:300px"></div>';
document.body.append(element);
window.manager = {element, keywords: query, showStatus() {}, handleFlyoverHover() {}, createFlyover: () => ({}), hasAlternatives: () => false};
initGrid.call(manager);
manager.grid.setData({columns: [{id: column, name: column}], rows: [{[column]: value}]});
manager.grid.render();
}""", {"init_body": init_body, "sanitizer": sanitizer, "escape_cell": escape_cell,
"column": column, "value": value, "query": query})
page.wait_for_function("manager.grid.viewRows !== undefined")
assert page.evaluate("manager.grid.viewRows.length") == 1
page.evaluate("query => {manager.keywords=query;}", nonmatching)
assert not page.evaluate("manager.grid.options.rowFilter(manager.grid.data.rows[0])")
+3 -3
View File
@@ -56,7 +56,7 @@ _WANTED_CONSTS = {
_HANDLER_NAMES = {
"install_custom_node_git_url", # S-A
"install_custom_node_pip", # S-B
"install_custom_node", # S-C
"_queue_node_install", # S-C
}
@@ -435,14 +435,14 @@ class BindingProofTest(unittest.TestCase):
for name, flag in (
("install_custom_node_git_url", "allow_git_url_install"),
("install_custom_node_pip", "allow_pip_install"),
("install_custom_node", "allow_git_url_install"),
("_queue_node_install", "allow_git_url_install"),
):
with self.subTest(handler=name):
src = ast.unparse(HANDLERS[name])
self.assertIn("is_dedicated_install_allowed(", src)
self.assertIn(flag, src)
self.assertIn("args.listen", src)
sc_literals = self._ias_literal_calls(HANDLERS["install_custom_node"])
sc_literals = self._ias_literal_calls(HANDLERS["_queue_node_install"])
self.assertIn("middle", sc_literals, "entry gate must stay UNCHANGED")
self.assertIn(None, sc_literals, "a variable-arg retained path must remain")
+331
View File
@@ -0,0 +1,331 @@
"""RED->GREEN guard for the unescaped-innerHTML regression in the legacy Manager UI.
A node pack `description` is markdown-transformed server-side and then written to
innerHTML by the client. `convert_markdown_to_html`'s `replace_a` interpolated the
captured URL RAW into a single-quoted href, and the URL pattern is `([^)]+)` — so a
URL carrying a single quote closed the attribute and injected an event handler.
No `<` or `>` is needed, which is why the upstream `<`/`>` escaping did not stop it.
IMPORT DISCIPLINE (load-bearing, see tests/conftest.py):
- `glob/` has NO `__init__.py`, so `import glob.manager_util` is not a package
import, and putting `glob/` on sys.path would SHADOW the stdlib `glob` module
and break pytest itself. manager_util is therefore loaded by FILE LOCATION
under a private module name — it is the real module, really executed.
- `glob/manager_server.py` starts a network thread at import and needs the
ComfyUI stack, so it is NEVER imported. `convert_markdown_to_html` is
AST-extracted and executed in an isolated namespace instead — the same
technique tests/test_csrf_content_type_helper.py already uses in this repo.
"""
import unittest
from manager_test_utils import load_manager_util, load_markdown_functions
def _parse_anchors(html):
"""Return one dict of attributes per <a> tag, using a real HTML parser."""
from html.parser import HTMLParser
class _Collector(HTMLParser):
def __init__(self):
super().__init__()
self.anchors = []
def handle_starttag(self, tag, attrs):
if tag == "a":
self.anchors.append(dict(attrs))
collector = _Collector()
collector.feed(html)
collector.close()
return collector.anchors
def _parse_element_names(html):
"""Return the tag name of every element the HTML actually produces."""
from html.parser import HTMLParser
class _Collector(HTMLParser):
def __init__(self):
super().__init__()
self.names = []
def handle_starttag(self, tag, attrs):
self.names.append(tag)
collector = _Collector()
collector.feed(html)
collector.close()
return collector.names
MANAGER_UTIL = load_manager_util()
CONVERT = load_markdown_functions(MANAGER_UTIL)["convert_markdown_to_html"]
# Synthetic stand-in for the crafted payload. It keeps a LEGITIMATE https scheme
# on purpose: a scheme allow-list alone does not stop this one, so the test pins
# the attribute ESCAPING rather than the allow-list.
BREAKOUT_URL = "https://example.com/x' onmouseover='alert`1`"
BREAKOUT_MD = f"[a/click me]({BREAKOUT_URL})"
class TestAttributeBreakout(unittest.TestCase):
def test_quote_in_url_cannot_close_the_href_attribute(self):
html = CONVERT(BREAKOUT_MD)
self.assertNotIn(
"' onmouseover='", html,
f"URL quote escaped the href attribute and injected a handler: {html!r}",
)
def test_transformed_anchor_exposes_no_event_handler_attribute(self):
# Parsed, not regex-matched: after the fix the payload text still LIVES
# inside the href value (harmlessly, as an odd URL), so a naive
# /\son[a-z]+=/ scan would flag the fixed output too. What actually
# matters is which ATTRIBUTES the anchor ends up carrying.
html = CONVERT(BREAKOUT_MD)
anchors = _parse_anchors(html)
self.assertEqual(len(anchors), 1, f"expected one anchor, got {html!r}")
names = set(anchors[0])
self.assertEqual(
names, {"href", "target", "rel"},
f"unexpected attribute(s) on the anchor: {sorted(names)} in {html!r}",
)
def test_legit_link_still_renders_a_usable_anchor(self):
html = CONVERT("see [a/the docs](https://example.com/guide?a=1&b=2) please")
self.assertIn("<a href='https://example.com/guide?a=1&amp;b=2'", html)
self.assertIn(">the docs</a>", html)
self.assertIn("target='_blank'", html)
def test_link_text_is_escaped_once(self):
for label in ["a <b> tag", "a &lt;b&gt; tag"]:
with self.subTest(label=label):
html = CONVERT(f"[a/{label}](https://example.com)")
self.assertIn("&lt;b&gt;", html)
self.assertNotIn("&amp;lt;", html)
def test_prose_and_link_labels_are_safe_inside_formatted_notes(self):
item = {"description": '<img src=x> [w/Read [a/**<Flux>**](https://example.com/?q=<Flux>)]\n<img src=y>'}
load_markdown_functions(MANAGER_UTIL)["populate_markdown"](item)
html = item["description"]
self.assertEqual(_parse_element_names(html), ["p", "a", "b", "br"])
self.assertIn("<B>&lt;Flux&gt;</B>", html)
self.assertIn("&lt;img src=x&gt;", html)
self.assertIn("&lt;img src=y&gt;", html)
self.assertEqual(_parse_anchors(html)[0]["href"], "https://example.com/?q=<Flux>")
class TestDescriptionUrlRoundTrip(unittest.TestCase):
def test_description_urls_are_escaped_once(self):
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
cases = [
("https://example.com/?q=<Flux>", "https://example.com/?q=<Flux>"),
("https://example.com/?a=1&amp;b=2", "https://example.com/?a=1&b=2"),
("https://example.com/?q=&lt;Flux&gt;", "https://example.com/?q=<Flux>"),
("https://example.com/?q=&#60;Flux&#x3e;", "https://example.com/?q=<Flux>"),
("https://example.com/?a=1&notebook=2&copy=3", "https://example.com/?a=1&notebook=2&copy=3"),
("https://example.com/?q=&notit;", "https://example.com/?q=&notit;"),
("https://example.com/?q=&amp;lt;", "https://example.com/?q=&lt;"),
("https://example.com/a**b**c?x=1&amp;y=2", "https://example.com/a**b**c?x=1&y=2"),
]
for url, expected in cases:
with self.subTest(url=url):
item = {"description": f"[a/link]({url})"}
populate(item)
anchors = _parse_anchors(item["description"])
self.assertEqual(anchors[0]["href"], expected)
self.assertEqual(_parse_element_names(item["description"]), ["a"])
def test_decoded_entities_cannot_bypass_url_or_attribute_checks(self):
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
for url in ["javascript&#58;alert`1`", "&#106;avascript:alert`1`", "java&Tab;script:alert`1`"]:
with self.subTest(url=url):
item = {"description": f"[a/link]({url})"}
populate(item)
self.assertEqual(_parse_anchors(item["description"])[0]["href"], "#")
url = "https://example.com/x&#39; onmouseover=&#39;alert`1`"
item = {"description": f"[a/link]({url})"}
populate(item)
anchor = _parse_anchors(item["description"])[0]
self.assertEqual(anchor["href"], "https://example.com/x' onmouseover='alert`1`")
self.assertEqual(set(anchor), {"href", "target", "rel"})
class TestEscapedHrefSurvivesTheLaterPasses(unittest.TestCase):
"""The escaper's output must still be intact by the time it is returned.
replace_a runs before the %%..%% / **..** / [w/..] / [i/..] passes, and those
passes used to rewrite the INSIDE of the href it had just secured — putting
<font>/<B>/<p> markup and fresh single quotes back into the attribute. That
both corrupted legitimate URLs and undid the escaping.
"""
def test_percent_markers_in_a_url_leave_the_href_intact(self):
html = CONVERT("[a/t](https://e.com/%%A%%)")
self.assertIn("href='https://e.com/%%A%%'", html)
self.assertNotIn("<font", html)
def test_bold_markers_in_a_url_leave_the_href_intact(self):
html = CONVERT("[a/t](https://e.com/a**b**c)")
self.assertIn("href='https://e.com/a**b**c'", html)
self.assertNotIn("<B>", html)
def test_note_markers_in_a_url_leave_the_href_intact(self):
html = CONVERT("[a/t](https://e.com/[w/A])")
self.assertIn("href='https://e.com/[w/A]'", html)
self.assertNotIn("cm-warn-note", html)
def test_no_element_leaks_out_of_a_url(self):
# Parsed rather than string-matched: the anchor must be the ONLY element.
html = CONVERT("[a/t](https://e.com/%%A%%and**B**)")
self.assertEqual(_parse_element_names(html), ["a"])
def test_nested_markdown_in_the_LINK_TEXT_still_renders(self):
# Protecting the href must not cost the label. Moving the anchor
# substitution last would have escaped these into visible mojibake.
self.assertIn("<B>bold</B>", CONVERT("[a/**bold** text](https://e.com)"))
self.assertIn("<font color='white'>hi</font>", CONVERT("[a/%%hi%% there](https://e.com)"))
def test_markup_outside_a_link_is_unaffected(self):
html = CONVERT("%%white%% and **bold** and [w/warn]")
self.assertIn("<font color='white'>white</font>", html)
self.assertIn("<B>bold</B>", html)
self.assertIn("cm-warn-note", html)
def test_the_newline_pass_cannot_reach_inside_the_href(self):
# The newline substitution is the LAST thing convert_markdown_to_html
# does, so restoring the href before it let '<BR>' be injected into the
# attribute the escaper had secured. Not exploitable — '<BR>' carries no
# quote — but it is the same class of breach this function exists to fix,
# so the invariant is pinned all the way to the returned string.
html = CONVERT("[a/t](https://e.com/a\nb)")
anchors = _parse_anchors(html)
self.assertEqual(len(anchors), 1)
self.assertNotIn("<BR>", anchors[0]["href"])
self.assertEqual(_parse_element_names(html), ["a"])
def test_newlines_outside_a_link_still_become_line_breaks(self):
html = CONVERT("first\nsecond")
self.assertIn("<BR>", html)
def test_a_forged_placeholder_in_the_input_cannot_hijack_restoration(self):
html = CONVERT("\x00H0\x00 [a/t](https://e.com/real)")
self.assertIn("href='https://e.com/real'", html)
self.assertEqual(html.count("https://e.com/real"), 1)
class TestUrlSchemeAllowlist(unittest.TestCase):
def test_helpers_are_public_on_manager_util(self):
self.assertTrue(callable(getattr(MANAGER_UTIL, "escape_html_attribute", None)))
self.assertTrue(callable(getattr(MANAGER_UTIL, "sanitize_url", None)))
def test_dangerous_schemes_are_rejected(self):
for bad in [
"javascript:alert`1`",
"JaVaScRiPt:alert`1`",
" javascript:alert`1`",
"java\tscript:alert`1`",
"java\nscript:alert`1`",
"\x01javascript:alert`1`",
"data:text/html;base64,PHNjcmlwdD4=",
"vbscript:msgbox",
]:
with self.subTest(bad=bad):
self.assertEqual(MANAGER_UTIL.sanitize_url(bad), "#")
def test_safe_urls_pass_through(self):
for good in [
"https://example.com/a?b=1",
"http://example.com",
"HTTPS://Example.COM/Path",
"/relative/path",
"relative/path",
"#in-page-anchor",
"//protocol-relative/path",
# A relative link may legitimately contain '&'. An earlier version
# rejected this shape while guarding against an entity-hidden colon;
# the guarantee comes from the escape layer instead (below), so the
# rejection was redundant and only broke real links.
"a&b/c",
"docs/page?x=1&y=2",
]:
with self.subTest(good=good):
self.assertEqual(MANAGER_UTIL.sanitize_url(good), good)
def test_entity_hidden_colon_is_inert_after_escaping(self):
# Asserted on the POST-ESCAPE href, because that is where the safety
# actually comes from: sanitize_url returns these unchanged (they have no
# real colon, so they read as relative), and escape_html_attribute then
# escapes the '&'. Entity decoding in an attribute is single-pass, so
# what the browser ends up with is a literal string, never a scheme.
for hidden in ["javascript&#58;alert`1`", "&#x6a;avascript:alert`1`",
"&#106;avascript:alert`1`"]:
with self.subTest(hidden=hidden):
href = MANAGER_UTIL.escape_html_attribute(MANAGER_UTIL.sanitize_url(hidden))
self.assertIn("&amp;", href)
self.assertNotIn("&#58;", href)
self.assertNotIn("&#x6a;", href)
self.assertNotIn("&#106;", href)
def test_entity_hidden_colon_in_markdown_produces_no_live_scheme(self):
html = CONVERT("[a/click](javascript&#58;alert`1`)")
anchors = _parse_anchors(html)
self.assertEqual(len(anchors), 1)
# The parser decodes entities exactly as a browser would; after that the
# href must NOT be a javascript: URL.
self.assertFalse(anchors[0]["href"].lower().startswith("javascript:"))
def test_dangerous_scheme_in_markdown_does_not_reach_the_href(self):
html = CONVERT("[a/click](javascript:alert`1`)")
self.assertNotIn("javascript:", html.lower())
self.assertIn("href='#'", html)
class TestAttributeEscaper(unittest.TestCase):
def test_escapes_all_five_characters(self):
self.assertEqual(
MANAGER_UTIL.escape_html_attribute("""&<>"'"""),
"&amp;&lt;&gt;&quot;&#x27;",
)
def test_ampersand_is_escaped_first_and_only_once(self):
self.assertEqual(MANAGER_UTIL.escape_html_attribute("a&b"), "a&amp;b")
def test_non_string_input_is_coerced(self):
self.assertEqual(MANAGER_UTIL.escape_html_attribute(None), "None")
class TestServerTitleTransformIsTheOnlyUpstreamControl(unittest.TestCase):
"""Pin the server-side facts that the bare flyover title sinks rest on.
js/custom-nodes-manager.js says, at both flyover pack-title sinks, that the
title is already escaped by populate_markdown and so must NOT be escaped
again at the client (a second escape would double-escape a legitimate
<>-title into mojibake). That comment is a load-bearing claim about THIS
file's subject: it is the whole reason those two sinks are bare. These
cases make the claim machine-checked, so if the server side ever stops
covering `title` the suite says so, instead of leaving a comment asserting
a protection that is gone.
"""
def test_populate_markdown_escapes_the_title_before_it_reaches_the_client(self):
# The specific link the sink comments name. This is the case that fails
# if someone drops `title` from populate_markdown, which is the drift
# the bare sinks are actually exposed to.
populate_markdown = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
pack = {"title": "Nodes for <Flux> workflows"}
populate_markdown(pack)
self.assertEqual(pack["title"], "Nodes for &lt;Flux&gt; workflows")
def test_sanitize_tag_escapes_angle_brackets(self):
self.assertEqual(MANAGER_UTIL.sanitize_tag("<img>"), "&lt;img&gt;")
def test_sanitize_tag_does_not_escape_quotes_or_ampersand(self):
# Documented limit, not a defect: both title interpolations are in
# element-text position, where quotes are inert. It IS the reason a
# title moving into attribute position would need escaping.
self.assertEqual(MANAGER_UTIL.sanitize_tag("\"a\" & 'b'"), "\"a\" & 'b'")
if __name__ == "__main__":
unittest.main()
+487
View File
@@ -0,0 +1,487 @@
"""Inventory the current direct message-sink calls and reviewed error builders.
These sinks accept HTML because some callers render buttons and server-escaped
metadata. Unknown arguments or assignments require a provenance review. Runtime
rendering tests separately verify that raw text stays inert and escaped titles
remain readable; this source inventory is not a general JavaScript dataflow check.
"""
import re
import tempfile
import unittest
from collections import Counter
from pathlib import Path
from unittest.mock import patch
from js_lift import _skip_literal
REPO_ROOT = Path(__file__).resolve().parent.parent
JS_DIR = REPO_ROOT / "js"
SINK_FILES = [
"common.js",
"custom-nodes-manager.js",
"model-manager.js",
"node-usage-analyzer.js",
]
SINK_METHODS = r"showStatus|showMessage|showError|showSelection"
# Capture receivers so a new alias is reviewed instead of silently omitted.
CALL_RE = re.compile(
r"(?<![\w$.])([\w$]+(?:\??\.[\w$]+)*)"
rf"\??\.({SINK_METHODS})\("
)
SINK_CALL_RE = re.compile(
rf"(?:\.(?:{SINK_METHODS})|\[\s*['\"](?:{SINK_METHODS})['\"]\s*\])"
r"\s*(?:\?\.\s*)?\("
)
PINNED_RECEIVERS = frozenset({"this", "self", "this.ui"})
# Destructuring would hide calls from the receiver-based inventory.
DESTRUCTURE_RE = re.compile(
r"(?:const|let|var)\s*\{[^}]*\b"
rf"(?:{SINK_METHODS})"
r"\b[^}]*\}"
)
SERVER_ESCAPED = "server-escaped" # already display-ready; caller must NOT escape
RAW = "raw" # not display-ready; caller MUST escape
NO_DATA = "no-data" # no channel/registry value interpolated
FORWARDED = "forwarded" # showError's own delegation; see HtmlByDesignSinkTest
def _argument(source: str, start: int) -> str:
"""Read a complete argument, including multiline templates."""
depth = 1
end = start
while depth:
skipped = _skip_literal(source, end)
if skipped != end:
end = skipped
continue
if source[end] == "(":
depth += 1
elif source[end] == ")":
depth -= 1
end += 1
if source[end:end + 1] == ";":
end += 1
return re.sub(r"\s+", " ", source[start:end].strip())
def _call_sites():
"""Return (file, line, receiver, method, argument) for direct calls."""
sites = []
for name in SINK_FILES:
source = (JS_DIR / name).read_text(encoding="utf-8")
matches = list(CALL_RE.finditer(source))
parsed = {match.end() for match in matches}
for candidate in SINK_CALL_RE.finditer(source):
if candidate.end() not in parsed:
lineno = source.count("\n", 0, candidate.start()) + 1
raise AssertionError(f"Unsupported message-sink call at {name}:{lineno}")
for match in matches:
lineno = source.count("\n", 0, match.start()) + 1
sites.append((name, lineno, match.group(1), match.group(2),
_argument(source, match.end())))
return sites
# ---------------------------------------------------------------------------
# THE REGISTRY. Keyed by (file, method, argument-source). Every showStatus and
# showMessage call site must appear here with a provenance verdict and a reason.
# ---------------------------------------------------------------------------
REGISTRY = {
# --- custom-nodes-manager.js -------------------------------------------
("custom-nodes-manager.js", "showStatus",
"`${prevViewRowsLength.toLocaleString()} custom nodes`);"):
(NO_DATA, "a formatted row count"),
("custom-nodes-manager.js", "showStatus",
"`Loading node mappings (${mode}) ...`);"):
(NO_DATA, "`mode` is a datasrc-combo value chosen in the UI, not channel data"),
("custom-nodes-manager.js", "showStatus",
"`${label} ${item.title} ...`);"):
(SERVER_ESCAPED,
"item.title comes from /customnode/getlist, where populate_markdown -> "
"sanitize_tag has already escaped it; escaping again shows entities"),
("custom-nodes-manager.js", "showStatus",
"`${label} ${Object.keys(result).length} custom node(s) successfully`);"):
(NO_DATA, "a count of queue results"),
("custom-nodes-manager.js", "showStatus",
"`Loading missing nodes (${mode}) ...`);"):
(NO_DATA, "UI-chosen mode"),
("custom-nodes-manager.js", "showStatus",
"`Loading alternatives (${mode}) ...`);"):
(NO_DATA, "UI-chosen mode"),
("custom-nodes-manager.js", "showStatus",
"`Loading workflow usage analysis ...`);"):
(NO_DATA, "hardcoded"),
("custom-nodes-manager.js", "showStatus",
"`Loading custom nodes (${mode}) ...`);"):
(NO_DATA, "UI-chosen mode"),
("custom-nodes-manager.js", "showMessage",
"`To apply the installed/updated/disabled/enabled custom node, please restart "
"ComfyUI. And refresh browser.`, \"red\");"):
(NO_DATA, "hardcoded"),
("custom-nodes-manager.js", "showMessage", "\"\");"):
(NO_DATA, "clears the banner"),
# --- model-manager.js ---------------------------------------------------
("model-manager.js", "showStatus",
"`${grid.viewRows.length.toLocaleString()} external models`);"):
(NO_DATA, "a formatted row count"),
("model-manager.js", "showStatus", "`Install ${item.name} ...`);"):
(SERVER_ESCAPED,
"item.name comes from /externalmodel/getlist, where populate_markdown -> "
"sanitize_tag has already escaped it. WI-112 added a caller escape here "
"and it rendered '&lt;Flux&gt;' to the user; removing it is the fix"),
("model-manager.js", "showStatus", "`Install ${result.length} models successfully`);"):
(NO_DATA, "a count of queue results"),
("model-manager.js", "showStatus", "`Loading external model list ...`);"):
(NO_DATA, "hardcoded"),
("model-manager.js", "showMessage",
"`To apply the installed model, please click the 'Refresh' button.`, \"red\")"):
(NO_DATA, "hardcoded"),
("model-manager.js", "showMessage", "\"\");"):
(NO_DATA, "clears the banner"),
# --- node-usage-analyzer.js --------------------------------------------
("node-usage-analyzer.js", "showStatus",
"`${grid.viewRows.length.toLocaleString()} installed packages`);"):
(NO_DATA, "a formatted row count"),
("node-usage-analyzer.js", "showStatus",
"`Install ${sanitizeHTML(String(item.name))} ...`);"):
(RAW,
"item.name is packKey (loadData sets `name: packKey`) — a raw dict key no "
"server transform touches, so this caller escapes it. The ONLY raw-provenance "
"showStatus caller in the four files"),
("node-usage-analyzer.js", "showStatus", "msg);"):
(SERVER_ESCAPED,
"uninstallNodes preserves the escaped title or escapes the raw name fallback; "
"test_node_usage_escaping executes that path through the panel sink"),
("node-usage-analyzer.js", "showStatus",
"`Uninstalled ${targets.length} custom node(s) successfully`);"):
(NO_DATA, "a count of targets"),
("node-usage-analyzer.js", "showStatus",
"`Install ${Object.keys(result).length} models successfully`);"):
(NO_DATA, "a count of queue results"),
("node-usage-analyzer.js", "showStatus",
"`Uninstall ${Object.keys(result).length} custom node(s) successfully`);"):
(NO_DATA, "a count of queue results"),
("node-usage-analyzer.js", "showStatus", "`Analyzing node usage ...`);"):
(NO_DATA, "hardcoded"),
("node-usage-analyzer.js", "showMessage",
"`To apply the uninstalled custom nodes, please restart ComfyUI and refresh "
"browser.`, \"red\");"):
(NO_DATA, "hardcoded"),
("node-usage-analyzer.js", "showMessage",
"`To apply the installed model, please click the 'Refresh' button.`, \"red\");"):
(NO_DATA, "hardcoded"),
("node-usage-analyzer.js", "showMessage", "\"No workflows were found for analysis.\");"):
(NO_DATA, "hardcoded"),
("node-usage-analyzer.js", "showMessage", "\"\");"):
(NO_DATA, "clears the banner"),
# --- showError's own delegation, in each of the three classes -----------
# Not an application caller: this IS `showError(err) { this.showMessage(err,
# "red"); }`. Whether `err` is display-ready is the showError CALLER's
# responsibility, which HtmlByDesignSinkTest below enforces separately. It is
# registered rather than pattern-excluded so that if showError ever stops
# forwarding — or starts forwarding something else — this entry goes stale
# and the guard says so.
("custom-nodes-manager.js", "showMessage", "err, \"red\");"):
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
("model-manager.js", "showMessage", "err, \"red\");"):
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
("node-usage-analyzer.js", "showMessage", "err, \"red\");"):
(FORWARDED, "showError delegation; err is escaped by the showError caller"),
}
DISPLAY_READY_METHODS = ("showStatus", "showMessage")
# A caller escape is spelled this way throughout the UI files.
ESCAPE_MARKER = "sanitizeHTML"
class MessageSinkRegistryTest(unittest.TestCase):
"""The registry must account for every display-ready-sink call site."""
@classmethod
def setUpClass(cls):
cls.sites = _call_sites()
def test_the_four_sink_implementations_all_exist(self):
# If a file stops defining its own sink (e.g. it starts delegating to
# createUIStateManager) the registry's routing assumptions change and
# this guard must be re-derived rather than silently kept.
own = {
"custom-nodes-manager.js": ".cn-manager-status",
"model-manager.js": ".cmm-manager-status",
"node-usage-analyzer.js": ".nu-manager-status",
}
for name, selector in own.items():
source = (JS_DIR / name).read_text(encoding="utf-8")
self.assertIn("showStatus(msg, color)", source, "%s lost its own sink" % name)
self.assertIn(selector, source)
common = (JS_DIR / "common.js").read_text(encoding="utf-8")
self.assertIn("export function createUIStateManager(", common)
self.assertIn("showStatus: (msg, color)", common)
def test_every_caller_is_classified(self):
"""FAIL-CLOSED: an unregistered call site breaks the build."""
observed = Counter((name, method, arg) for name, _, _, method, arg in self.sites
if method in DISPLAY_READY_METHODS)
duplicates = {
('custom-nodes-manager.js', 'showStatus', '`Loading workflow usage analysis ...`);'),
('node-usage-analyzer.js', 'showMessage',
'`To apply the uninstalled custom nodes, please restart ComfyUI and refresh browser.`, "red");'),
}
expected = Counter({key: 1 + (key in duplicates) for key in REGISTRY})
self.assertEqual(observed, expected, 'Changed message calls require a provenance review')
def test_the_observed_receivers_are_the_pinned_set(self):
observed = {receiver for _name, _lineno, receiver, _m, _a in self.sites}
self.assertEqual(
observed, set(PINNED_RECEIVERS),
"the set of receivers calling the message sinks changed: %s. These "
"sinks assign innerHTML and DO NOT escape, so every route to them "
"must be accounted for. Classify the new receiver's call sites in "
"REGISTRY and add it to PINNED_RECEIVERS — do not narrow CALL_RE to "
"make this pass." % (sorted(observed ^ set(PINNED_RECEIVERS)),),
)
def test_no_sink_method_is_destructured_out_of_its_receiver(self):
for name in SINK_FILES:
source = (JS_DIR / name).read_text(encoding="utf-8")
with self.subTest(file=name):
self.assertEqual(
DESTRUCTURE_RE.findall(source), [],
"%s destructures a message-sink method out of its receiver. "
"The resulting bare calls are invisible to this guard; call "
"them through their receiver instead." % (name,),
)
def test_unsupported_sink_calls_require_review(self):
source = (JS_DIR / "common.js").read_text(encoding="utf-8")
for call in ('this["showMessage"](raw);',
'createUIStateManager(element).showError(raw);'):
with self.subTest(call=call), patch(__name__ + ".SINK_FILES", ["common.js"]):
with patch.object(Path, "read_text", return_value=source + "\n" + call):
with self.assertRaisesRegex(AssertionError, "Unsupported message-sink call"):
_call_sites()
def test_the_registry_has_no_stale_entries(self):
"""A registry entry whose call site is gone or whose argument changed."""
live = {
(name, method, arg)
for name, _lineno, _receiver, method, arg in self.sites
if method in DISPLAY_READY_METHODS
}
stale = sorted(key for key in REGISTRY if key not in live)
self.assertEqual(
stale, [],
"REGISTRY entries no longer match any call site — the caller was removed "
"or its argument changed, so its provenance verdict is unverified:\n %s"
% "\n ".join("%s %s(%s" % key for key in stale),
)
def test_server_escaped_callers_do_not_escape_again(self):
for (name, method, arg), (provenance, reason) in REGISTRY.items():
if provenance != SERVER_ESCAPED:
continue
with self.subTest(file=name, method=method, arg=arg):
self.assertNotIn(
ESCAPE_MARKER, arg,
"%s %s escapes an ALREADY-escaped value — the user reads "
"'&lt;Flux&gt;' instead of '<Flux>'. Reason on file: %s"
% (name, method, reason),
)
def test_raw_callers_escape(self):
for (name, method, arg), (provenance, reason) in REGISTRY.items():
if provenance != RAW:
continue
with self.subTest(file=name, method=method, arg=arg):
self.assertIn(
ESCAPE_MARKER, arg,
"%s %s passes a RAW value into a sink that does not escape. "
"Reason on file: %s" % (name, method, reason),
)
def test_no_data_callers_interpolate_no_channel_value(self):
# Interpolations are allowed, but only of things that cannot carry
# channel/registry text: counts, lengths, and the UI-chosen mode.
allowed = re.compile(
r"^\$\{(?:[A-Za-z_.]*\.length|Object\.keys\([A-Za-z_.]+\)\.length"
r"|[A-Za-z_.]+\.toLocaleString\(\)|mode|label"
r"|[A-Za-z_.]*\.length\.toLocaleString\(\))\}$"
)
for (name, method, arg), (provenance, reason) in REGISTRY.items():
if provenance != NO_DATA:
continue
for expr in re.findall(r"\$\{[^}]*\}", arg):
with self.subTest(file=name, method=method, expr=expr):
self.assertRegex(
expr, allowed,
"%s %s is classified %s but interpolates %s, which is not a "
"count or a UI-chosen value. Re-classify it. Reason on file: %s"
% (name, method, NO_DATA, expr, reason),
)
class HtmlByDesignSinkTest(unittest.TestCase):
"""showSelection / showError render real markup and are NOT display-ready sinks."""
@classmethod
def setUpClass(cls):
cls.sites = _call_sites()
def test_show_selection_receives_no_channel_data(self):
expected = {
"custom-nodes-manager.js": [
'"");',
'"");',
# Unknown labels in this builder have behavioral coverage.
'list.join(""));',
],
"model-manager.js": [
'"");',
'"");',
'`<span>Selected <b>${selectedList.length}</b> models <button '
'class="cmm-btn-install p-button p-component" mode="install">Install</button>`);',
],
"node-usage-analyzer.js": [
'"");',
'"");',
'`<span>Selected <b>${selectedList.length}</b> packages (none can be uninstalled)</span>`);',
'` <div class="nu-selected-buttons"> <span>Selected <b>${installedSelected.length}</b> '
'installed packages</span> <button class="nu-btn-uninstall" mode="uninstall">'
'Uninstall Selected</button> </div> `);',
],
}
actual = Counter((name, arg) for name, _, _, method, arg in self.sites if method == "showSelection")
self.assertEqual(actual, Counter((name, arg) for name, args in expected.items() for arg in args))
def test_show_error_arguments_are_escaped_or_literal(self):
# showError forwards to showMessage, which does not escape, AND its
# errorMsg is separately handed to ComfyUI-core show_message(). So every
# non-literal argument must already be escaped at the call site.
escaped = {
"custom-nodes-manager.js": {
'`Failed to get custom node mappings: ${sanitizeHTML(String(res.error))}`);',
'`Failed to get alternatives: ${sanitizeHTML(String(res.error))}`);',
'`Failed to get workflow data: ${sanitizeHTML(String(result.error))}`);',
},
"node-usage-analyzer.js": {'sanitizeHTML(String(result.error)));'},
}
for name, lineno, _receiver, method, arg in self.sites:
if method != "showError":
continue
is_literal = re.fullmatch(
r'''(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')\s*\);''', arg,
) is not None
# These direct arguments are checked by the per-file guards below.
is_error_var = re.fullmatch(r"errorMsg\s*\);", arg) is not None
with self.subTest(file=name, line=lineno, arg=arg):
if is_literal or is_error_var:
continue
self.assertIn(
arg, escaped.get(name, set()),
"%s:%d passes an unescaped non-literal into showError, which does "
"not escape and whose text also reaches core show_message()"
% (name, lineno),
)
def test_every_error_message_builder_escapes_its_server_text(self):
common = {
'errorMsg = "";',
"errorMsg += `This action is not allowed with this security level configuration.\\n`;",
"errorMsg += sanitizeHTML(await res.text()) + '\\n';",
}
queue_error = "errorMsg += sanitizeHTML(String(v)) + '\\n';"
outdated = "errorMsg += `ComfyUI version is outdated. Please update ComfyUI to use Manager normally.\\n`;"
expected = {
"custom-nodes-manager.js": common | {
"errorMsg = `'${item.title}': `;",
"errorMsg = `Not found custom node: ${hash}`;",
queue_error, outdated,
},
"model-manager.js": common | {
"errorMsg = `'${item.name}': `;", queue_error, outdated,
},
"node-usage-analyzer.js": common | {
"errorMsg = `'${sanitizeHTML(String(item.name))}': `;", queue_error,
},
"common.js": common | {"errorMsg = `'${displayTitle}': `;"},
}
mutations = re.compile(r"(?<![\w$.])errorMsg\s*(?:[+*/%&|^?-]*=(?!=)|\+\+|--)[^;]*;")
for name, statements in expected.items():
source = (JS_DIR / name).read_text(encoding="utf-8")
actual = {re.sub(r"\s+", " ", match) for match in mutations.findall(source)}
with self.subTest(file=name):
self.assertEqual(actual, statements)
def test_error_guard_rejects_unclassified_arguments(self):
for argument in (
"err);", "errText);", "errorMsg$raw);", "err + extra);", "err.detail);",
'"prefix " + errText);', "'prefix ' + errText);",
"sanitizeHTML(err) + extra);",
):
with self.subTest(argument=argument):
guard = HtmlByDesignSinkTest("test_show_error_arguments_are_escaped_or_literal")
guard.sites = [("custom-nodes-manager.js", 1, "this", "showError", argument)]
result = unittest.TestResult()
guard.run(result)
self.assertEqual(len(result.failures), 1)
self.assertEqual(result.errors, [])
def test_html_guards_reject_unreviewed_source(self):
cases = [
("custom-nodes-manager.js", 'this.showSelection(list.join(""));',
'this.showSelection(selectedList[0].title);',
"test_show_selection_receives_no_channel_data"),
("node-usage-analyzer.js", "${installedSelected.length}</b>",
"${installedSelected[0].title}</b>",
"test_show_selection_receives_no_channel_data"),
("custom-nodes-manager.js", 'let errorMsg = "";',
'let errorMsg = "";\nerrorMsg += response.message;',
"test_every_error_message_builder_escapes_its_server_text"),
("model-manager.js", "errorMsg += sanitizeHTML(String(v)) + '\\n';",
"errorMsg += sanitizeHTML(String(v)) + response.message + '\\n';",
"test_every_error_message_builder_escapes_its_server_text"),
]
for name, before, after, test in cases:
with self.subTest(file=name, replacement=after), tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
for filename in SINK_FILES:
source = (JS_DIR / filename).read_text(encoding="utf-8")
if filename == name:
self.assertIn(before, source)
source = source.replace(before, after, 1)
(directory / filename).write_text(source, encoding="utf-8")
with patch(__name__ + ".JS_DIR", directory):
guard = HtmlByDesignSinkTest(test)
guard.sites = _call_sites()
result = unittest.TestResult()
guard.run(result)
self.assertTrue(result.failures)
self.assertEqual(result.errors, [])
def test_duplicate_calls_require_review(self):
for cls, test, argument in (
(MessageSinkRegistryTest, 'test_every_caller_is_classified', 'msg);'),
(HtmlByDesignSinkTest, 'test_show_selection_receives_no_channel_data', 'list.join(""));'),
):
with self.subTest(test=test):
guard = cls(test)
guard.sites = _call_sites()
guard.sites.append(next(site for site in guard.sites if site[-1] == argument))
result = unittest.TestResult()
guard.run(result)
self.assertTrue(result.failures)
self.assertEqual(result.errors, [])
if __name__ == "__main__":
unittest.main()
+412
View File
@@ -0,0 +1,412 @@
import html
import json
import os
import unittest
from pathlib import Path
from js_lift import NODE, JsSource, parse as _parse, run_node as _run_node, slice_braced
from manager_test_utils import load_manager_util, load_markdown_functions
REPO_ROOT = Path(__file__).resolve().parent.parent
JS_DIR = Path(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
JS = JsSource(JS_DIR)
MANAGER_UTIL = load_manager_util()
MODEL_MANAGER_SRC = (JS_DIR / "model-manager.js").read_text(encoding="utf-8")
COMMON_SRC = (JS_DIR / "common.js").read_text(encoding="utf-8")
def _lift_sanitize_html() -> str:
return JS.lift_declaration("common.js", "export function sanitizeHTML(")
def _lift_icons() -> str:
"""The real `icons` constant — the url formatter embeds icons.download."""
return JS.lift_declaration("common.js", "export const icons = ")
def _lift_url_helpers() -> str:
"""sanitizeUrl + safeHref, which live in common.js since the consolidation.
Returns '' on a pre-consolidation tree so the MANAGER_JS_DIR reproduce still
works against an older revision: there the helpers were local to
model-manager.js and _lift_model_manager_helpers picks them up instead.
"""
if "export function sanitizeUrl" not in COMMON_SRC:
return ""
span = JS.lift_span("common.js", "export const SAFE_URL_SCHEMES",
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
return span.replace("export ", "")
def _lift_model_manager_helpers() -> str:
"""The escaping helpers that are still local to model-manager.js.
escapeCell and renderOptions stay in the client file — they are specific to
its grid and its filter dropdowns. On a pre-consolidation tree this block
also still contains sanitizeUrl/safeHref, which is why the slice starts at
whichever marker that revision actually has.
"""
if "const escapeCell" not in MODEL_MANAGER_SRC:
return ""
marker = ("const SAFE_URL_SCHEMES" if "const SAFE_URL_SCHEMES" in MODEL_MANAGER_SRC
else "const escapeCell")
start = MODEL_MANAGER_SRC.index(marker)
end = MODEL_MANAGER_SRC.index("const renderOptions")
end = MODEL_MANAGER_SRC.index('}).join("");', end) + len('}).join("");')
return MODEL_MANAGER_SRC[start:end]
def _slice_column(column_id: str) -> str:
"""Return one column-definition object out of the `}, {`-chained columns array.
These objects are literals inside an array, so the first `{` after the id
marker belongs to the NEXT column — brace matching cannot be used. The
object instead ends at the first `}` sitting at the array's own indent.
"""
start = MODEL_MANAGER_SRC.index("id: %s," % column_id)
end = MODEL_MANAGER_SRC.index("\n\t\t}", start)
return MODEL_MANAGER_SRC[start:end]
def _lift_formatter(anchor: str) -> str:
"""Lift the ``formatter: ...`` at ``anchor`` as a bare function expression."""
block = slice_braced(MODEL_MANAGER_SRC, anchor)
marker = "formatter:"
return block[block.index(marker) + len(marker):].strip()
NAME_FORMATTER_ANCHOR = "formatter: function(name, rowItem, columnItem, cellNode)"
URL_FORMATTER_ANCHOR = "formatter: (url, rowItem, columnItem)"
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
ATTR_BREAKOUT = '" onfocus=alert(1) x="'
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class ModelManagerSinkEscapingTest(unittest.TestCase):
"""Every sink is exercised through the REAL lifted code, never a copy."""
@classmethod
def setUpClass(cls):
cls.preamble = "\n".join([
_lift_sanitize_html(),
_lift_icons(),
_lift_url_helpers(),
_lift_model_manager_helpers(),
])
def _eval(self, expressions: dict) -> dict:
body = ",".join("%s: %s" % (json.dumps(k), v) for k, v in expressions.items())
return _run_node(
"%s\n"
"const nameFormatter = %s;\n"
"const urlFormatter = %s;\n"
"console.log(JSON.stringify({%s}));\n"
% (self.preamble,
_lift_formatter(NAME_FORMATTER_ANCHOR),
_lift_formatter(URL_FORMATTER_ANCHOR),
body)
)
def _assert_inert(self, markup: str, allowed_tags):
"""No element outside the allow-list, and no event-handler attribute."""
parsed = _parse(markup)
self.assertEqual(
[t for t in parsed.tags if t not in allowed_tags], [],
"live element injected into: %r" % (markup,),
)
handlers = [name for name, _ in parsed.attrs if name.lower().startswith("on")]
self.assertEqual(handlers, [], "event-handler attribute injected into: %r" % (markup,))
# --- D-IV: name column, previously UNQUOTED href ------------------------
def test_d4_name_href_survives_breakout_attempts(self):
out = self._eval({
"space": "nameFormatter('Model', {reference: %s})" % json.dumps(BREAKOUT_HREF),
"quote": "nameFormatter('Model', {reference: %s})" % json.dumps(QUOTE_BREAKOUT),
})
for markup in out.values():
self._assert_inert(markup, allowed_tags={"a", "b"})
self.assertEqual(
sorted(name for name, _ in _parse(markup).attrs),
["href", "rel", "target"],
"unexpected attributes on the anchor: %r" % (markup,),
)
def test_d4_name_href_rejects_dangerous_schemes(self):
out = self._eval({
"js": "nameFormatter('Model', {reference: 'javascript:alert(1)'})",
"obfuscated": r"nameFormatter('Model', {reference: 'java\tscript:alert(1)'})",
"entity": "nameFormatter('Model', {reference: 'javascript&#58;alert(1)'})",
"data": "nameFormatter('Model', {reference: 'data:text/html,<script>alert(1)</script>'})",
"ok": "nameFormatter('Model', {reference: 'https://huggingface.co/repo'})",
})
for key in ("js", "obfuscated", "data"):
href = dict(_parse(out[key]).attrs)["href"]
self.assertEqual(href, "#", "%s: dangerous scheme survived as %r" % (key, href))
# The entity form is defeated by the attribute escape, not the scheme
# check: `&` becomes `&amp;`, so `&#58;` reaches the browser as text.
self.assertIn("&amp;#58;", out["entity"])
self.assertEqual(dict(_parse(out["ok"]).attrs)["href"], "https://huggingface.co/repo")
# --- D-V: url column, quoted but previously unescaped -------------------
def test_d5_url_href_escaped_inside_its_quotes(self):
out = self._eval({
"quote": "urlFormatter(%s)" % json.dumps(QUOTE_BREAKOUT),
"attr": "urlFormatter(%s)" % json.dumps(ATTR_BREAKOUT),
"js": "urlFormatter('javascript:alert(1)')",
})
self._assert_inert(out["quote"], allowed_tags={"a", "svg", "path"})
self._assert_inert(out["attr"], allowed_tags={"a", "svg", "path"})
self.assertEqual(dict(_parse(out["js"]).attrs)["href"], "#")
# --- D-III: the four previously formatter-less columns ------------------
def test_d3_columns_bind_the_escaping_formatter(self):
for column in ("'type'", "'base'", '"save_path"', "'filename'"):
block = _slice_column(column)
self.assertIn(
"formatter: escapeCell", block,
"column %s still renders RAW model-list.json into innerHTML" % column,
)
def test_d3_escape_cell_neutralises_payload_and_keeps_empty_cells(self):
out = _run_node(
"%s\nconsole.log(JSON.stringify({"
"payload: escapeCell(%s),"
"nul: escapeCell(null), undef: escapeCell(undefined),"
"num: escapeCell(42), plain: escapeCell('checkpoints')"
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD))
)
self._assert_inert(out["payload"], allowed_tags=set())
self.assertEqual(html.unescape(out["payload"]), TEXT_PAYLOAD,
"the payload must survive as visible text")
# null/undefined pass through so turbogrid keeps rendering an empty cell.
self.assertIsNone(out["nul"])
self.assertNotIn("undef", out)
self.assertEqual(out["num"], "42")
self.assertEqual(out["plain"], "checkpoints")
# --- D-VI: the showStatus message sink ----------------------------------
def test_d6_show_status_renders_item_name_inert(self):
"""item.name must reach .cmm-manager-status innerHTML inert.
This used to assert that the CALL SITE carried a sanitizeHTML. That was
wrong in a way the assertion could not see: ``item.name`` arrives from
``/externalmodel/getlist`` ALREADY escaped by ``populate_markdown`` ->
``sanitize_tag``, so escaping it again rendered ``&lt;Flux&gt;`` to the
user. The call-site escape was removed for that reason, and the sink
deliberately does not escape either -- see
tests/test_message_sink_provenance.py for why a blanket sink escape
cannot be correct across these callers.
The GUARANTEE is unchanged, and is now asserted end-to-end on the value
the caller ACTUALLY receives: take the server's own output for a hostile
name, push it through the REAL lifted sink, and read innerHTML. That is
strictly stronger than the old form, which passed whether or not the
rendered result was inert.
"""
anchor = "this.showStatus(`Install "
line = MODEL_MANAGER_SRC[MODEL_MANAGER_SRC.index(anchor):]
line = line[:line.index("\n")]
self.assertNotIn(
"sanitizeHTML", line,
"item.name is already server-escaped; escaping here is the mojibake "
"bug this call site used to have: %r" % (line,),
)
# What populate_markdown -> sanitize_tag puts on the wire for the payload.
server_value = TEXT_PAYLOAD.replace("<", "&lt;").replace(">", "&gt;")
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\tshowStatus(msg, color)")
rendered = _run_node(
"%s\nconst sink = %s;\n"
"const item = {name: %s};\n"
"const el = { innerHTML: '' };\n"
"const ctx = { element: { querySelector: () => el } };\n"
"sink.call(ctx, `Install ${item.name} ...`);\n"
"console.log(JSON.stringify({msg: el.innerHTML}));\n"
% (_lift_sanitize_html(), lifted, json.dumps(server_value))
)
self._assert_inert(rendered["msg"], allowed_tags=set())
# ...and the user reads the payload as text, not as entity soup.
self.assertEqual(
html.unescape(rendered["msg"]), "Install %s ..." % TEXT_PAYLOAD
)
# --- filter dropdowns: same raw type/base values, different route -------
def test_filter_dropdown_options_escape_label_and_value(self):
out = _run_node(
"%s\nconsole.log(JSON.stringify({"
"label: renderOptions([{label: %s, value: 'x'}], ''),"
"value: renderOptions([{label: 'x', value: %s}], ''),"
"plain: renderOptions([{label: 'checkpoints', value: 'checkpoints'}], 'checkpoints')"
"}));\n" % (self.preamble, json.dumps(TEXT_PAYLOAD), json.dumps(ATTR_BREAKOUT))
)
self._assert_inert(out["label"], allowed_tags={"option"})
self._assert_inert(out["value"], allowed_tags={"option"})
# Selection still matches on the RAW value, and benign data is untouched.
self.assertIn(" selected", out["plain"])
self.assertIn(">checkpoints</option>", out["plain"])
def test_update_filter_routes_all_three_dropdowns_through_render_options(self):
block = slice_braced(MODEL_MANAGER_SRC, "updateFilter()")
for dropdown in ("$filter", "$type", "$base"):
self.assertIn(
"%s.innerHTML = renderOptions(" % dropdown, block,
"%s still builds <option> markup inline" % dropdown,
)
# --- description: server-transformed HTML, must NOT be double-escaped ---
def test_description_column_is_not_re_escaped(self):
block = _slice_column("'description'")
self.assertNotIn(
"formatter", block,
"description is server-transformed HTML (populate_markdown); re-escaping "
"it renders visible mojibake such as 'Nodes for &lt;Flux&gt; workflows'",
)
def test_description_server_value_still_reads_cleanly(self):
# What populate_markdown -> sanitize_tag actually emits for "<Flux>".
server_value = "Nodes for &lt;Flux&gt; workflows"
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
# Run through the client escaper as well, the user would see the entity
# text instead of the angle brackets — that is what this guard forbids.
double = _run_node(
"%s\nconsole.log(JSON.stringify({v: sanitizeHTML(%s)}));\n"
% (_lift_sanitize_html(), json.dumps(server_value))
)["v"]
self.assertEqual(html.unescape(double), server_value)
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class ModelManagerMessageSinkTest(unittest.TestCase):
"""model-manager's OWN message sinks — it does not use createUIStateManager.
This class replaces an earlier ``test_turbogrid_bundle_untouched`` guard,
which asserted that ``js/turbogrid.esm.js`` was unchanged relative to a
fixed base commit. That was an INTEGRATION ARTIFACT, not a property of this
file: it encoded "only model-manager was touched by the work item that
added these tests". In the integrated tree the vendored grid IS changed on
purpose, so the assertion reported a cross-file fact that says nothing
about whether model-manager's own sinks are safe. What this file can
actually guarantee is asserted instead — and it is strictly more coverage,
because the sinks below were not covered by anything before.
"""
@classmethod
def setUpClass(cls):
cls.sanitize = _lift_sanitize_html()
def _call_sink(self, method: str, msg, color=None) -> str:
"""Run one lifted sink against a stub element; return the real innerHTML."""
lifted = "function " + slice_braced(MODEL_MANAGER_SRC, "\t%s(msg, color)" % method)
args = json.dumps(msg) + ("" if color is None else ", " + json.dumps(color))
return _run_node(
"%s\n"
"const sink = %s;\n"
"const el = { innerHTML: '' };\n"
"const ctx = { element: { querySelector: () => el } };\n"
"sink.call(ctx, %s);\n"
"console.log(JSON.stringify({v: el.innerHTML}));\n"
% (self.sanitize, lifted, args)
)["v"]
# --- the sinks take DISPLAY-READY text and do NOT escape ---------------
def test_the_sinks_deliberately_do_not_escape(self):
# Structural, and load-bearing: a well-meaning sanitizeHTML added here
# would double-escape the server-escaped values these sinks actually
# receive. tests/test_message_sink_provenance.py is what keeps that safe
# by classifying every caller.
for method in ("showStatus(msg, color)", "showMessage(msg, color)"):
body = slice_braced(MODEL_MANAGER_SRC, "\t" + method)
self.assertNotIn(
"sanitizeHTML", body,
"%s escapes at the sink; its callers pass ALREADY-escaped server "
"values, so this renders '&lt;Flux&gt;' to the user" % method,
)
self.assertIn("innerHTML", body)
def test_a_server_escaped_value_renders_inert_through_the_sink(self):
server_value = "Install %s ..." % TEXT_PAYLOAD.replace(
"<", "&lt;").replace(">", "&gt;")
rendered = self._call_sink("showStatus", server_value)
self.assertNotIn("img", _parse(rendered).tags)
def test_a_benign_angle_bracket_name_is_not_double_escaped(self):
# THE regression this arrangement exists to prevent. The server sends
# 'Model for &lt;Flux&gt; workflows'; the user must read the angle
# brackets, not the entities.
server_value = "Install Model for &lt;Flux&gt; workflows ..."
rendered = self._call_sink("showStatus", server_value)
self.assertEqual(
html.unescape(rendered), "Install Model for <Flux> workflows ..."
)
self.assertNotIn("&amp;lt;", rendered)
def test_the_font_wrapper_still_renders(self):
rendered = self._call_sink("showMessage", "plain text", "red")
collector = _parse(rendered)
self.assertIn("font", collector.tags)
self.assertIn(("color", "red"), collector.attrs)
# --- showError: HTML BY CONTRACT, escaped by its callers ---------------
def test_show_error_forwards_to_the_non_escaping_show_message(self):
# showError is HTML by contract: its errorMsg is built escaped because
# the SAME string also goes to ComfyUI-core show_message(). Forwarding
# to a NON-escaping showMessage keeps that at exactly one escape.
body = slice_braced(MODEL_MANAGER_SRC, "\tshowError(err)")
self.assertIn("this.showMessage(err, \"red\")", body)
# --- the caller side ---------------------------------------------------
def test_the_install_status_caller_passes_the_server_value_untouched(self):
self.assertIn("this.showStatus(`Install ${item.name} ...`)", MODEL_MANAGER_SRC)
def test_the_install_error_message_escapes_only_the_raw_response(self):
# The name is already escaped by the server; the error response is raw.
self.assertIn(
"errorMsg = `'${item.name}': `", MODEL_MANAGER_SRC
)
self.assertIn("errorMsg += sanitizeHTML(await res.text())", MODEL_MANAGER_SRC)
def test_install_failure_preserves_server_escaped_names_in_both_sinks(self):
populate = load_markdown_functions(MANAGER_UTIL)["populate_markdown"]
methods = ",\n".join(slice_braced(MODEL_MANAGER_SRC, marker) for marker in [
"async installModels(list, btn)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)"
])
response_text = '<img src=x onerror="window.__probe=1"> download failed'
for name in ["Model <Flux>", '<img src=x onerror="window.__probe=1">']:
for status in [403, 500]:
with self.subTest(name=name, status=status):
item = {"name": name, "originalData": {}, "hash": "model"}
populate(item)
rendered = _run_node("""
%s
const messages = {};
const elements = {};
const api = {fetchApi: async (url) => url.endsWith('/status')
? {json: async () => ({is_processing: false})}
: {status: %d, json: async () => ({}), text: async () => %s}};
const show_message = (message) => messages.dialog = message;
const ctx = {
%s,
element: {querySelector: (selector) => elements[selector] ||= {innerHTML: ''}},
grid: {scrollRowIntoView() {}, updateCell() {}},
focusInstall() {return true;}
};
await ctx.installModels([%s], {classList: {add() {}}});
messages.panel = elements['.cmm-manager-message'].innerHTML;
console.log(JSON.stringify(messages));
""" % (self.sanitize, status, json.dumps(response_text), methods, json.dumps(item)))
self.assertEqual(set(rendered), {"panel", "dialog"})
for message in rendered.values():
self.assertIn("'%s': " % name, html.unescape(message))
self.assertNotIn("img", _parse(message).tags)
if status == 500:
self.assertIn(response_text, html.unescape(message))
def test_the_queue_result_error_text_is_escaped_by_the_caller(self):
self.assertIn("errorMsg += sanitizeHTML(String(v))", MODEL_MANAGER_SRC)
if __name__ == "__main__":
unittest.main()
+286
View File
@@ -0,0 +1,286 @@
"""RED->GREEN guard for the js/node-usage-analyzer.js innerHTML sinks.
The analyzer writes pack data and workflow data into innerHTML through turbogrid
cells, a flyover, and the status/message elements. Before the fix: the title
column built an UNQUOTED href (a bare space breaks out of the attribute); the
install-progress showStatus interpolated a raw pack key; the usage-details
flyover interpolated a raw workflow filename; and the install error path
interpolated both a raw pack key and raw server text.
One value needed fixing at its SOURCE rather than at the sink. The title column
is built as `pack.title || packKey`: /customnode/getlist runs populate_markdown
over each pack, so `pack.title` is server-escaped, but `packKey` is a dict key
that no server transform touches. Escaping at the formatter would have
double-escaped the common path into visible entity text ("Nodes for &lt;Flux&gt;
workflows"); leaving it bare would have shipped the raw fallback. The fix
normalises the fallback so the column carries ONE provenance, which is what
makes the bare formatter correct by construction.
This grid uses cellResizeObserver only — no rowFilter, no
highlightKeywordsFilter — so the keyword-highlight re-render does not reach it.
The two searchable managers use js/manager-grid.js to secure that path without
modifying the vendored TurboGrid bundle.
The status/message sinks take DISPLAY-READY text and do NOT escape, because
their callers carry mixed provenance; every caller is classified in
tests/test_message_sink_provenance.py. This analyzer is the only file that
reaches BOTH sink implementations — the shared one from common.js via `this.ui`
and its own class methods.
REPRODUCE THE RED HALF against the pre-fix revision — one command:
mkdir -p /some/dir
git show <base>:js/node-usage-analyzer.js > /some/dir/node-usage-analyzer.js
git show <base>:js/common.js > /some/dir/common.js
MANAGER_JS_DIR=/some/dir pytest tests/test_node_usage_escaping.py
"""
import html
import json
import os
import unittest
from pathlib import Path
from js_lift import (
NODE,
JsSource,
event_handlers,
parse,
run_node,
slice_braced,
)
REPO_ROOT = Path(__file__).resolve().parent.parent
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
ANALYZER = "node-usage-analyzer.js"
COMMON = "common.js"
TITLE_FORMATTER_ANCHOR = "formatter: function (name, rowItem, columnItem, cellNode)"
BREAKOUT_HREF = "https://evil.example/a onmouseover=alert(1) x"
QUOTE_BREAKOUT = 'https://evil.example/a"><img src=x onerror=alert(1)>'
# An ordinary pack reference, for the legs that ask what a CORRECT link
# looks like rather than what a hostile one cannot do.
BENIGN_REFERENCE = "https://github.com/owner/repo"
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
def _url_helper_home() -> str:
"""Which file currently owns sanitizeUrl/safeHref, or '' on a pre-fix tree.
They were local to the analyzer when this guard was written and now live in
common.js, imported by every client file. Resolving the home rather than
hard-coding it keeps the MANAGER_JS_DIR reproduce working against both
revisions, and makes a THIRD home fail loudly instead of being lifted twice.
"""
for filename in (COMMON, ANALYZER):
if "SAFE_URL_SCHEMES" in JS.text(filename):
return filename
return ""
def _preamble() -> str:
parts = [JS.lift_declaration(COMMON, "export function sanitizeHTML(")]
home = _url_helper_home()
if home:
span = JS.lift_span(
home, "SAFE_URL_SCHEMES", "safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
parts.append("const " + span.replace("export ", ""))
return "\n".join(parts)
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class NodeUsageSinkEscapingTest(unittest.TestCase):
"""Every sink is exercised through the REAL lifted code, never a copy."""
def _assert_inert(self, markup: str, allowed_tags):
"""No element outside the allow-list, and no event-handler attribute."""
parsed = parse(markup)
self.assertEqual(
[t for t in parsed.tags if t not in allowed_tags], [],
"live element injected into: %r" % (markup,),
)
self.assertEqual(
event_handlers(markup), [],
"event-handler attribute injected into: %r" % (markup,),
)
# --- C2: title column, previously UNQUOTED href -------------------------
def _render_title(self, reference: str, name: str = "My Pack") -> str:
return run_node(
"%s\nconst titleFormatter = %s;\n"
"console.log(JSON.stringify({markup: titleFormatter(%s, {reference: %s})}));\n"
% (_preamble(),
JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR),
json.dumps(name), json.dumps(reference))
)["markup"]
def test_c2_href_survives_breakout_attempts(self):
for reference in (BREAKOUT_HREF, QUOTE_BREAKOUT):
markup = self._render_title(reference)
self._assert_inert(markup, allowed_tags={"a", "b"})
self.assertEqual(
sorted(name for name, _ in parse(markup).attrs),
["href", "rel", "target"],
"unexpected attributes on the anchor: %r" % (markup,),
)
def test_c2_title_anchor_severs_the_opener(self):
"""The pack chooses this host, so the page it opens must not keep us.
`reference` is channel data, so the pack author picks the page that
opens; without rel that page holds a live window.opener back into the
Manager UI. Membership on the SPLIT attribute, not a literal match:
'noreferrer noopener' is the same thing, and pinning token order would
report a correct fix as a defect.
"""
markup = self._render_title(BENIGN_REFERENCE)
attrs = dict(parse(markup).attrs)
self.assertEqual(
attrs.get("target"), "_blank",
"this guard is about _blank anchors; the target changed, so "
"re-derive it rather than deleting the assertion: %r" % (markup,))
self.assertIn(
"rel", attrs,
"the title anchor opens a pack-chosen page in a new tab without "
"rel, so that page keeps a reference to this window: %r" % (markup,))
tokens = attrs["rel"].lower().split()
self.assertIn("noopener", tokens, markup)
self.assertIn("noreferrer", tokens, markup)
def test_c2_href_rejects_dangerous_schemes(self):
for reference in ("javascript:alert(1)", "java\tscript:alert(1)",
"data:text/html,<script>alert(1)</script>"):
href = dict(parse(self._render_title(reference)).attrs)["href"]
self.assertEqual(href, "#", "%r survived as %r" % (reference, href))
def test_c2_benign_reference_and_title_are_untouched(self):
markup = self._render_title(BENIGN_REFERENCE, name="My Pack")
self.assertEqual(dict(parse(markup).attrs)["href"], BENIGN_REFERENCE)
self.assertIn("<b>My Pack</b>", markup)
def test_c2_title_is_not_double_escaped_at_the_formatter(self):
# The column value is already escaped HTML text by the time it arrives,
# so re-escaping here would put entity text on screen.
block = JS.lift_formatter(ANALYZER, TITLE_FORMATTER_ANCHOR)
self.assertNotIn(
"sanitizeHTML(String(name))", block,
"title is normalised at its source; escaping it again renders '&lt;' to the user",
)
server_value = "Nodes for &lt;Flux&gt; workflows"
markup = self._render_title("https://example.com/x", name=server_value)
self.assertIn(server_value, markup)
self.assertEqual(html.unescape(server_value), "Nodes for <Flux> workflows")
def _run_panel(self, action, packs=None, status=500):
"""Run the production data, operation and rendering methods with stubbed I/O."""
methods = ",\n".join(slice_braced(JS.text(ANALYZER), "\n\t" + marker) for marker in [
"async loadData()", "async installModels(list, btn)", "async uninstallModels(list, btn)",
"showUsageDetails(rowItem)", "showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
])
return run_node("""
%s
%s
%s
const elements = {};
const messages = {};
const posts = [];
const manager_instance = {datasrc_combo: {value: 'default'}};
const fetchData = async () => ({data: {channel: 'default', node_packs: %s}});
const analyzeWorkflowUsage = async () => ({success: true});
const api = {fetchApi: async (url, options) => {
if (options?.body) posts.push(JSON.parse(options.body));
return {status: %d, json: async () => ({is_processing: false}), text: async () => %s};
}};
const customConfirm = async () => true;
const md5 = () => 'hash';
const show_message = message => messages.dialog = message;
const btn = {classList: {add() {}, remove() {}}};
const ctx = {
%s,
element: {querySelector: selector => elements[selector] ||= {innerHTML: '', style: {}}},
grid: {scrollRowIntoView() {}, updateCell() {}, updateRow() {}},
flyover: {show: (title, body) => Object.assign(messages, {title, body})},
showLoading() {}, hideLoading() {}, renderGrid() {}, getModelList(rows) {return rows;}
};
ctx.ui = createUIStateManager(ctx.element, {
status: '.nu-manager-status', message: '.nu-manager-message'
});
%s
console.log(JSON.stringify({elements, messages, posts, rows: ctx.modelList}));
""" % (_preamble(),
JS.lift_declaration(COMMON, "export function createUIStateManager(element, selectors)"),
JS.lift_declaration(COMMON, "export async function uninstallNodes(nodeList, options = {})"),
json.dumps(packs or {}), status, json.dumps(TEXT_PAYLOAD), methods, action))
def test_title_fallback_is_escaped_at_its_source(self):
out = self._run_panel("await ctx.loadData();", {TEXT_PAYLOAD: {"state": "enabled"}})
row = out["rows"][0]
self._assert_inert(row["title"], allowed_tags=set())
self.assertEqual(html.unescape(row["title"]), TEXT_PAYLOAD)
self.assertEqual(row["name"], TEXT_PAYLOAD)
def test_install_progress_and_errors_escape_raw_values(self):
for status in (403, 500):
with self.subTest(status=status):
out = self._run_panel("await ctx.loadData(); await ctx.installModels(ctx.modelList, btn);",
{TEXT_PAYLOAD: {"state": "enabled"}}, status)
progress = out["elements"][".nu-manager-status"]["innerHTML"]
self._assert_inert(progress, allowed_tags=set())
self.assertEqual(html.unescape(progress), f"Install {TEXT_PAYLOAD} ...")
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
for error in errors:
self._assert_inert(error, allowed_tags={"font"})
self.assertIn(f"'{TEXT_PAYLOAD}': ", html.unescape(error))
if status == 500:
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
def test_flyover_escapes_workflow_filename(self):
for filename in (TEXT_PAYLOAD, "workflow <Flux> & friends.json"):
with self.subTest(filename=filename):
out = self._run_panel("ctx.showUsageDetails(%s);" % json.dumps({
"title": "My Pack", "workflowDetails": [{"filename": filename, "nodeCount": 2}],
}))
body = out["messages"]["body"]
self._assert_inert(body, allowed_tags={"div"})
self.assertIn(filename, html.unescape(body))
self.assertIn("2 nodes", body)
def test_uninstall_failure_keeps_normalized_titles_readable(self):
for name in ("Pack <Flux> & friends", TEXT_PAYLOAD):
for has_title in (False, True):
for status in (403, 500):
with self.subTest(name=name, has_title=has_title, status=status):
pack = {"state": "enabled"}
if has_title:
pack["title"] = html.escape(name)
out = self._run_panel("await ctx.loadData(); await ctx.uninstallModels(ctx.modelList, btn);",
{name: pack}, status)
progress = out["elements"][".nu-manager-status"]["innerHTML"]
self.assertEqual(html.unescape(progress), f"Uninstall {name} ...")
errors = [out["elements"][".nu-manager-message"]["innerHTML"], out["messages"]["dialog"]]
for error in errors:
self._assert_inert(error, allowed_tags={"font"})
self.assertIn(f"'{name}': ", html.unescape(error))
if status == 500:
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(error))
def test_uninstall_helper_escapes_a_raw_name_fallback(self):
out = self._run_panel("""
await uninstallNodes([{name: %s}], {
onProgress: msg => messages.progress = msg,
onError: msg => messages.error = msg
});
""" % json.dumps(TEXT_PAYLOAD))
for message in out["messages"].values():
self._assert_inert(message, allowed_tags=set())
self.assertIn(TEXT_PAYLOAD, html.unescape(message))
self.assertEqual(out["posts"][0]["name"], TEXT_PAYLOAD)
def test_grid_has_no_keyword_highlight_path(self):
# The premise the row states: this grid cannot hit the highlight defect.
source = JS.text(ANALYZER)
self.assertNotIn("highlightKeywordsFilter", source)
self.assertNotIn("rowFilter:", source)
if __name__ == "__main__":
unittest.main()
+83
View File
@@ -0,0 +1,83 @@
"""Exercise notice dependency installation without starting ComfyUI or pip."""
import ast
import builtins
import importlib.metadata
import itertools
import os
import re
import subprocess
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import Mock, mock_open, patch
import toml
from packaging.requirements import Requirement
from manager_test_utils import load_manager_util
ROOT = Path(__file__).resolve().parent.parent
class NoticeDependencyTest(unittest.TestCase):
def test_util_can_load_before_nh3_is_installed(self):
with patch.dict(sys.modules, {'nh3': None}):
util = load_manager_util()
self.assertEqual(util.escape_html_attribute('<text>'), '&lt;text&gt;')
with self.assertRaises(ModuleNotFoundError):
util.sanitize_html_fragment('<p>notice</p>')
def test_bootstrap_installs_missing_or_outdated_nh3_before_importing_it(self):
manifest = (ROOT / 'requirements.txt').read_text()
commented_manifest = '\n# Dependencies\n--index-url https://pypi.org/simple\n' + manifest.replace(
'nh3>=0.3.7', ' nh3>=0.3.7 # notice sanitizer',
)
for installed_version, requirements in itertools.product(
(None, '0.3.0', '0.3.7rc1', '0.3.7', '0.4.0'), (manifest, commented_manifest),
):
with self.subTest(installed_version=installed_version, commented=requirements == commented_manifest):
source = (ROOT / 'prestartup_script.py').read_text()
node = next(n for n in ast.parse(source).body
if isinstance(n, ast.FunctionDef) and n.name == 'ensure_dependencies')
installer = Mock()
namespace = {
'__file__': str(ROOT / 'prestartup_script.py'),
'os': os,
're': re,
'subprocess': types.SimpleNamespace(
check_output=installer, CalledProcessError=subprocess.CalledProcessError,
),
'manager_util': types.SimpleNamespace(make_pip_cmd=lambda args: args),
}
exec(compile(ast.Module(body=[node], type_ignores=[]), '<bootstrap>', 'exec'), namespace)
needs_install = installed_version in (None, '0.3.0', '0.3.7rc1')
modules = {name: types.ModuleType(name) for name in ('git', 'toml', 'rich', 'chardet')}
# An old native module must not be imported before pip upgrades it.
modules['nh3'] = None if needs_install else types.ModuleType('nh3')
failure = importlib.metadata.PackageNotFoundError('nh3') if installed_version is None else None
with patch.dict(sys.modules, modules), patch(
'importlib.metadata.version', return_value=installed_version, side_effect=failure,
), patch('builtins.open', mock_open(read_data=requirements)), patch('builtins.print'), patch(
'builtins.__import__', wraps=builtins.__import__,
) as imports:
namespace['ensure_dependencies']()
nh3_imports = [call for call in imports.call_args_list if call.args[0] == 'nh3']
self.assertEqual(len(nh3_imports), 0 if needs_install else 1)
if needs_install:
installer.assert_called_once_with(['install', '-r', str(ROOT / 'requirements.txt')])
else:
installer.assert_not_called()
def test_dependency_manifests_agree_on_notice_requirements(self):
lines = (re.split(r'\s+#', line.strip(), maxsplit=1)[0]
for line in (ROOT / 'requirements.txt').read_text().splitlines())
pip_requirements = {
req.name: req for req in map(Requirement, (line for line in lines if line and not line.startswith(('#', '-'))))
}
project_requirements = {
req.name: req for req in map(Requirement, toml.load(ROOT / 'pyproject.toml')['project']['dependencies'])
}
for name in ('nh3', 'packaging'):
with self.subTest(name=name):
self.assertEqual(str(pip_requirements[name]), str(project_requirements[name]))
+372
View File
@@ -0,0 +1,372 @@
"""RED->GREEN guard for the pack-grid column sinks in js/custom-nodes-manager.js.
WHY THESE THREE VALUES
----------------------
The Custom Nodes grid renders every cell through turbogrid, which hands a column
formatter the RAW row value and assigns the formatter's return through
``innerHTML``. Whatever a formatter returns unescaped is therefore live markup on
FIRST RENDER, before any click, for every row the list contains.
Three values reached that sink unescaped, and all three carry the same provenance
as ``author`` (which the neighbouring formatter in the same column array already
escapes):
``stars`` and ``last_update`` are written by ``populate_github_stats`` in
glob/manager_core.py straight off github-stats.json, with no transform.
``populate_markdown`` -- the only server-side escape on this payload -- covers
``description``, ``name`` and ``title`` and NOTHING else, and
``populate_github_stats`` swallows a broken row with a bare ``except:``, so a
channel-supplied ``stars`` / ``last_update`` survives untouched when
``v['reference']`` raises. That double fact is pinned in
``PopulateProvenanceTest`` below, because it is what makes escaping HERE
correct rather than a double-escape.
``channel`` is the resolved channel alias from /customnode/getlist -- a key out
of the channel-list config file, passed through untransformed into the
"Channel: ..." banner's ``innerHTML``.
WHY THE OLD GUARDS DID NOT STOP THEM
------------------------------------
Neither ``stars`` guard was a guard. ``stars < 0`` is a NaN comparison for a
string ('<img ...>' < 0 is false), and ``typeof stars === 'number'`` only chooses
the toLocaleString branch -- so a string payload fell through to the bare
``return stars;``. ``last_update`` looked defended by ``.split(' ')[0]``, but a
space is not the only attribute separator a browser accepts: ``/`` works, and so
do tab and newline, so ``<img/src=x/onerror=alert(1)>`` passes through the split
whole. Escaping is what closes both; a smarter split would not.
REPRODUCE THE RED HALF against the pre-fix revision -- one command:
mkdir -p /some/dir
git show <base>:js/custom-nodes-manager.js > /some/dir/custom-nodes-manager.js
git show <base>:js/common.js > /some/dir/common.js
MANAGER_JS_DIR=/some/dir pytest tests/test_pack_column_escaping.py
"""
import html
import json
import os
import unittest
from pathlib import Path
from js_lift import (
NODE,
JsSource,
event_handlers,
line_containing,
parse,
slice_object_entry,
slice_braced,
)
from js_lift import run_node
from manager_test_utils import load_manager_util, load_markdown_functions
REPO_ROOT = Path(__file__).resolve().parent.parent
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
GLOB_DIR = Path(os.environ.get("MANAGER_GLOB_DIR") or (REPO_ROOT / "glob"))
MANAGER = "custom-nodes-manager.js"
COMMON = "common.js"
STARS_ANCHOR = "id: 'stars',"
LAST_UPDATE_ANCHOR = "id: 'last_update',"
CHANNEL_ANCHOR = '.cn-manager-channel").innerHTML'
# A space-separated payload for the columns that never split, and a
# SLASH-separated one for last_update, whose `.split(' ')[0]` used to read as a
# defence. Neither needs `<` to be dangerous in an attribute, but both are
# element injections here because the sink is element text.
TEXT_PAYLOAD = "<img src=x onerror=alert(1)>"
NO_SPACE_PAYLOAD = "<img/src=x/onerror=alert(1)>"
TAB_PAYLOAD = "<img\tsrc=x\tonerror=alert(1)>"
def _lift_get_time_ago() -> str:
"""getTimeAgo, lifted by SPAN rather than by brace matching.
js_lift's brace scanner reads a lone ``/`` as a regex literal -- correct for
every range it was written for -- and getTimeAgo DIVIDES
(``Math.abs(diff) / divisor``), so ``slice_braced`` raises on it by design
rather than handing back a wrong slice. The span is bounded by the
function's own last statement instead, so a change to that statement fails
loudly here rather than lifting a truncated body.
"""
span = JS.lift_span(COMMON, "export function getTimeAgo(", "'in a year', diff < 0);")
return span.replace("export ", "", 1) + "\n}"
def _preamble() -> str:
"""sanitizeHTML and getTimeAgo, lifted from the shipped common.js."""
return "\n".join([
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
_lift_get_time_ago(),
])
def _render(formatter_source: str, value) -> str:
return run_node(
"%s\nconst formatter = %s;\n"
"console.log(JSON.stringify({markup: String(formatter(%s))}));\n"
% (_preamble(), formatter_source, json.dumps(value))
)["markup"]
class _InertAssertions(unittest.TestCase):
def assert_inert(self, markup: str, allowed_tags):
parsed = parse(markup)
self.assertEqual(
[t for t in parsed.tags if t not in allowed_tags], [],
"live element injected into: %r" % (markup,),
)
self.assertEqual(
event_handlers(markup), [],
"event-handler attribute injected into: %r" % (markup,),
)
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class StarsColumnTest(_InertAssertions):
"""The ★ column formatter, lifted and executed as shipped."""
@classmethod
def setUpClass(cls):
cls.formatter = JS.lift_formatter(MANAGER, STARS_ANCHOR)
def test_a_string_payload_renders_inert(self):
for payload in (TEXT_PAYLOAD, NO_SPACE_PAYLOAD, TAB_PAYLOAD):
with self.subTest(payload=payload):
markup = self._markup(payload)
self.assert_inert(markup, allowed_tags=set())
self.assertEqual(
html.unescape(markup), payload,
"the payload must survive as READABLE TEXT, not be dropped",
)
def _markup(self, value):
return _render(self.formatter, value)
def test_a_number_still_renders_through_toLocaleString(self):
"""The behaviour the fix must not disturb: a real star count is grouped.
Compared against node's OWN toLocaleString rather than a hard-coded
"1,234", so the assertion does not encode the runtime's default locale.
"""
expected = run_node(
"console.log(JSON.stringify({v: (1234567).toLocaleString()}));"
)["v"]
markup = self._markup(1234567)
self.assertEqual(markup, expected)
self.assertNotIn("&", markup, "a number must not come back entity-escaped")
def test_a_negative_count_is_still_na(self):
# -1 is populate_github_stats' "no stats for this pack" sentinel.
self.assertEqual(self._markup(-1), "N/A")
def test_zero_is_not_treated_as_missing(self):
self.assertEqual(self._markup(0), "0")
def test_a_missing_value_still_renders_an_empty_cell(self):
"""null / undefined pass through, as at every other escaped column.
turbogrid's renderNodeContent does `void 0===e&&(e="")` and then assigns
`innerHTML`, which is [LegacyNullToEmptyString] -- so a formatter that
passes null/undefined THROUGH yields an empty cell, while one that
stringifies them first puts the literal words "undefined" / "null" on
screen. That is the shape spelled out at the id formatter in this same
file and at model-manager's escapeCell, both of which say so in a comment.
It is reachable on exactly the malformed-row path this file is about:
populate_github_stats swallows a broken entry with a bare `except:`, so
`stars` is simply ABSENT for a pack whose `reference` lookup raises.
Asserted through renderNodeContent's own semantics rather than on the
formatter's return alone, because "returns undefined" and "renders an
empty cell" are different claims and it is the second one the user sees.
"""
out = run_node(
"%s\nconst formatter = %s;\n"
"const cell = (v) => { let e = formatter(v); if (void 0 === e) e = '';\n"
" return e === null ? '' : String(e); };\n"
"console.log(JSON.stringify({\n"
" undefined_passthrough: formatter(undefined) === undefined,\n"
" null_passthrough: formatter(null) === null,\n"
" undefined_cell: cell(undefined), null_cell: cell(null)}));\n"
% (_preamble(), self.formatter)
)
self.assertTrue(
out["undefined_passthrough"],
"a missing star count is stringified instead of passed through, so "
"the cell reads 'undefined' where it used to be empty",
)
self.assertTrue(out["null_passthrough"], "null is stringified, not passed through")
self.assertEqual(out["undefined_cell"], "")
self.assertEqual(out["null_cell"], "")
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class LastUpdateColumnTest(_InertAssertions):
"""The Last Update column formatter, lifted and executed as shipped."""
@classmethod
def setUpClass(cls):
cls.formatter = JS.lift_formatter(MANAGER, LAST_UPDATE_ANCHOR)
def _markup(self, value):
return _render(self.formatter, value)
def test_a_slash_separated_payload_renders_inert(self):
"""The case `.split(' ')[0]` cannot touch: no space anywhere in it."""
markup = self._markup(NO_SPACE_PAYLOAD)
self.assert_inert(markup, allowed_tags={"span"})
def test_separator_variants_render_inert(self):
for payload in (TEXT_PAYLOAD, NO_SPACE_PAYLOAD, TAB_PAYLOAD):
with self.subTest(payload=payload):
self.assert_inert(self._markup(payload), allowed_tags={"span"})
def test_the_title_attribute_cannot_be_broken_out_of(self):
# `ago` is not escaped, deliberately: getTimeAgo returns "" or a phrase
# off a fixed table and never echoes its input. This pins that premise
# behaviourally rather than trusting it.
markup = self._markup('x" onmouseover=alert(1) y')
self.assertEqual(event_handlers(markup), [], markup)
self.assertEqual(
sorted(name for name, _ in parse(markup).attrs), ["title"], markup,
)
def test_a_normal_date_still_renders_its_short_form(self):
markup = self._markup("2024-01-02 03:04:05")
self.assertIn(">2024-01-02<", markup)
self.assertEqual([t for t in parse(markup).tags], ["span"])
def test_a_negative_timestamp_is_still_na(self):
self.assertEqual(self._markup(-1), "N/A")
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class ChannelBannerTest(_InertAssertions):
"""The "Channel: ..." banner, rendered through the shipped source line."""
@classmethod
def setUpClass(cls):
cls.line = line_containing(JS.text(MANAGER), CHANNEL_ANCHOR)
def _markup(self, channel):
expression = self.line.split("innerHTML =", 1)[1].strip().rstrip(";")
return run_node(
"%s\nconst self = {channel: %s};\n"
"console.log(JSON.stringify({markup: %s}));\n"
% (_preamble(), json.dumps(channel),
expression.replace("this.channel", "self.channel"))
)["markup"]
def test_a_payload_in_the_channel_alias_renders_inert(self):
markup = self._markup(TEXT_PAYLOAD)
self.assert_inert(markup, allowed_tags=set())
self.assertIn("Channel: ", markup)
def test_a_benign_alias_is_still_readable(self):
# No server transform touches this value, so escaping it once is
# correct and cannot show entity text for an ordinary alias.
self.assertIn("Channel: dev (Incomplete list)", self._markup("dev"))
class ColumnShapeTest(unittest.TestCase):
"""The formatters must keep EXISTING, or the grid's default takes over.
turbogrid's default formatter is a pass-through, so deleting either
formatter silently restores the raw-value-into-innerHTML path these tests
exist to close -- with every behavioural test above still passing, because
they lift the formatter and would simply stop being able to find one.
"""
def test_both_columns_declare_a_formatter(self):
source = JS.text(MANAGER)
for anchor in (STARS_ANCHOR, LAST_UPDATE_ANCHOR):
with self.subTest(column=anchor):
entry = slice_object_entry(source, anchor)
self.assertIn(
"formatter:", entry,
"%s lost its formatter; the grid default renders the raw "
"value into innerHTML" % (anchor,),
)
self.assertIn("sanitizeHTML(", entry)
class PopulateProvenanceTest(unittest.TestCase):
"""WHY escaping here is single-escaping, not double-escaping.
If the server ever started escaping these two fields, the formatters would
render entity text to the user and this file would be the thing to change.
The premise is therefore asserted rather than assumed.
"""
def test_populate_markdown_does_not_cover_stars_or_last_update(self):
source = (GLOB_DIR / "manager_server.py").read_text(encoding="utf-8")
body = source[source.index("def populate_markdown("):]
body = body[:body.index("\ndef ", 1)] if "\ndef " in body[1:] else body
for field in ("'description'", "'name'", "'title'"):
self.assertIn("if %s in x:" % field, body)
for field in ("stars", "last_update"):
self.assertNotIn(
"'%s'" % field, body,
"populate_markdown now touches %s -- if it escapes it, the "
"formatter must STOP escaping or the user reads entity text"
% (field,),
)
def test_the_stats_fields_are_written_untransformed(self):
source = (GLOB_DIR / "manager_core.py").read_text(encoding="utf-8")
body = source[source.index("def populate_github_stats("):]
body = body[:body.index("\ndef ", 1)]
self.assertIn("v['stars'] = json_obj_github[url]['stars']", body)
self.assertIn("v['last_update'] = json_obj_github[url]['last_update']", body)
self.assertIn(
"except:", body,
"the bare except is what leaves a channel-supplied stars/last_update "
"in place when reference lookup raises; if it is gone, re-derive the "
"provenance argument in this file's docstring",
)
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class PackOperationErrorTest(_InertAssertions):
def test_failed_operations_keep_server_escaped_titles_readable(self):
populate = load_markdown_functions(load_manager_util())["populate_markdown"]
methods = ",\n".join(slice_braced(JS.text(MANAGER), "\n\t" + marker) for marker in [
"async installNodes(list, btn, title, selected_version)",
"showError(err)", "showMessage(msg, color)", "showStatus(msg, color)",
])
for title in ("Pack <Flux> & friends", TEXT_PAYLOAD):
item = {"title": title, "originalData": {}, "hash": "pack"}
populate(item)
for mode in ("install", "uninstall"):
for status in (403, 500):
with self.subTest(title=title, mode=mode, status=status):
out = run_node("""
%s
const item = %s;
const elements = {};
const messages = {};
const api = {fetchApi: async () => ({status: %d,
json: async () => ({is_processing: false}), text: async () => %s})};
const show_message = message => messages.dialog = message;
const customConfirm = async () => true;
const ctx = {
%s,
element: {querySelector: selector => elements[selector] ||= {innerHTML: ''}},
grid: {getRowItemBy: () => item, scrollRowIntoView() {}, updateCell() {}},
focusInstall() {return true;}
};
await ctx.installNodes(['pack'], {target: {classList: {add() {}}},
label: 'Operation', mode: %s}, '', null);
messages.panel = elements['.cn-manager-message'].innerHTML;
console.log(JSON.stringify(messages));
""" % (_preamble(), json.dumps(item), status, json.dumps(TEXT_PAYLOAD), methods, json.dumps(mode)))
for message in out.values():
self.assert_inert(message, allowed_tags={"font"})
self.assertIn(f"'{title}': ", html.unescape(message))
if status == 500:
self.assertIn(": " + TEXT_PAYLOAD, html.unescape(message))
if __name__ == "__main__":
unittest.main()
+590
View File
@@ -0,0 +1,590 @@
"""Check remote notice HTML, sharing URLs, snapshot names and node selection UI.
Execute shipped Python and JavaScript with isolated external boundaries. Rendering
checks cover hostile inputs and readable controls; link checks preserve opener isolation.
"""
import ast
import os
import re
import unittest
from html import unescape
from html.parser import HTMLParser
from pathlib import Path
from js_lift import NODE, JsSource, run_node
from manager_test_utils import GLOB_DIR, load_manager_util, load_markdown_functions
REPO_ROOT = Path(__file__).resolve().parent.parent
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
COMMON = "common.js"
SHARE_COMMON = "comfyui-share-common.js"
SHARE_OPENART = "comfyui-share-openart.js"
SHARE_COPUS = "comfyui-share-copus.js"
SHARE_YOUML = "comfyui-share-youml.js"
SNAPSHOT = "snapshot.js"
MANAGER = "custom-nodes-manager.js"
# A scheme that executes on click, and a scheme that carries its own document.
# Both are strings a third party can put in a URL field; neither is a host.
SCHEME_PAYLOAD = "javascript:window.__probe=1"
DATA_PAYLOAD = "data:text/html,<b>x</b>"
BENIGN_URL = "https://example.com/w/123?a=1&b=2"
# An http URL that ALSO closes the attribute it lands in. A service-chosen URL
# is not "either a bad scheme or a quote" -- it is one string that can be both,
# so a scheme allow-list alone leaves the quote. One variant per quoting style,
# because the call sites differ.
BREAKOUT_SINGLE_QUOTED = "https://example.com/x' onmouseover='window.__probe=1"
BREAKOUT_DOUBLE_QUOTED = 'https://example.com/x" onmouseover="window.__probe=1'
# Element text, for the sinks that render a name rather than a link.
TEXT_PAYLOAD = '<img src=x onerror="window.__probe=1">'
def _function_source(name: str, filename: str = "manager_server.py") -> str:
"""The source text of one top-level function, WITHOUT importing its module.
Used for the wiring legs, where the question is "does this call site pass
its value through the normaliser" -- a question about the call site, which a
behavioural test on the normaliser alone cannot answer.
"""
source = (GLOB_DIR / filename).read_text(encoding="utf-8")
tree = ast.parse(source)
wanted = [
node for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name
]
assert len(wanted) == 1, "expected exactly one %s, got %d" % (name, len(wanted))
return ast.get_source_segment(source, wanted[0]) or ""
class _Collector(HTMLParser):
"""What a browser would actually build from a fragment."""
def __init__(self):
super().__init__()
self.elements = []
def handle_starttag(self, tag, attrs):
self.elements.append((tag, dict(attrs)))
handle_startendtag = handle_starttag
def parse(markup: str) -> _Collector:
collector = _Collector()
collector.feed(markup)
collector.close()
return collector
def tags(markup: str):
return [tag for tag, _ in parse(markup).elements]
def handler_attrs(markup: str):
return [
"%s@%s" % (tag, name)
for tag, attrs in parse(markup).elements
for name in attrs
if name.lower().startswith("on")
]
def hrefs(markup: str):
return [
attrs["href"] for tag, attrs in parse(markup).elements
if tag == "a" and "href" in attrs
]
# ---------------------------------------------------------------------------
# [D1] the relayed wiki fragment
# ---------------------------------------------------------------------------
class NoticeFragmentSanitizerTest(unittest.TestCase):
"""Notice sanitization must remove executable content while preserving formatting."""
@classmethod
def setUpClass(cls):
cls.manager_util = load_manager_util()
def _sanitize(self, fragment: str) -> str:
fn = getattr(self.manager_util, "sanitize_html_fragment", None)
self.assertIsNotNone(
fn,
"glob/manager_util.py exposes no sanitize_html_fragment(fragment). "
"The notice fragment is remote HTML the server relays verbatim and "
"the client assigns to innerHTML by design, so the server is the "
"only boundary that can constrain it.",
)
return fn(fragment)
def test_a_script_element_does_not_survive(self):
out = self._sanitize('<p>before</p><script>window.__probe=1</script><p>after</p>')
self.assertNotIn("script", tags(out), out)
self.assertIn("p", tags(out), "ordinary markup was destroyed along with it: %r" % (out,))
def test_a_handler_attribute_does_not_survive(self):
out = self._sanitize('<img src="https://example.com/i.png" onerror="window.__probe=1">')
self.assertEqual(handler_attrs(out), [], out)
def test_a_handler_attribute_does_not_survive_on_any_element(self):
# The allow-list is over ATTRIBUTES, not over one element that happened
# to be tested: a deny-list keyed to <img> leaves <div onmouseover=...>.
for markup in (
'<div onmouseover="window.__probe=1">hover</div>',
'<a href="https://example.com/" onclick="window.__probe=1">click</a>',
'<b ONERROR="window.__probe=1">bold</b>',
):
with self.subTest(markup=markup):
self.assertEqual(handler_attrs(self._sanitize(markup)), [], markup)
def test_an_executing_scheme_does_not_survive_in_an_href(self):
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD):
with self.subTest(payload=payload):
out = self._sanitize('<a href="%s">click</a>' % (payload,))
self.assertEqual(
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [],
"the scheme survived into an href: %r" % (out,),
)
def test_ordinary_formatting_still_displays(self):
"""The half a strip-everything sanitizer would fail.
The notice is formatted prose. If the fix returns text, the feature is
gone and no other test in this file would say so.
"""
fragment = (
'<p><b>bold</b> and <i>italic</i></p>'
'<ul><li>one</li><li>two</li></ul>'
'<a href="https://example.com/x">link</a>'
'<img src="https://example.com/i.png">'
)
out = self._sanitize(fragment)
for tag in ("p", "b", "i", "ul", "li", "a", "img"):
with self.subTest(tag=tag):
self.assertIn(tag, tags(out), "%r was dropped: %r" % (tag, out))
self.assertIn("https://example.com/x", hrefs(out), out)
self.assertIn("one", out)
self.assertIn("two", out)
def test_an_unclosed_element_does_not_blank_the_rest_of_the_notice(self):
"""Malformed elements must not discard unrelated notice content."""
for wrapper in ("div", "blockquote", "section", "article", "figure",
"main", "header", "nav", "aside"):
with self.subTest(wrapper=wrapper):
out = self._sanitize(
"<%s><svg></%s><p>after</p>" % (wrapper, wrapper))
self.assertIn(
"after", out,
"an unclosed element inside <%s> swallowed the rest of the "
"notice: %r" % (wrapper, out))
self.assertNotIn("svg", tags(out), out)
def test_an_unterminated_script_still_fails_closed(self):
"""HTML script/style contents must not be emitted as notice markup."""
for fragment in ("<section><script></section><p>after</p>",
"<div><style></div><p>after</p>"):
with self.subTest(fragment=fragment):
out = self._sanitize(fragment)
# NOT `out == ""`: a wrapper's OPEN tag is emitted before the
# skip window starts, so `<div><style>...` legitimately keeps
# its `<div>`. The property is that nothing AFTER the
# unterminated element comes back -- as markup or as text.
self.assertNotIn(
"after", out,
"text after an unterminated script/style came back: %r"
% (out,))
self.assertEqual(
[t for t in tags(out) if t != "div"], [],
"markup after an unterminated script/style came back: %r"
% (out,))
def test_foreign_content_does_not_hide_safe_following_prose(self):
# HTML5 parsing closes the SVG context before this paragraph. The old
# HTMLParser-based sanitizer treated the rest as script text and lost it.
out = self._sanitize('<svg><script></svg><p>after</p>')
self.assertEqual(tags(out), ['p'])
self.assertIn('after', out)
def test_notice_formatting_attributes_survive(self):
out = self._sanitize(
'<p class="notice" title="Release" align="center">'
'<font color="red">Update</font></p>'
'<table width="400"><tr><td colspan="2" rowspan="3">Data</td></tr></table>'
'<img src="https://example.com/i.png" alt="Preview" width="100" height="80">'
)
elements = dict(parse(out).elements)
self.assertEqual(elements['p'], {'class': 'notice', 'title': 'Release', 'align': 'center'})
self.assertEqual(elements['font'], {'color': 'red'})
self.assertEqual(elements['table']['width'], '400')
self.assertEqual(elements['td'], {'colspan': '2', 'rowspan': '3'})
self.assertEqual(elements['img'], {
'src': 'https://example.com/i.png', 'alt': 'Preview', 'width': '100', 'height': '80',
})
def test_notice_links_open_safely_regardless_of_input_attribute_order(self):
for attrs in (
'href="https://example.com/?a=1&amp;b=2" target="named" rel="opener"',
'title="Docs" rel="opener" target="named" href="https://example.com/?a=1&amp;b=2"',
):
with self.subTest(attrs=attrs):
out = self._sanitize(f'<a {attrs}>Docs</a>')
anchor = dict(parse(out).elements)['a']
self.assertEqual(anchor['href'], 'https://example.com/?a=1&b=2')
self.assertEqual(anchor['target'], '_blank')
self.assertEqual(set(anchor['rel'].split()), {'noopener', 'noreferrer'})
def test_notice_url_schemes_remain_limited_to_http_https_and_relative(self):
for url in ('ftp://example.com', 'mailto:a@example.com', 'java&#9;script:alert`1`'):
with self.subTest(url=url):
out = self._sanitize(f'<a href="{url}">Link</a><img src="{url}">')
for _, attrs in parse(out).elements:
self.assertNotIn('href', attrs)
self.assertNotIn('src', attrs)
for url in ('http://example.com', 'https://example.com', '/relative', '#section'):
with self.subTest(url=url):
self.assertEqual(hrefs(self._sanitize(f'<a href="{url}">Link</a>')), [url])
def test_hidden_element_contents_are_not_exposed_as_notice_text(self):
for tag in ('script', 'style', 'iframe', 'object', 'svg', 'math',
'template', 'noscript', 'textarea', 'title'):
with self.subTest(tag=tag):
out = self._sanitize(f'<{tag}>hidden</{tag}><p>after</p>')
self.assertNotIn('hidden', out)
self.assertIn('after', out)
def test_a_mixed_fragment_keeps_the_good_and_drops_the_bad(self):
"""One fragment carrying both, since a real wiki page would."""
out = self._sanitize(
'<p>Release <b>1.2</b></p>'
'<script>window.__probe=1</script>'
'<a href="https://example.com/notes">notes</a>'
'<a href="%s">click</a>'
'<img src="https://example.com/i.png" onerror="window.__probe=1">'
% (SCHEME_PAYLOAD,)
)
self.assertNotIn("script", tags(out), out)
self.assertEqual(handler_attrs(out), [], out)
self.assertEqual(
[h for h in hrefs(out) if h.startswith(("javascript:", "data:"))], [], out)
self.assertIn("https://example.com/notes", hrefs(out), out)
self.assertIn("b", tags(out), out)
self.assertIn("img", tags(out), "the benign image was dropped: %r" % (out,))
class NoticeWiringTest(unittest.TestCase):
"""Check the notice route applies the sanitizer to its captured remote HTML."""
def test_get_notice_passes_the_captured_fragment_through_the_sanitizer(self):
"""Verify the captured HTML is sanitized, not merely that the function is mentioned."""
body = _function_source("get_notice")
self.assertIn(
"match.group(1)", body,
"get_notice no longer captures the fragment the way this guard "
"expects; re-derive it against the new shape rather than deleting "
"the assertion.",
)
self.assertRegex(
body, r"sanitize_html_fragment\s*\(\s*match\.group\(1\)\s*\)",
"get_notice relays the captured wiki fragment without passing it "
"through an allow-list normaliser. The client assigns the response "
"body to innerHTML, so whatever the wiki page contains is what the "
"browser builds. The call has to take the capture as its argument; "
"naming the function elsewhere in the body is not wiring.",
)
self.assertNotRegex(
body, re.compile(r"^\s*markdown_content\s*=\s*match\.group\(1\)\s*$",
re.MULTILINE),
"the raw capture is still assigned directly; the sanitizer must sit "
"on this assignment, not beside it.",
)
# ---------------------------------------------------------------------------
# [D2] share-service URLs
# ---------------------------------------------------------------------------
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class ShareDialogUrlTest(unittest.TestCase):
"""Share-service URLs must use safe schemes and quoted attributes."""
def _preamble(self) -> str:
span = JS.lift_span(
COMMON, "export const SAFE_URL_SCHEMES",
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
return "\n".join([
JS.lift_declaration(COMMON, "export function sanitizeHTML("),
span.replace("export ", ""),
])
def _render(self, expression: str, bindings: str) -> str:
return run_node(
"%s\n%s\nconsole.log(JSON.stringify({markup: %s}));\n"
% (self._preamble(), bindings, expression)
)["markup"]
def _assert_scheme_rejected(self, markup: str, label: str):
bad = [h for h in hrefs(markup) if h.startswith(("javascript:", "data:"))]
self.assertEqual(
bad, [],
"%s emits a service-chosen scheme as a clickable href: %r" % (label, markup),
)
self.assertEqual(handler_attrs(markup), [], markup)
# --- comfyui-share-common.js, the fully service-controlled URL ----------
def _common_expression(self) -> str:
from js_lift import line_containing
line = line_containing(JS.text(SHARE_COMMON),
'this.final_message.innerHTML = "Your art has been shared:')
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
def test_share_common_rejects_an_executing_scheme(self):
# The href here is SINGLE-quoted, so the breakout variant uses `'`.
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_SINGLE_QUOTED):
with self.subTest(payload=payload):
markup = self._render(
self._common_expression(),
"const response_json = {comfyworkflows: {url: %s}};" % (_json(payload),))
self._assert_scheme_rejected(markup, "comfyui-share-common.js")
def test_share_common_still_links_an_ordinary_url(self):
"""The leg a refuse-everything fix would fail."""
markup = self._render(
self._common_expression(),
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),))
self.assertIn(
BENIGN_URL, hrefs(markup),
"an ordinary https URL must still become a working link: %r" % (markup,))
def test_every_share_anchor_severs_the_opener(self):
"""Check opener isolation without imposing an order on rel tokens."""
cases = [
("comfyui-share-common.js", self._common_expression(),
"const response_json = {comfyworkflows: {url: %s}};" % (_json(BENIGN_URL),)),
("comfyui-share-youml.js", self._youml_expression(),
"const messagePrefix = 'shared.';\nconst recipePageUrl = %s;" % (_json(BENIGN_URL),)),
("comfyui-share-openart.js", self._openart_expression(),
"const url = %s;" % (_json(BENIGN_URL),)),
("comfyui-share-copus.js", self._copus_expression(),
"const url = %s;" % (_json(BENIGN_URL),)),
]
for name, expression, bindings in cases:
with self.subTest(file=name):
markup = self._render(expression, bindings)
anchors = [attrs for tag, attrs in parse(markup).elements if tag == "a"]
self.assertTrue(anchors, "no anchor produced by %s: %r" % (name, markup))
for attrs in anchors:
self.assertEqual(
attrs.get("target"), "_blank",
"%s: this guard is about _blank anchors; the target "
"changed, so re-derive it: %r" % (name, markup))
self.assertIn(
"rel", attrs,
"%s opens a service-chosen page in a new tab without "
"rel, so that page keeps a reference to this window: %r"
% (name, markup))
rel = attrs["rel"].lower().split()
self.assertIn("noopener", rel, markup)
self.assertIn("noreferrer", rel, markup)
# --- comfyui-share-youml.js, also fully service-controlled --------------
def _youml_expression(self) -> str:
span = JS.lift_span(SHARE_YOUML, "this.message.innerHTML = `${messagePrefix}",
"visit it on YouML</a>`;")
return span.split("innerHTML =", 1)[1].strip().rstrip(";")
def test_share_youml_rejects_an_executing_scheme(self):
# The href here is DOUBLE-quoted, so the breakout variant uses `"`.
for payload in (SCHEME_PAYLOAD, DATA_PAYLOAD, BREAKOUT_DOUBLE_QUOTED):
with self.subTest(payload=payload):
markup = self._render(
self._youml_expression(),
"const messagePrefix = 'Workflow has been shared.';\n"
"const recipePageUrl = %s;" % (_json(payload),))
self._assert_scheme_rejected(markup, "comfyui-share-youml.js")
# --- openart / copus: an id is interpolated into a URL PATH -------------
# The scheme is hardcoded, so these cannot carry javascript:. What they CAN
# carry is a quote: the id is a service-returned string dropped inside a
# double-quoted attribute, so `"` closes it and the rest is markup. That is a
# different failure from the two above and is asserted as such.
def _openart_expression(self) -> str:
from js_lift import line_containing
line = line_containing(JS.text(SHARE_OPENART),
"this.message.innerHTML = `Workflow has been shared successfully.")
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
def _copus_expression(self) -> str:
from js_lift import line_containing
line = line_containing(JS.text(SHARE_COPUS),
"this.message.innerHTML = `Workflow has been shared successfully.")
return line.split("innerHTML =", 1)[1].strip().rstrip(";")
def test_openart_id_cannot_break_out_of_the_href_attribute(self):
markup = self._render(
self._openart_expression(),
'const url = "https://openart.ai/workflows/-/-/" + '
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
self.assertEqual(handler_attrs(markup), [], markup)
def test_copus_id_cannot_break_out_of_the_href_attribute(self):
markup = self._render(
self._copus_expression(),
'const url = "https://example.invalid/work/" + '
'%s;' % (_json('x" onmouseover="window.__probe=1'),))
self.assertEqual(handler_attrs(markup), [], markup)
# ---------------------------------------------------------------------------
# [D3] the snapshot name
# ---------------------------------------------------------------------------
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class SnapshotNameTest(unittest.TestCase):
"""Snapshot filenames must remain inert text in status messages."""
def _render(self, name: str) -> str:
line = _unique_line(JS.text(SNAPSHOT), "self.updateMessage(`<BR><font color=")
expression = _call_argument(line, "self.updateMessage(")
return run_node(
"%s\nconst target = {name: %s};\n"
"console.log(JSON.stringify({markup: %s}));\n"
% (JS.lift_declaration(COMMON, "export function sanitizeHTML("),
_json(name), expression)
)["markup"]
def test_a_crafted_snapshot_name_renders_as_text(self):
markup = self._render(TEXT_PAYLOAD)
self.assertEqual(handler_attrs(markup), [], markup)
self.assertNotIn("img", tags(markup), markup)
def test_an_ordinary_snapshot_name_still_reads_normally(self):
markup = self._render("2026-09-07_backup")
self.assertIn("2026-09-07_backup", markup)
# ---------------------------------------------------------------------------
# [D4] rel=noopener, and the defensive escapes
# ---------------------------------------------------------------------------
class ServerAnchorRelTest(unittest.TestCase):
"""Converted [a/text](url) links must isolate the opener and remain usable."""
@classmethod
def setUpClass(cls):
cls.manager_util = load_manager_util()
# staticmethod, or attribute access binds it and passes `self`
# as the markdown argument.
cls.convert = staticmethod(load_markdown_functions(cls.manager_util)["convert_markdown_to_html"])
def test_a_markdown_link_carries_rel_noopener_noreferrer(self):
out = self.convert("see [a/the docs](https://example.com/docs)")
anchors = [attrs for tag, attrs in parse(out).elements if tag == "a"]
self.assertTrue(anchors, "no anchor was produced at all: %r" % (out,))
for attrs in anchors:
with self.subTest(attrs=attrs):
self.assertIn(
"rel", attrs,
"the anchor opens a new tab without rel: %r. The opened page "
"keeps a reference to this window." % (out,),
)
rel = attrs["rel"].lower().split()
self.assertIn("noopener", rel, out)
self.assertIn("noreferrer", rel, out)
def test_the_link_still_works(self):
out = self.convert("see [a/the docs](https://example.com/docs)")
self.assertIn("https://example.com/docs", hrefs(out), out)
class ClientAnchorAndStateTest(unittest.TestCase):
"""External-link isolation and rendering of unrecognised pack states."""
@classmethod
def setUpClass(cls):
cls.source = JS.text(MANAGER)
def test_the_title_link_severs_the_opener(self):
marker = "link.target = '_blank';"
index = _unique_index(self.source, marker)
# max(0, ...) because a negative start would wrap to the END of the
# string and silently hand back an unrelated window.
window = self.source[max(0, index - 400):index + 400]
self.assertRegex(
window, r"link\.rel\s*=",
"the title link opens a new tab without setting rel, so the pack's "
"own repository page keeps a reference to this window.",
)
rel = re.search(r"link\.rel\s*=\s*['\"]([^'\"]*)['\"]", window)
self.assertIsNotNone(rel, "link.rel is set from a non-literal: %r" % (window,))
tokens = rel.group(1).lower().split()
# Membership, not token order: 'noreferrer noopener' is the same thing,
# and demanding one spelling would false-RED a correct fix. Matches how
# ServerAnchorRelTest checks the server-side anchor.
self.assertIn("noopener", tokens, window)
self.assertIn("noreferrer", tokens, window)
@unittest.skipIf(NODE is None, "node is required")
def test_selection_survives_unrecognised_and_prototype_names(self):
sanitize = JS.lift_declaration(COMMON, "export function sanitizeHTML(")
buttons = JS.lift_declaration(MANAGER, "\n\tgetActionButtons(")
render = JS.lift_declaration(MANAGER, "\n\trenderSelected()")
for state in ['not-a-known-install-group', 'constructor', '__proto__', TEXT_PAYLOAD, 'enabled']:
with self.subTest(state=state):
result = run_node(sanitize + "\nconst manager = {" + buttons + "," + render + """,
grid: {hasMask: false, getSelectedRows: () => [{action: STATE, hash: 'fixture'}]},
getFilterItem: () => null,
showSelection(html) { this.html = html; }
};
manager.renderSelected();
console.log(JSON.stringify({html: manager.html,
buttons: manager.getActionButtons(STATE, {version: 'unknown'}, true)}));
""".replace('STATE', _json(state)))
self.assertIn('Selected <b>1</b>', result['html'])
self.assertIn(state, unescape(result['html']))
self.assertFalse(handler_attrs(result['html']))
self.assertNotIn('img', tags(result['html']))
if state == 'enabled':
self.assertIn('<button', result['buttons'])
else:
self.assertEqual(result['buttons'], '')
def _json(value) -> str:
import json
return json.dumps(value)
def _unique_index(source: str, marker: str) -> int:
"""Index of `marker`, refusing to guess when it is not unique.
js_lift.line_containing takes the FIRST occurrence silently. Every marker
here is unique today; this makes a future second occurrence fail loudly
instead of pinning whichever copy happens to come first.
"""
count = source.count(marker)
assert count == 1, "marker %r occurs %d times; disambiguate it" % (marker, count)
return source.index(marker)
def _unique_line(source: str, marker: str) -> str:
"""The single source line holding `marker`, with the same uniqueness rule."""
index = _unique_index(source, marker)
start = source.rfind("\n", 0, index) + 1
return source[start:source.index("\n", index)]
def _call_argument(line: str, call_marker: str) -> str:
"""The argument text of `call_marker(...)`, dropping exactly one `)` and `;`.
NOT `.rstrip(");")`: rstrip takes a CHARACTER SET, so it eats every trailing
`)`, `;` and `"`. A fix spelled `updateMessage(escape(`...`));` would lose a
closing paren it needs and become a node syntax error -- which pytest would
show as this guard failing, i.e. the fix would look like the defect.
"""
text = line.split(call_marker, 1)[1].rstrip()
if text.endswith(";"):
text = text[:-1].rstrip()
assert text.endswith(")"), "unexpected call shape: %r" % (line.strip(),)
return text[:-1]
if __name__ == "__main__":
unittest.main()
+58
View File
@@ -0,0 +1,58 @@
"""Render actual update-completion handlers in an isolated browser."""
from pathlib import Path
import pytest
from js_lift import JsSource
JS = JsSource(Path(__file__).resolve().parent.parent / "js")
@pytest.mark.parametrize("status", ["success", "failed"])
def test_update_results_preserve_links_titles_and_missing_url_labels(status):
playwright = pytest.importorskip("playwright.sync_api")
helpers = JS.lift_declaration("common.js", "export function sanitizeHTML(")
helpers += JS.lift_span(
"common.js", "export const SAFE_URL_SCHEMES",
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));",
).replace("export ", "")
helpers += JS.lift_declaration("common.js", "export function show_message(")
handler = JS.lift_declaration("comfyui-manager.js", "async function onQueueStatus(")
url = "https://example.test/guide?q=O'Reilly&lang=en"
title = "Guide <draft> & notes"
fallback_id = "Pack <draft>"
with playwright.sync_playwright() as driver:
if not Path(driver.chromium.executable_path).exists():
pytest.skip("install Chromium with: playwright install chromium")
browser = driver.chromium.launch()
page = browser.new_page()
page.route("**/*", lambda route: route.abort())
page.set_content('<div id="dialog"></div>')
page.add_script_tag(content="""
let is_updating = true;
function reset_action_buttons() {}
function setNeedRestart() {}
function infoToast() {}
const app = {ui: {dialog: {
element: document.getElementById('dialog'),
show(message) { this.element.innerHTML = message; }
}}};
""" + helpers + handler)
page.evaluate("event => onQueueStatus(event)", {"detail": {
"status": "done", "nodepack_result": {
"comfyui": "skip",
"pack-link": {"url": url, "title": title, "msg": status},
fallback_id: {"msg": status},
},
}})
items = page.locator("#dialog li")
assert items.all_text_contents() == [title, fallback_id]
assert items.nth(0).locator("a").get_attribute("href") == url
assert set(items.nth(0).locator("a").evaluate("node => node.getAttributeNames()")) == {
"href", "target", "rel",
}
assert items.nth(0).locator("a").get_attribute("rel") == "noopener noreferrer"
assert items.nth(1).locator("a").count() == 0
assert page.locator("#dialog draft").count() == 0
browser.close()
+410
View File
@@ -0,0 +1,410 @@
"""Drift guard: the JS sanitizeUrl must agree with glob/manager_util.py::sanitize_url.
There are two implementations of one URL allow-list — ``sanitizeUrl`` in
js/common.js and ``sanitize_url`` in glob/manager_util.py. They agree today.
Nothing structural keeps them agreeing: a change to either side has no failing
test on the other, and the JS side is what stands between channel data and an
href. This module is that missing coupling. It runs BOTH real implementations
over a shared case list and fails on any divergence, naming the input and both
answers.
WHAT IT COMPARES: the real shipped code on both sides. The Python function is
loaded by file location (``glob/`` has no ``__init__.py``, and putting it on
sys.path would shadow the stdlib ``glob``); the JS function is lifted out of
js/common.js by tests/js_lift and executed in node. Neither is a copy, so a
perturbation of either implementation turns this guard RED.
CASES: the shared matrix at ``cases/url_sanitize_cases.json`` (57 inputs handed
over by gm3-review from the WI-112 and WI-122 reviews, since extended) PLUS a
generated grid that composes scheme x in-scheme-noise x delimiter. The generated
half matters because a fixed list can only catch divergence it happens to
contain, and the implementations are what is under test, not the list.
TWO AXES ARE COMPARED, not one. Behaviour over sampled inputs cannot see a
difference no sample happens to hold, so the two pieces of CONFIGURATION the
implementations branch on are compared directly as well: the scheme allow-list
(``test_the_scheme_allow_lists_are_identical``) and the whitespace set each side
trims off an accepted URL (``test_the_trim_sets_are_identical``). The trim axis
was added after the matrix was found to contain no BOM or unicode-whitespace
input at all — ``String.prototype.trim()`` and ``str.strip()`` disagree on six
codepoints (U+FEFF one way; U+001C-001F and U+0085 the other), and every case in
the matrix had ASCII edges, so the divergence sat unguarded. js/common.js now
spells Python's set out rather than delegating to ``trim()``; see the
``unicode_whitespace_divergence`` block in the case file.
⛔ STRING INPUTS ONLY, deliberately. Python ``str()`` and JS ``String()`` are
not equivalent for non-strings — 1.0 -> '1.0' vs '1', True -> 'True' vs 'true',
[1,2] -> '[1, 2]' vs '1,2', {} -> '{}' vs '[object Object]' — so feeding
non-strings would report divergence for something that is not a defect. In
practice ``url`` arrives as a string off the JSON payload. The two aligned
non-string cases that DO matter are pinned separately in
``test_null_and_undefined_agree`` rather than left as an undocumented gap; the
right response to a coercion mismatch is to narrow the domain, never to loosen
the comparison, because a loosened comparison stops catching real drift.
"""
import inspect
import json
import os
import re
import unittest
from pathlib import Path
from js_lift import NODE, JsSource, run_node
from manager_test_utils import load_manager_util
REPO_ROOT = Path(__file__).resolve().parent.parent
JS = JsSource(os.environ.get("MANAGER_JS_DIR") or (REPO_ROOT / "js"))
CASES_PATH = Path(__file__).resolve().parent / "cases" / "url_sanitize_cases.json"
# Every codepoint ``str.strip()`` removes, measured by RUNNING it rather than by
# restating a table. sanitize_url returns ``raw.strip()``, so this is the set the
# JS side has to match; test_the_trim_sets_are_identical asserts that linkage
# still holds before comparing.
PYTHON_TRIM_SET = frozenset(c for c in range(0x110000) if chr(c).strip() == "")
def _generated_cases(allow_listed):
"""Compose scheme x in-scheme noise x delimiter into extra inputs.
A fixed list only catches the divergence it already contains. This grid is
derived from the axes the two implementations actually branch on — the
scheme allow-list, the control-char strip, and the head-delimiter cut — so
it explores combinations nobody wrote down.
``allow_listed`` is the UNION of the two live allow-lists, not a literal, so
a scheme added to either side is exercised behaviourally without anyone
remembering to add a case for it. That is a second layer under
test_the_scheme_allow_lists_are_identical, which is what catches an
allow-list divergence definitively; this layer only makes the behavioural
sampling track the configuration instead of drifting away from it.
"""
schemes = sorted(set(allow_listed) | {
"HTTP", "javascript", "data", "vbscript", "file", "a-b", "a.b",
})
noises = ["", "\t", "\n", "\x00", "\x01", " ", "\x7f"]
delimiters = ["", "/", "?", "#", "//host/p", "?q=1", "#frag"]
for scheme in schemes:
for noise in noises:
head = scheme[:2] + noise + scheme[2:]
for delimiter in delimiters:
yield "%s:%s" % (head, delimiter)
yield " %s:%s" % (head, delimiter)
def _all_cases(allow_listed):
matrix = json.loads(CASES_PATH.read_text(encoding="utf-8"))
cases = list(matrix["shared_matrix"])
cases.extend(_generated_cases(allow_listed))
# De-duplicate while preserving order so a failure names a stable index.
seen = set()
ordered = []
for case in cases:
if case not in seen:
seen.add(case)
ordered.append(case)
return ordered
def _lift_sanitize_url() -> str:
"""The REAL sanitizeUrl and the two constants it closes over, from common.js.
Lifted as one contiguous span rather than three declarations: SAFE_URL_SCHEMES
is an array literal, so a brace-matching lift would run past it into the
function body and declare the constants twice.
"""
span = JS.lift_span("common.js", "export const SAFE_URL_SCHEMES",
"export const safeHref = (url) => sanitizeHTML(sanitizeUrl(url));")
# safeHref needs sanitizeHTML, which this guard does not exercise; drop the
# trailing line rather than pulling an unrelated dependency into the script.
span = span[:span.index("export const safeHref")]
return span.replace("export ", "")
_JS_ALLOW_LIST_LITERAL = re.compile(r"(SAFE_URL_SCHEMES\s*=\s*)\[[^\]]*\]")
def _js_allow_list(lifted_js=None):
"""The JS SAFE_URL_SCHEMES as the running code actually sees it.
Evaluated in node rather than parsed out of the source, so a change to how
the list is spelled cannot make this silently read the old value.
"""
return run_node(
"%s\nconsole.log(JSON.stringify({v: SAFE_URL_SCHEMES}));\n"
% (lifted_js if lifted_js is not None else _lift_sanitize_url())
)["v"]
def _js_trim_set(lifted_js=None):
"""Every codepoint the JS side strips off the edges of an accepted URL.
Evaluated in node, for the same reason ``_js_allow_list`` is: a set parsed
out of the source text stops describing the running code the moment the
spelling changes.
Shimmed on a PRE-FIX tree, where sanitizeUrl called ``raw.trim()`` inline and
no ``trimUrl`` exists. Without the shim this guard would raise on the old
revision instead of reporting the divergence, and the RED half of the fix
could not be demonstrated with the guard that is supposed to catch it.
"""
lifted = _lift_sanitize_url() if lifted_js is None else lifted_js
shim = "" if "trimUrl" in lifted else "const trimUrl = (value) => value.trim();\n"
return set(run_node(
"%s\n%sconst out = [];\n"
"for (let c = 0; c < 0x110000; c += 1) {\n"
" if (trimUrl(String.fromCodePoint(c)) === '') out.push(c);\n"
"}\n"
"console.log(JSON.stringify({v: out}));\n" % (lifted, shim)
)["v"])
def _perturb_js_allow_list(lifted_js, extra_scheme):
"""Add a scheme to the lifted JS allow-list, whatever it currently holds.
Rewriting the array by PATTERN rather than by a hard-coded literal matters:
a literal-match perturbation stops applying the moment someone edits the
allow-list, and then the harness's own did-it-apply self-test fires instead
of the parity comparison — a red that looks like coverage and is not.
"""
perturbed, count = _JS_ALLOW_LIST_LITERAL.subn(
lambda m: "%s%s" % (m.group(1), json.dumps(sorted(
set(_js_allow_list(lifted_js)) | {extra_scheme}))),
lifted_js, count=1,
)
assert count == 1, "could not locate the JS SAFE_URL_SCHEMES array to perturb"
return perturbed
def _js_results(cases):
"""Run the REAL js/common.js sanitizeUrl over every case, in node."""
lifted = _lift_sanitize_url()
return run_node(
"%s\nconst cases = %s;\n"
"console.log(JSON.stringify({results: cases.map((c) => sanitizeUrl(c))}));\n"
% (lifted, json.dumps(cases))
)["results"]
@unittest.skipIf(NODE is None, "node is required to execute the lifted production JS")
class UrlSanitizeParityTest(unittest.TestCase):
"""Both real implementations, one case list, zero tolerated divergence."""
@classmethod
def setUpClass(cls):
cls.manager_util = load_manager_util()
cls.allow_lists = {
"python": set(cls.manager_util.SAFE_URL_SCHEMES),
"js": set(_js_allow_list()),
}
cls.cases = _all_cases(cls.allow_lists["python"] | cls.allow_lists["js"])
cls.js = _js_results(cls.cases)
def test_case_matrix_is_actually_loaded(self):
# A guard that silently ran zero cases would pass forever.
matrix = json.loads(CASES_PATH.read_text(encoding="utf-8"))
self.assertGreaterEqual(len(matrix["shared_matrix"]), 57)
self.assertGreater(len(self.cases), 400)
self.assertEqual(len(self.js), len(self.cases))
def test_the_scheme_allow_lists_are_identical(self):
"""Compare the CONFIGURATION, not only the behaviour it drives.
Comparing behaviour over sampled inputs cannot see a scheme added to one
allow-list unless that scheme happens to be in the sample — adding 'ftp'
or 'mailto' or 'ws' to either side changes what is accepted and produces
no divergence at all. That is the most plausible future drift for this
pair (someone adds a scheme for a real reason and forgets the other
side), so it is closed definitively here rather than by sampling.
"""
self.assertEqual(
self.allow_lists["js"], self.allow_lists["python"],
"the URL scheme allow-lists have diverged — js/common.js accepts %r "
"and glob/manager_util.py accepts %r. A scheme added to one side and "
"not the other silently changes what reaches an href. Fix the lists, "
"do NOT relax this comparison."
% (sorted(self.allow_lists["js"]), sorted(self.allow_lists["python"])),
)
def test_the_trim_sets_are_identical(self):
"""Compare the trim CONFIGURATION, the way the allow-lists are compared.
Both implementations end an accepted URL with a trim, and the two trims
are NOT the same function: ``String.prototype.trim()`` strips U+FEFF,
which ``str.strip()`` keeps, and leaves U+001C-001F and U+0085, which
``str.strip()`` strips. Sampling cannot be relied on to find that — the
handed-over matrix had ASCII edges on all 62 inputs and reported no
divergence for years of cases. The cases added alongside this test do
catch it, but the set comparison is what closes the axis DEFINITIVELY,
exactly as test_the_scheme_allow_lists_are_identical does for schemes.
The fix direction is fixed, not free: glob/manager_util.py is the
declared source of truth for this pair, so the JS side matches Python.
"""
source = inspect.getsource(self.manager_util.sanitize_url)
self.assertIn(
"raw.strip()", source,
"sanitize_url no longer returns raw.strip(), so PYTHON_TRIM_SET is "
"measuring the wrong function — re-derive it from whatever the "
"Python side trims with now.",
)
js = _js_trim_set()
self.assertEqual(
js, set(PYTHON_TRIM_SET),
"the URL trim sets have diverged. js/common.js strips %s that "
"glob/manager_util.py keeps, and keeps %s that it strips. Both sides "
"must trim the SAME set or the two implementations answer differently "
"for the same accepted URL; fix js/common.js's URL_TRIM, do NOT relax "
"this comparison."
% (sorted("U+%04X" % c for c in js - PYTHON_TRIM_SET) or "nothing",
sorted("U+%04X" % c for c in PYTHON_TRIM_SET - js) or "nothing"),
)
def test_js_and_python_agree_on_every_case(self):
divergences = []
for case, js_answer in zip(self.cases, self.js):
py_answer = self.manager_util.sanitize_url(case)
if py_answer != js_answer:
divergences.append(
" input=%r\n python=%r\n js =%r" % (case, py_answer, js_answer)
)
self.assertEqual(
divergences, [],
"js/common.js sanitizeUrl has drifted from "
"glob/manager_util.py::sanitize_url over %d case(s):\n%s\n"
"Fix the implementations so they agree again — do NOT loosen this "
"comparison, which is the only thing coupling them."
% (len(divergences), "\n".join(divergences)),
)
def test_null_and_undefined_agree(self):
"""The two non-string inputs both sides handle explicitly.
Everything else non-string is out of domain — see the module docstring.
"""
out = run_node(
"%s\nconsole.log(JSON.stringify({"
"nul: sanitizeUrl(null), undef: sanitizeUrl(undefined)}));\n"
% _lift_sanitize_url()
)
self.assertEqual(out["nul"], self.manager_util.sanitize_url(None))
self.assertEqual(out["undef"], "#")
def _divergences_against(self, lifted_js):
"""Run the SAME comparison test_js_and_python_agree_on_every_case runs."""
js = run_node(
"%s\nconst cases = %s;\n"
"console.log(JSON.stringify({results: cases.map((c) => sanitizeUrl(c))}));\n"
% (lifted_js, json.dumps(self.cases))
)["results"]
return [
case for case, answer in zip(self.cases, js)
if self.manager_util.sanitize_url(case) != answer
]
def test_the_guard_goes_red_on_each_perturbation_of_the_js_side(self):
"""Prove the comparison has teeth rather than asserting that it does.
Each perturbation is a shape a real regression takes — a scheme added to
the allow-list, the control-char strip losing its /g flag so only the
FIRST noise byte is removed, the scheme-less branch changing its answer,
and the empty-input fallback changing. Every one must be caught by the
case list as it stands, so a guard whose lift silently returned a stub,
or whose matrix had rotted into irrelevance, cannot look healthy.
"""
base = _lift_sanitize_url()
perturbations = {
# By PATTERN, not by literal: a literal-match perturbation stops
# applying the moment someone edits the allow-list, and the
# did-it-apply assertion below then fires INSTEAD of the parity
# comparison — a red that reads like coverage and is not.
"allow-list gains a scheme": _perturb_js_allow_list(base, "javascript"),
"noise strip loses its /g flag":
base.replace("/[\\x00-\\x20\\x7f]/g", "/[\\x00-\\x20\\x7f]/", 1),
"scheme-less branch stops trimming":
base.replace("return trimUrl(raw);\n}", "return raw;\n}", 1),
# The regression this fix closed: delegating back to String.trim(),
# which strips U+FEFF that str.strip() keeps and keeps U+001C-001F
# and U+0085 that str.strip() strips. Caught only by the unicode
# cases added with the fix — before them the matrix had ASCII edges
# throughout and this perturbation produced zero divergence.
"trim delegates back to String.trim()":
base.replace("trimUrl(raw)", "raw.trim()"),
"empty-input fallback changes": base.replace("return '#';", "return '';", 1),
}
for label, lifted in perturbations.items():
with self.subTest(perturbation=label):
self.assertNotEqual(
lifted, base,
"perturbation %r did not apply — this is the HARNESS "
"self-test, NOT the parity comparison. Do not read it as "
"coverage: fix the perturbation so the comparison is "
"actually exercised." % (label,),
)
caught = self._divergences_against(lifted)
self.assertNotEqual(
caught, [],
"perturbing the JS side (%s) produced NO divergence — the case "
"list cannot detect this drift, so the guard is weaker than it "
"looks. Add a case that distinguishes it." % (label,),
)
def test_the_guard_goes_red_when_either_allow_list_gains_a_scheme(self):
"""The perturbation that was NOT caught before this test existed.
Both directions are exercised, because the drift can start on either
side and the guard is only worth having if it is symmetric. 'ftp' is used
deliberately: it is absent from the shared matrix, so nothing but the
allow-list comparison itself can catch it.
"""
with self.subTest(side="python (the declared SoT)"):
real = self.manager_util.SAFE_URL_SCHEMES
try:
setattr(self.manager_util, "SAFE_URL_SCHEMES", frozenset(set(real) | {"ftp"}))
self.assertNotEqual(
set(self.manager_util.SAFE_URL_SCHEMES), self.allow_lists["js"],
"adding a scheme to the PYTHON allow-list produced no "
"divergence — the configuration axis is unguarded again",
)
finally:
setattr(self.manager_util, "SAFE_URL_SCHEMES", real)
self.assertEqual(set(self.manager_util.SAFE_URL_SCHEMES), self.allow_lists["python"])
with self.subTest(side="js"):
perturbed = _js_allow_list(_perturb_js_allow_list(_lift_sanitize_url(), "ftp"))
self.assertIn("ftp", perturbed, "the JS perturbation did not apply")
self.assertNotEqual(
set(perturbed), self.allow_lists["python"],
"adding a scheme to the JS allow-list produced no divergence",
)
def test_the_guard_goes_red_on_a_perturbed_python_side(self):
"""The coupling has to run both ways, not just JS-drifts-from-Python."""
real = self.manager_util.sanitize_url
try:
setattr(self.manager_util, "sanitize_url", lambda url: "#")
caught = self._divergences_against(_lift_sanitize_url())
finally:
setattr(self.manager_util, "sanitize_url", real)
self.assertNotEqual(
caught, [],
"perturbing the PYTHON side produced no divergence — the comparison "
"is not reading the Python implementation it claims to",
)
def test_sanitize_url_has_exactly_one_client_side_home(self):
"""The consolidation this guard exists to protect."""
js_dir = JS.js_dir
homes = sorted(
path.name for path in js_dir.glob("*.js")
if "function sanitizeUrl" in path.read_text(encoding="utf-8")
)
self.assertEqual(
homes, ["common.js"],
"sanitizeUrl must live only in common.js; every other client file "
"imports it. A second copy is the drift this guard cannot see, "
"because it only compares the copy in common.js.",
)
if __name__ == "__main__":
unittest.main()
+4 -5
View File
@@ -299,7 +299,8 @@ class WriteConfigPersistenceTest(unittest.TestCase):
"security_level": cp["default"].get("security_level", "<ABSENT>"),
"allow_git_url_install": cp["default"].get(
"allow_git_url_install", "<ABSENT>"),
"key_count": len(cp["default"]),
"allow_flagged_nodepack_install": cp["default"].get(
"allow_flagged_nodepack_install", "<ABSENT>"),
}))
"""
)
@@ -310,10 +311,8 @@ class WriteConfigPersistenceTest(unittest.TestCase):
"T5: the install flag must bootstrap secure-by-default",
)
self.assertEqual(
18, payload["key_count"],
"T5: bootstrap wrote %d keys; write_config persists 18 "
"(manager_core.py:1685-1704). A change to that count is a scope "
"signal, not a nit." % payload["key_count"],
"False", payload["allow_flagged_nodepack_install"],
"T5: flagged installs must bootstrap with the override disabled",
)
def test_t6_persisted_key_is_not_reclobbered(self):