From 72f05493e54184f06970bfd60d7b13605e6c3f98 Mon Sep 17 00:00:00 2001
From: "Dr.Lt.Data" <128333288+ltdrdata@users.noreply.github.com>
Date: Fri, 13 Nov 2026 08:19:40 +0900
Subject: [PATCH] v4: Verify the TLS certificate when fetching the legacy
notice (#3504)
The legacy notice route fetched the News page from GitHub with
verify_ssl=False, so certificate verification was off for every user:
whoever sat on the connection could present any certificate for
github.com and choose the notice text and links. The sanitizer limits
the markup, but it cannot tell whose content it is.
- Turn verification off for the notice only when bypass_ssl is set, as
manager_util.get_data does
- Answer a certificate or connection failure with "Unable to retrieve
Notice" where it raised and the notice area showed the server's 500
page
- Add tests that run get_notice against a local HTTPS server behind a
local CONNECT proxy: an untrusted certificate and a trusted
certificate for another host are rejected; bypass_ssl accepts the
untrusted certificate; a CA trusted through SSL_CERT_FILE is
accepted; a closed proxy, a proxy answering 502 and a proxy that
never answers end in the unavailable notice, each with one warning
naming the cause
- Add a CHANGELOG entry
Port of #3424 to v4 (#3302).
---
CHANGELOG.md | 8 +
comfyui_manager/legacy/manager_server.py | 70 ++++----
tests/test_notice_html_sanitizer.py | 1 +
tests/test_notice_tls.py | 218 +++++++++++++++++++++++
4 files changed, 264 insertions(+), 33 deletions(-)
create mode 100644 tests/test_notice_tls.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3491b4c6..9c80e6d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -32,6 +32,14 @@ and this project adheres to [Semantic Versioning 2.0.0](https://semver.org/spec/
Entries are case-insensitive, apply to all versions, and take effect without a
restart. Server startup creates a commented template with no default exceptions;
other installation security checks still apply.
+- Verify the TLS certificate when the legacy UI fetches its notice from GitHub;
+ verification was off for every user. A response whose certificate is not
+ trusted is discarded and the notice area shows `Unable to retrieve Notice`. It
+ shows the same when the connection fails, where it used to show a server
+ error. Behind a proxy that re-signs TLS traffic, point the `SSL_CERT_FILE`
+ environment variable at a CA bundle that includes the proxy's CA, or set
+ `bypass_ssl = True` in `config.ini`, which turns verification off for this
+ fetch too (#3302).
### Fixed
diff --git a/comfyui_manager/legacy/manager_server.py b/comfyui_manager/legacy/manager_server.py
index 2f181423..7da62e73 100644
--- a/comfyui_manager/legacy/manager_server.py
+++ b/comfyui_manager/legacy/manager_server.py
@@ -1851,46 +1851,50 @@ async def get_notice(request):
url = "github.com"
path = "/ltdrdata/ltdrdata.github.io/wiki/News"
- async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=False)) as session:
- async with session.get(f"https://{url}{path}") as response:
- if response.status == 200:
- # html_content = response.read().decode('utf-8')
- html_content = await response.text()
+ try:
+ async with aiohttp.ClientSession(trust_env=True, connector=aiohttp.TCPConnector(verify_ssl=not manager_util.bypass_ssl)) as session:
+ async with session.get(f"https://{url}{path}") as response:
+ if response.status == 200:
+ # html_content = response.read().decode('utf-8')
+ html_content = await response.text()
- pattern = re.compile(r'
([\s\S]*?)
')
- match = pattern.search(html_content)
+ pattern = re.compile(r'([\s\S]*?)
')
+ match = pattern.search(html_content)
- if match:
- # Remote page content lands in innerHTML on the client:
- # keep formatting markup only.
- markdown_content = html_utils.sanitize_html_fragment(match.group(1))
- version_tag = os.environ.get('__COMFYUI_DESKTOP_VERSION__')
- if version_tag is not None:
- markdown_content += f"
ComfyUI: {version_tag} [Desktop]"
- else:
- version_tag = context.get_comfyui_tag()
- if version_tag is None:
- markdown_content += f"
ComfyUI: {core.comfy_ui_revision}[{comfy_ui_hash[:6]}]({core.comfy_ui_commit_datetime.date()})"
+ if match:
+ # Remote page content lands in innerHTML on the client:
+ # keep formatting markup only.
+ markdown_content = html_utils.sanitize_html_fragment(match.group(1))
+ version_tag = os.environ.get('__COMFYUI_DESKTOP_VERSION__')
+ if version_tag is not None:
+ markdown_content += f"
ComfyUI: {version_tag} [Desktop]"
else:
- markdown_content += (f"
ComfyUI: {version_tag}
"
- f" ({core.comfy_ui_commit_datetime.date()})")
- # markdown_content += f"
()"
- markdown_content += f"
Manager: {core.version_str}"
+ version_tag = context.get_comfyui_tag()
+ if version_tag is None:
+ markdown_content += f"
ComfyUI: {core.comfy_ui_revision}[{comfy_ui_hash[:6]}]({core.comfy_ui_commit_datetime.date()})"
+ else:
+ markdown_content += (f"
ComfyUI: {version_tag}
"
+ f" ({core.comfy_ui_commit_datetime.date()})")
+ # markdown_content += f"
()"
+ markdown_content += f"
Manager: {core.version_str}"
- try:
- if '__COMFYUI_DESKTOP_VERSION__' not in os.environ:
- if core.comfy_ui_commit_datetime == datetime(1900, 1, 1, 0, 0, 0):
- markdown_content = 'Your ComfyUI isn\'t git repo.
' + markdown_content
- elif core.comfy_ui_required_commit_datetime.date() > core.comfy_ui_commit_datetime.date():
- markdown_content = 'Your ComfyUI is too OUTDATED!!!
' + markdown_content
- except Exception:
- pass
+ try:
+ if '__COMFYUI_DESKTOP_VERSION__' not in os.environ:
+ if core.comfy_ui_commit_datetime == datetime(1900, 1, 1, 0, 0, 0):
+ markdown_content = 'Your ComfyUI isn\'t git repo.
' + markdown_content
+ elif core.comfy_ui_required_commit_datetime.date() > core.comfy_ui_commit_datetime.date():
+ markdown_content = 'Your ComfyUI is too OUTDATED!!!
' + markdown_content
+ except Exception:
+ pass
- return web.Response(text=markdown_content, status=200)
+ return web.Response(text=markdown_content, status=200)
+ else:
+ return web.Response(text="Unable to retrieve Notice", status=200)
else:
return web.Response(text="Unable to retrieve Notice", status=200)
- else:
- return web.Response(text="Unable to retrieve Notice", status=200)
+ except (aiohttp.ClientError, asyncio.TimeoutError) as e:
+ logging.warning(f"[ComfyUI-Manager] Unable to retrieve Notice: {type(e).__name__}: {e}")
+ return web.Response(text="Unable to retrieve Notice", status=200)
# legacy /manager/notice
diff --git a/tests/test_notice_html_sanitizer.py b/tests/test_notice_html_sanitizer.py
index cc27b6e2..69b9d3dc 100644
--- a/tests/test_notice_html_sanitizer.py
+++ b/tests/test_notice_html_sanitizer.py
@@ -123,6 +123,7 @@ def test_notice_endpoint_sanitizes_remote_content_and_retains_local_version_foot
'routes': web.RouteTableDef(), 're': re, 'web': web,
'html_utils': load_renderer()['html_utils'],
'aiohttp': SimpleNamespace(ClientSession=lambda **kwargs: client, TCPConnector=lambda **kwargs: None),
+ 'manager_util': SimpleNamespace(bypass_ssl=False),
'os': SimpleNamespace(environ={'__COMFYUI_DESKTOP_VERSION__': '1.0'}),
'core': SimpleNamespace(version_str='4.2.1'),
})
diff --git a/tests/test_notice_tls.py b/tests/test_notice_tls.py
new file mode 100644
index 00000000..da010486
--- /dev/null
+++ b/tests/test_notice_tls.py
@@ -0,0 +1,218 @@
+"""The legacy notice route verifies the upstream certificate and survives a failed fetch (#3302).
+
+Each case runs the real `get_notice` in a child process. The child points HTTPS_PROXY at a
+local CONNECT proxy that hands the tunnel for github.com:443 to a local HTTPS server, which
+holds a "github.com" certificate from a throwaway CA: a machine in the middle, unless the
+case trusts that CA through SSL_CERT_FILE. Nothing leaves 127.0.0.1. A child is needed
+because aiohttp builds its verifying SSL context when it is imported.
+"""
+import asyncio
+import json
+import logging
+import os
+import re
+import shutil
+import ssl
+import subprocess
+import sys
+from types import SimpleNamespace
+
+import aiohttp
+import pytest
+from aiohttp import web
+
+from legacy_html_testutil import PACKAGE, load_functions, load_renderer
+
+NOTICE_PATH = "/ltdrdata/ltdrdata.github.io/wiki/News"
+NOTICE_HTML = ('Served by the test server '
+ 'notes
')
+UNAVAILABLE = "Unable to retrieve Notice"
+TUNNEL = "CONNECT github.com:443 HTTP/1.1"
+OTHER_HOST = "notice-test.example"
+
+OPENSSL_CONFIG = """
+[req]
+distinguished_name = dn
+prompt = no
+[dn]
+CN = ComfyUI-Manager notice test CA
+[ca]
+basicConstraints = critical, CA:TRUE
+keyUsage = critical, keyCertSign, cRLSign
+subjectKeyIdentifier = hash
+"""
+LEAF_EXTENSIONS = """
+[leaf]
+basicConstraints = critical, CA:FALSE
+keyUsage = critical, digitalSignature, keyEncipherment
+extendedKeyUsage = serverAuth
+subjectAltName = DNS:{host}
+subjectKeyIdentifier = hash
+authorityKeyIdentifier = keyid, issuer
+"""
+
+
+def load_get_notice(bypass_ssl):
+ return load_functions(PACKAGE / "legacy/manager_server.py", {"get_notice"}, {
+ "routes": web.RouteTableDef(), "aiohttp": aiohttp, "asyncio": asyncio, "logging": logging,
+ "re": re, "web": web, "os": os,
+ "html_utils": load_renderer()["html_utils"],
+ "manager_util": SimpleNamespace(bypass_ssl=bypass_ssl),
+ "core": SimpleNamespace(version_str="4.test"),
+ })["get_notice"]
+
+
+async def _pipe(reader, writer):
+ try:
+ while data := await reader.read(65536):
+ writer.write(data)
+ await writer.drain()
+ finally:
+ writer.close()
+
+
+async def fetch_notice(certdir, host, bypass_ssl, proxy_mode):
+ """Child process: what `get_notice` answers and logs, and the tunnels the proxy was asked for."""
+ async def notice_page(request):
+ return web.Response(text=NOTICE_HTML, content_type="text/html")
+
+ upstream = web.Application()
+ upstream.router.add_get(NOTICE_PATH, notice_page)
+ runner = web.AppRunner(upstream)
+ await runner.setup()
+ server_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+ server_context.load_cert_chain(os.path.join(certdir, host + ".pem"), os.path.join(certdir, host + ".key"))
+ await web.TCPSite(runner, "127.0.0.1", 0, ssl_context=server_context).start()
+ upstream_port = runner.addresses[0][1]
+
+ tunnels = []
+ answered = asyncio.Event()
+
+ async def connect_proxy(reader, writer):
+ tunnels.append((await reader.readuntil(b"\r\n\r\n")).split(b"\r\n", 1)[0].decode())
+ if proxy_mode == "bad_gateway":
+ writer.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n")
+ await writer.drain()
+ if proxy_mode == "silent":
+ await answered.wait()
+ if proxy_mode != "tunnel":
+ writer.close()
+ return
+ upstream_reader, upstream_writer = await asyncio.open_connection("127.0.0.1", upstream_port)
+ writer.write(b"HTTP/1.1 200 Connection established\r\n\r\n")
+ await writer.drain()
+ await asyncio.gather(_pipe(reader, upstream_writer), _pipe(upstream_reader, writer), return_exceptions=True)
+
+ proxy = await asyncio.start_server(connect_proxy, "127.0.0.1", 0)
+ proxy_port = proxy.sockets[0].getsockname()[1]
+ if proxy_mode == "closed":
+ proxy.close()
+ await proxy.wait_closed()
+ if proxy_mode == "silent":
+ aiohttp.client.DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=0.5)
+ os.environ["HTTPS_PROXY"] = f"http://127.0.0.1:{proxy_port}"
+
+ # Only what get_notice logs: aiohttp warns through its own loggers, for example about .netrc.
+ warnings = []
+ handler = logging.Handler(logging.WARNING)
+ handler.emit = lambda record: record.name == "root" and warnings.append(record.getMessage())
+ logging.getLogger().addHandler(handler)
+
+ response = await load_get_notice(bypass_ssl)(None)
+ answered.set()
+ proxy.close()
+ await runner.cleanup()
+ return {"status": response.status, "text": response.text, "tunnels": tunnels, "warnings": warnings}
+
+
+@pytest.fixture(scope="module")
+def certdir(tmp_path_factory):
+ if shutil.which("openssl") is None:
+ pytest.skip("the openssl command is needed to create the test certificates")
+ path = tmp_path_factory.mktemp("notice-tls")
+ config = path / "openssl.cnf"
+ config.write_text(OPENSSL_CONFIG, encoding="utf-8")
+
+ def openssl(*args):
+ subprocess.run(["openssl", *args], cwd=path, check=True, capture_output=True)
+
+ openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "2", "-config", str(config),
+ "-extensions", "ca", "-keyout", "ca.key", "-out", "ca.pem")
+ for serial, host in enumerate(("github.com", OTHER_HOST), start=1):
+ (path / f"{host}.ext").write_text(LEAF_EXTENSIONS.format(host=host), encoding="utf-8")
+ openssl("req", "-newkey", "rsa:2048", "-nodes", "-subj", f"/CN={host}", "-config", str(config),
+ "-keyout", f"{host}.key", "-out", f"{host}.csr")
+ openssl("x509", "-req", "-in", f"{host}.csr", "-CA", "ca.pem", "-CAkey", "ca.key",
+ "-set_serial", str(serial), "-days", "2", "-extfile", f"{host}.ext", "-extensions", "leaf",
+ "-out", f"{host}.pem")
+ return path
+
+
+def fetch(certdir, host="github.com", trust_ca=False, bypass_ssl=False, proxy_mode="tunnel"):
+ env = {key: value for key, value in os.environ.items()
+ if not key.lower().endswith("_proxy") and key not in ("SSL_CERT_FILE", "SSL_CERT_DIR")}
+ env["__COMFYUI_DESKTOP_VERSION__"] = "test"
+ if trust_ca:
+ env["SSL_CERT_FILE"] = str(certdir / "ca.pem")
+ child = subprocess.run(
+ [sys.executable, __file__, str(certdir), host, str(int(bypass_ssl)), proxy_mode],
+ env=env, capture_output=True, text=True, timeout=60)
+ assert child.returncode == 0, "get_notice raised instead of answering:\n" + child.stderr[-2000:]
+ return json.loads(child.stdout.strip().splitlines()[-1])
+
+
+def assert_unavailable(result, *cause):
+ assert (result["status"], result["text"]) == (200, UNAVAILABLE)
+ assert len(result["warnings"]) == 1
+ for text in cause:
+ assert text in result["warnings"][0]
+
+
+def test_untrusted_certificate_is_rejected_before_its_content_is_used(certdir):
+ result = fetch(certdir)
+
+ assert result["tunnels"] == [TUNNEL]
+ assert_unavailable(result, "ClientConnectorCertificateError", "CERTIFICATE_VERIFY_FAILED")
+
+
+def test_bypass_ssl_accepts_the_untrusted_certificate(certdir):
+ result = fetch(certdir, bypass_ssl=True)
+
+ assert result["tunnels"] == [TUNNEL]
+ assert result["status"] == 200
+ assert "the test server" in result["text"]
+ assert result["warnings"] == []
+
+
+def test_trusted_certificate_for_another_host_is_rejected(certdir):
+ result = fetch(certdir, host=OTHER_HOST, trust_ca=True)
+
+ assert result["tunnels"] == [TUNNEL]
+ assert_unavailable(result, "ClientConnectorCertificateError", "not valid for 'github.com'")
+
+
+def test_ca_trusted_through_ssl_cert_file_is_accepted(certdir):
+ result = fetch(certdir, trust_ca=True)
+
+ assert result["tunnels"] == [TUNNEL]
+ assert result["status"] == 200
+ assert "the test server" in result["text"]
+ assert 'href="https://example.com/notes"' in result["text"]
+ assert "