feat(security): dedicated install flags decouple git_url/pip from security_level

Install via git URL and pip install are no longer gated by
security_level. Each surface gets a dedicated config.ini flag —
allow_git_url_install / allow_pip_install (both default false, secure
by default) — that fully REPLACES the security-level term for these two
features. The network-position invariant is retained: a non-local
listener stays denied regardless of the flags unless
network_mode = personal_cloud.

- New pure predicate is_dedicated_install_allowed() in
  common/manager_security (no config access; callers resolve config)
- Legacy endpoints /v2/customnode/install/git_url and .../pip switch
  from is_allowed_security_level('high+') to the flag gate; batch
  installs of unknown git URLs likewise (middle+ entry gate unchanged,
  unknown-pip 'block' stays unconditional; response shapes preserved)
- Config readers/writers (glob + legacy) parse and persist the flags;
  denial logs and frontend 403 messages name the responsible flag and
  note the non-local-listener requirement (network_mode=personal_cloud)
- No auto-seed from security_level — users previously on weak/normal-
  must opt in explicitly (see CHANGELOG migration notes; README
  documents the new contract)
- Update the pre-existing permissive E2E harness
  (start_comfyui_permissive.sh + test_e2e_legacy_real_ops.py) to the
  new contract: it now also sets allow_git_url_install /
  allow_pip_install = true, since security_level = normal- alone no
  longer opens the git_url/pip endpoints

