mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-06-23 00:09:25 +08:00
Gate 'install via git URL' and 'install via pip' with dedicated opt-in boolean flags (allow_git_url_install / allow_pip_install) in config.ini [default], fully replacing the security_level term on those surfaces (REPLACE, not AND — a strict level no longer denies when the flag is on; a weak level no longer allows when the flag is off). - glob/manager_server.py: pure predicate is_dedicated_install_allowed (flag AND loopback, request-time args.listen); REPLACE gates at /customnode/install/git_url and /customnode/install/pip; batch unknown-URL arm routes through the same full predicate at the risky position (loopback term is load-bearing — the middle entry gate has no network-position term; the entry gate itself stays in force); unknown-pip in batch stays unconditionally blocked; new SECURITY_MESSAGE_FLAG_* denial constants name the responsible flag; security_403_response gains flag_token (comfyui_outdated keeps precedence) - glob/manager_core.py: register both keys (read via get_bool default-false, write list, exception fallback); "true"-only truthy; restart-only activation - js/common.js: 403 dialog copy names the responsible flag at the two install call sites - README.md: security-policy docs for both flags (per-surface scope incl. the batch entry-gate qualifier, REPLACE decoupling, loopback bound, opt-in config snippet, default-deny + migration note); stale tier lists corrected against the actual gates - CHANGELOG.md: opt-in migration note + accepted residual risk (flags bypass the forced-strong outdated-ComfyUI hardening on loopback, opt-in only), decoupling claim qualified for the batch entry gate Tests: unit suite (predicate truth table, REPLACE litmus both directions, AST binding-proofs against live handlers, subprocess-isolated config contract) plus a real-server E2E suite that mounts the Manager-under-test via git worktree (exact-SHA pin, detached) against a real ComfyUI and exercises both flag surfaces and both arms — deny arms (403 + flag-naming body/log + no install artifact), git-URL allow arm (real clone), pip allow arm as a two-phase reservation oracle — with zero-residual self-clean. Module skips without E2E_COMFYUI_ROOT; unit suite unaffected. The manager-v4 branch ships the identical policy (shared invariants + config contract); this tree uses the degraded predicate 'flag AND loopback' (no personal_cloud-equivalent mode here).
154 lines
5.7 KiB
Python
154 lines
5.7 KiB
Python
"""Unit tests for the dedicated-install-flag predicate.
|
|
|
|
Covers `is_dedicated_install_allowed(flag_value, listen_address)` in
|
|
glob/manager_server.py:
|
|
|
|
- Truth table: allowed iff flag is true AND the listener is loopback.
|
|
- REPLACE-by-construction: the 2-arg signature has no security_level /
|
|
network_mode parameter and the body references no config machinery,
|
|
so security_level cannot influence the outcome in either direction.
|
|
- Cross-flag isolation: a single flag_value input cannot consult the
|
|
other flag.
|
|
- Request-time evaluation: the body must not read the import-time
|
|
`is_local_mode` snapshot (callers pass args.listen per request).
|
|
|
|
Harness: glob/manager_server.py is not importable under the test runner
|
|
(`from comfy.cli_args import args`, PromptServer), so we AST-parse the
|
|
file and exec only the wanted pure defs — `glob/` is never added to
|
|
sys.path (the dir name shadows the stdlib `glob`).
|
|
"""
|
|
import ast
|
|
import inspect
|
|
import unittest
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
MANAGER_SERVER_PATH = REPO_ROOT / "glob" / "manager_server.py"
|
|
|
|
_WANTED = {"is_loopback", "is_dedicated_install_allowed"}
|
|
|
|
|
|
def _load_predicates():
|
|
"""Parse manager_server.py; exec only the wanted pure function defs."""
|
|
source = MANAGER_SERVER_PATH.read_text()
|
|
tree = ast.parse(source)
|
|
nodes = []
|
|
node_by_name = {}
|
|
for node in tree.body:
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in _WANTED:
|
|
nodes.append(node)
|
|
node_by_name[node.name] = node
|
|
missing = _WANTED - node_by_name.keys()
|
|
assert not missing, f"expected pure defs missing from manager_server.py: {missing}"
|
|
module = ast.Module(body=nodes, type_ignores=[])
|
|
ns: dict = {"bool": bool}
|
|
exec(compile(module, "manager_server_predicates", "exec"), ns)
|
|
return ns, node_by_name
|
|
|
|
|
|
_NS, _NODES = _load_predicates()
|
|
IS_LOOPBACK: Any = _NS["is_loopback"]
|
|
PREDICATE: Any = _NS["is_dedicated_install_allowed"]
|
|
PREDICATE_NODE = _NODES["is_dedicated_install_allowed"]
|
|
|
|
|
|
class IsLoopbackBehaviorTest(unittest.TestCase):
|
|
"""Pins the loopback term the predicate composes."""
|
|
|
|
def test_ipv4_loopback(self):
|
|
self.assertTrue(IS_LOOPBACK("127.0.0.1"))
|
|
|
|
def test_public_address(self):
|
|
self.assertFalse(IS_LOOPBACK("0.0.0.0"))
|
|
|
|
def test_ipv6_loopback(self):
|
|
self.assertTrue(IS_LOOPBACK("::1"))
|
|
|
|
def test_invalid_address_reads_false(self):
|
|
# Non-IP strings deny-by-default (ValueError path).
|
|
self.assertFalse(IS_LOOPBACK("localhost"))
|
|
self.assertFalse(IS_LOOPBACK(""))
|
|
|
|
|
|
class DedicatedInstallPredicateTest(unittest.TestCase):
|
|
"""P-direct truth table + REPLACE-by-construction."""
|
|
|
|
def test_truth_table(self):
|
|
"""allowed iff flag AND loopback."""
|
|
cases = [
|
|
# (flag_value, listen_address, expected)
|
|
(True, "127.0.0.1", True),
|
|
(False, "127.0.0.1", False),
|
|
(True, "0.0.0.0", False),
|
|
(False, "0.0.0.0", False),
|
|
(True, "::1", True),
|
|
(True, "not-an-ip", False), # invalid listen -> deny
|
|
]
|
|
for flag_value, listen, expected in cases:
|
|
with self.subTest(flag=flag_value, listen=listen):
|
|
result = PREDICATE(flag_value, listen)
|
|
self.assertIsInstance(result, bool)
|
|
self.assertEqual(result, expected)
|
|
|
|
def test_falsy_flag_values_deny(self):
|
|
"""Secure-by-default: any falsy flag never allows."""
|
|
for falsy in (False, None, 0, ""):
|
|
with self.subTest(flag=falsy):
|
|
self.assertFalse(PREDICATE(falsy, "127.0.0.1"))
|
|
|
|
def test_signature_has_no_security_level(self):
|
|
"""Exactly (flag_value, listen_address) — no security_level term."""
|
|
params = list(inspect.signature(PREDICATE).parameters)
|
|
self.assertEqual(params, ["flag_value", "listen_address"])
|
|
for name in params:
|
|
self.assertNotIn("security", name)
|
|
self.assertNotIn("network_mode", name)
|
|
|
|
def test_body_free_of_config_machinery(self):
|
|
"""Body references no security_level plumbing, config reader, or the
|
|
import-time `is_local_mode` snapshot (request-time evaluation)."""
|
|
forbidden = {
|
|
"is_allowed_security_level",
|
|
"security_level",
|
|
"get_config",
|
|
"core",
|
|
"is_local_mode",
|
|
"network_mode",
|
|
"args",
|
|
}
|
|
seen = set()
|
|
for node in ast.walk(PREDICATE_NODE):
|
|
if isinstance(node, ast.Name):
|
|
seen.add(node.id)
|
|
elif isinstance(node, ast.Attribute):
|
|
seen.add(node.attr)
|
|
elif isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
seen.add(node.value)
|
|
self.assertEqual(
|
|
seen & forbidden, set(),
|
|
"predicate body must stay config-import-free",
|
|
)
|
|
|
|
def test_cross_flag_isolation_by_construction(self):
|
|
"""A single flag_value input cannot consult the other flag."""
|
|
seen_strings = {
|
|
node.value
|
|
for node in ast.walk(PREDICATE_NODE)
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
}
|
|
self.assertNotIn("allow_git_url_install", seen_strings)
|
|
self.assertNotIn("allow_pip_install", seen_strings)
|
|
self.assertTrue(PREDICATE(True, "127.0.0.1"))
|
|
self.assertFalse(PREDICATE(False, "127.0.0.1"))
|
|
|
|
def test_purity_deterministic(self):
|
|
"""Pure predicate — repeat calls identical."""
|
|
for _ in range(3):
|
|
self.assertTrue(PREDICATE(True, "127.0.0.1"))
|
|
self.assertFalse(PREDICATE(True, "0.0.0.0"))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main(verbosity=2)
|