Tests: predicate truth table proving security_level independence in
both directions, dual-reader config contract, security-level-matrix
freeze guards, legacy gate regression guards (121 unit), plus 22
real-server E2E tests incl. URL-form pip install with self-clean.
This commit is contained in:
Dr.Lt.Data
2026-06-11 00:20:44 +09:00
parent 8b98723b42
commit 7c09be6955
16 changed files with 2512 additions and 33 deletions
@@ -83,6 +83,25 @@ def is_loopback(address):
return False
def is_dedicated_install_allowed(flag_value: bool, listen_address: str, network_mode: str) -> bool:
"""P-direct predicate for the dedicated install flags (goal265-spec.md §1.2).
allowed iff flag AND (loopback listener OR network_mode == 'personal_cloud').
Gates the git-URL / standalone-pip install surfaces via the dedicated
``allow_git_url_install`` / ``allow_pip_install`` config flags, fully
decoupled from ``security_level`` (REPLACE, not AND — spec §1.1 inv. 1).
The network-position term retains today's invariant that a public
(non-loopback, non-personal_cloud) listener stays denied regardless of
the flags (spec §1.1 inv. 2).
Pure function — NO config access; callers resolve ``flag_value`` and
``network_mode`` through their own config reader and pass values in
(preserves common/ layering: this module must stay config-import-free).
"""
return bool(flag_value) and (is_loopback(listen_address) or network_mode.lower() == 'personal_cloud')
def do_nothing():
pass
+6
View File
@@ -1706,6 +1706,8 @@ def write_config():
'network_mode': get_config()['network_mode'],
'db_mode': get_config()['db_mode'],
'verbose': get_config()['verbose'],
'allow_git_url_install': get_config()['allow_git_url_install'],
'allow_pip_install': get_config()['allow_pip_install'],
}
# Sanitize all string values to prevent CRLF injection attacks
@@ -1755,6 +1757,8 @@ def read_config():
'security_level': default_conf.get('security_level', SecurityLevel.NORMAL.value).lower(),
'db_mode': default_conf.get('db_mode', DBMode.CACHE.value).lower(),
'verbose': get_bool('verbose', False),
'allow_git_url_install': get_bool('allow_git_url_install', False),
'allow_pip_install': get_bool('allow_pip_install', False),
}
except Exception:
@@ -1783,6 +1787,8 @@ def read_config():
'security_level': SecurityLevel.NORMAL.value,
'db_mode': DBMode.CACHE.value,
'verbose': False,
'allow_git_url_install': False,
'allow_pip_install': False,
}
+2 -2
View File
@@ -216,7 +216,7 @@ export async function install_pip(packages) {
});
if(res.status == 403) {
show_message('This action is not allowed with this security level configuration.');
show_message("To use this feature, set <code>allow_pip_install = true</code> in the [default] section of config.ini. This setting is independent of security_level.<BR>Note: if the ComfyUI listener is not local, <code>network_mode = personal_cloud</code> is also required.");
return;
}
@@ -251,7 +251,7 @@ export async function install_via_git_url(url, manager_dialog) {
});
if(res.status == 403) {
show_message('This action is not allowed with this security level configuration.');
show_message("To use this feature, set <code>allow_git_url_install = true</code> in the [default] section of config.ini. This setting is independent of security_level.<BR>Note: if the ComfyUI listener is not local, <code>network_mode = personal_cloud</code> is also required.");
return;
}
+6
View File
@@ -1690,6 +1690,8 @@ def write_config():
'always_lazy_install': get_config()['always_lazy_install'],
'network_mode': get_config()['network_mode'],
'db_mode': get_config()['db_mode'],
'allow_git_url_install': get_config()['allow_git_url_install'],
'allow_pip_install': get_config()['allow_pip_install'],
}
# Sanitize all string values to prevent CRLF injection attacks
@@ -1734,6 +1736,8 @@ def read_config():
'network_mode': default_conf.get('network_mode', NetworkMode.PUBLIC.value).lower(),
'security_level': default_conf.get('security_level', SecurityLevel.NORMAL.value).lower(),
'db_mode': default_conf.get('db_mode', DBMode.CACHE.value).lower(),
'allow_git_url_install': get_bool('allow_git_url_install', False),
'allow_pip_install': get_bool('allow_pip_install', False),
}
except Exception:
@@ -1757,6 +1761,8 @@ def read_config():
'network_mode': NetworkMode.PUBLIC.value,
'security_level': SecurityLevel.NORMAL.value,
'db_mode': DBMode.CACHE.value,
'allow_git_url_install': False,
'allow_pip_install': False,
}
+29 -5
View File
@@ -43,6 +43,8 @@ SECURITY_MESSAGE_HIGH_P = "ERROR: To use this action, '--listen' must be set to
SECURITY_MESSAGE_NORMAL_MINUS = "ERROR: To use this feature, you must either set '--listen' to a local IP and set the security level to 'normal-' or lower, or set the security level to 'middle' or 'weak'. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_GENERAL = "ERROR: This installation is not allowed in this security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_NORMAL_MINUS_MODEL = "ERROR: Downloading models that are not in '.safetensors' format is only allowed for models registered in the 'default' channel at this security level. If you want to download this model, set the security level to 'normal-' or lower."
SECURITY_MESSAGE_FLAG_GIT_URL = "ERROR: This action requires 'allow_git_url_install = true' in config.ini ([default] section). This setting is independent of security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
SECURITY_MESSAGE_FLAG_PIP = "ERROR: This action requires 'allow_pip_install = true' in config.ini ([default] section). This setting is independent of security_level. Please contact the administrator.\nReference: https://github.com/Comfy-Org/ComfyUI-Manager#security-policy"
routes = PromptServer.instance.routes
@@ -122,6 +124,18 @@ def is_allowed_security_level(level):
return True
def _dedicated_install_allowed(flag_key: str) -> bool:
"""goal265: P-direct gate for the dedicated install flags (spec §1.2).
allowed iff config[flag_key] AND (loopback listener OR
network_mode == 'personal_cloud') fully decoupled from security_level.
Resolves config through the LEGACY reader and delegates the pure
predicate to common/manager_security (which stays config-import-free).
"""
return manager_security.is_dedicated_install_allowed(
core.get_config()[flag_key], args.listen, core.get_config()['network_mode'])
async def get_risky_level(files, pip_packages):
json_data1 = await core.get_data_by_mode('local', 'custom-node-list.json')
json_data2 = await core.get_data_by_mode('cache', 'custom-node-list.json', channel_url='https://raw.githubusercontent.com/Comfy-Org/ComfyUI-Manager/main')
@@ -1473,7 +1487,15 @@ async def _install_custom_node(json_data):
else:
return web.Response(status=404, text=f"Following node pack doesn't provide `nightly` version: ${git_url}")
if not is_allowed_security_level(risky_level):
# goal265 S-C (middle+ entry gate above UNCHANGED): unknown git URL ('high+')
# -> dedicated-flag full predicate replaces the security_level check (spec §1.2);
# unknown pip ('block') -> unconditional deny via is_allowed_security_level (Q1).
# Flag-deny PRESERVES today's 404 response shape at this position (R1).
if risky_level == 'high+':
if not _dedicated_install_allowed('allow_git_url_install'):
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL)
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
elif not is_allowed_security_level(risky_level):
logging.error(SECURITY_MESSAGE_GENERAL)
return web.Response(status=404, text="A security error has occurred. Please check the terminal logs")
@@ -1527,8 +1549,9 @@ async def _fix_custom_node(json_data):
@routes.post("/v2/customnode/install/git_url")
async def install_custom_node_git_url(request):
if not is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
# goal265 S-A: dedicated-flag gate, decoupled from security_level (spec §1.2).
if not _dedicated_install_allowed('allow_git_url_install'):
logging.error(SECURITY_MESSAGE_FLAG_GIT_URL)
return web.Response(status=403)
url = await request.text()
@@ -1547,8 +1570,9 @@ async def install_custom_node_git_url(request):
@routes.post("/v2/customnode/install/pip")
async def install_custom_node_pip(request):
if not is_allowed_security_level('high+'):
logging.error(SECURITY_MESSAGE_NORMAL_MINUS)
# goal265 S-B: dedicated-flag gate, decoupled from security_level (spec §1.2).
if not _dedicated_install_allowed('allow_pip_install'):
logging.error(SECURITY_MESSAGE_FLAG_PIP)
return web.Response(status=403)
packages = await request.text()