Skip to content

Commit c441b1c

Browse files
authored
Merge pull request #28 from hybridindie/fix/p1-quick-wins
fix: P1 quick-win findings from comprehensive review
2 parents 2f41fe2 + 4c6a2c9 commit c441b1c

File tree

8 files changed

+195
-39
lines changed

8 files changed

+195
-39
lines changed

.github/workflows/docker.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,31 @@ on:
99
- 'v*'
1010

1111
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
16+
17+
- name: Install uv
18+
uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4
19+
20+
- name: Set up Python
21+
run: uv python install 3.12
22+
23+
- name: Install dependencies
24+
run: uv sync
25+
26+
- name: Lint and type-check
27+
run: |
28+
uv run ruff check src/ tests/
29+
uv run ruff format --check src/ tests/
30+
uv run mypy src/comfyui_mcp/
31+
32+
- name: Run tests
33+
run: uv run pytest -v
34+
1235
docker:
36+
needs: test
1337
runs-on: ubuntu-latest
1438
permissions:
1539
contents: read

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ FROM python:3.12-slim
22

33
WORKDIR /app
44

5-
RUN groupadd --system app && useradd --system --gid app app
5+
RUN groupadd --system app && useradd --system --gid app --create-home app
66

77
RUN pip install --no-cache-dir uv
88

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,8 @@ transport:
279279
port: 8080
280280
```
281281
282+
> **SSE transport security warning:** The SSE transport has no built-in authentication. Binding to `0.0.0.0` or any non-localhost address exposes all MCP tools (workflow execution, file uploads, queue management) to any network client without authentication. Only enable SSE behind a reverse proxy (e.g., nginx, Caddy) that provides authentication and TLS. The server logs a warning at startup when SSE is bound to a non-localhost address.
283+
282284
### Environment variables
283285

284286
Environment variables override config file values:
@@ -411,6 +413,15 @@ security:
411413

412414
**Tip:** Use `audit_dangerous_nodes` to identify dangerous nodes, run workflows in audit mode to see which nodes you use, then switch to enforce mode with that allowlist.
413415

416+
### Choosing a security mode
417+
418+
| Mode | Behavior | Use case |
419+
|------|----------|----------|
420+
| `audit` (default) | Logs warnings about dangerous nodes but **allows** all workflows to execute | Development, initial setup, discovering which nodes your workflows use |
421+
| `enforce` | **Blocks** any workflow containing a node not on `allowed_nodes` | Production, shared environments, security-sensitive deployments |
422+
423+
Start in `audit` mode, review your audit log to build an allowlist, then switch to `enforce` mode for production use.
424+
414425
## Audit log
415426

416427
All tool invocations are logged as JSON lines to `~/.comfyui-mcp/audit.log`:
@@ -425,6 +436,24 @@ grep '"warnings":\[' ~/.comfyui-mcp/audit.log | grep -v '"warnings":\[\]'
425436

426437
Sensitive fields (`token`, `password`, `secret`, `api_key`, `authorization`) are automatically redacted from log entries.
427438

439+
### Log rotation
440+
441+
The audit log grows indefinitely. Set up log rotation to prevent disk exhaustion:
442+
443+
```bash
444+
# /etc/logrotate.d/comfyui-mcp
445+
/home/user/.comfyui-mcp/audit.log {
446+
weekly
447+
rotate 12
448+
compress
449+
missingok
450+
notifempty
451+
copytruncate
452+
}
453+
```
454+
455+
Use `copytruncate` because the server holds the file open. Adjust the path to match your `logging.audit_file` setting.
456+
428457
## Security
429458

430459
### Threat model
@@ -435,7 +464,6 @@ Sensitive fields (`token`, `password`, `secret`, `api_key`, `authorization`) are
435464
| Path traversal via file operations | High | Path sanitizer blocks `..`, null bytes, encoded attacks, absolute paths |
436465
| Denial of service via request flooding | Medium | Token-bucket rate limiter per tool category |
437466
| Credential leakage in logs | Medium | Automatic redaction of `token`, `password`, `secret`, `api_key`, `authorization` |
438-
| Information disclosure via API | Low | Dangerous endpoints (`/userdata`, `/free`, `/system_stats`) never proxied |
439467
| Information disclosure via API | Low | Dangerous endpoints (`/userdata`, `/free`) never proxied; `/system_stats` whitelist-filtered by `get_system_info` |
440468
| MITM on ComfyUI connection | Medium | Configurable TLS verification |
441469

docker-compose.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
version: "3.8"
2-
31
services:
42
comfyui-mcp:
53
build: .
@@ -9,8 +7,8 @@ services:
97
- COMFYUI_URL=${COMFYUI_URL:-http://comfyui:8188}
108
- COMFYUI_SECURITY_MODE=${COMFYUI_SECURITY_MODE:-audit}
119
volumes:
12-
- ./config.yaml:/root/.comfyui-mcp/config.yaml:ro
13-
- comfyui-mcp-data:/root/.comfyui-mcp/logs
10+
- ./config.yaml:/home/app/.comfyui-mcp/config.yaml:ro
11+
- comfyui-mcp-data:/home/app/.comfyui-mcp/logs
1412
restart: unless-stopped
1513

1614
volumes:

src/comfyui_mcp/model_manager.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import time
67

78
import httpx
89

910
from comfyui_mcp.client import ComfyUIClient
1011

12+
_NEGATIVE_TTL_SECONDS = 300 # Re-probe after 5 minutes on failure
13+
1114

1215
class ModelManagerUnavailableError(Exception):
1316
"""Raised when Model Manager is not installed or unreachable."""
@@ -19,6 +22,10 @@ class ModelManagerDetector:
1922
Uses asyncio.Lock to prevent concurrent probes from racing.
2023
Calls GET /model-manager/models once — the response both confirms availability
2124
and provides the folder list in a single round-trip.
25+
26+
Positive results are cached permanently. Negative results expire after
27+
``_NEGATIVE_TTL_SECONDS`` so a late-starting Model Manager is picked up
28+
without requiring a server restart.
2229
"""
2330

2431
_INSTALL_URL = "https://github.com/hayden-fr/ComfyUI-Model-Manager"
@@ -28,19 +35,30 @@ def __init__(self, client: ComfyUIClient) -> None:
2835
self._folders: list[str] | None = None
2936
self._checked = False
3037
self._available = False
38+
self._last_failure: float = 0.0
3139
self._lock = asyncio.Lock()
3240

41+
def _should_reprobe(self) -> bool:
42+
"""Return True if the negative cache has expired."""
43+
if self._available:
44+
return False
45+
if not self._checked:
46+
return True
47+
return (time.monotonic() - self._last_failure) >= _NEGATIVE_TTL_SECONDS
48+
3349
async def _probe(self) -> None:
34-
"""Probe Model Manager once and cache the result."""
50+
"""Probe Model Manager and cache the result."""
3551
async with self._lock:
36-
if self._checked:
52+
if self._checked and not self._should_reprobe():
3753
return
38-
self._checked = True
3954
try:
4055
self._folders = await self._client.get_model_manager_folders()
4156
self._available = True
57+
self._checked = True
4258
except (httpx.HTTPStatusError, httpx.RequestError):
4359
self._available = False
60+
self._checked = True
61+
self._last_failure = time.monotonic()
4462

4563
async def is_available(self) -> bool:
4664
"""Check if Model Manager is installed. Caches the result."""

tests/test_client.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,46 @@ async def test_no_retry_on_http_error(self, client):
220220
assert route.call_count == 1
221221

222222

223+
class TestPathInjectionPrevention:
224+
async def test_prompt_id_path_traversal_rejected(self, client):
225+
with pytest.raises(ValueError, match="Invalid prompt_id"):
226+
await client.get_history_item("../../../free")
227+
228+
async def test_prompt_id_non_uuid_rejected(self, client):
229+
with pytest.raises(ValueError, match="Invalid prompt_id"):
230+
await client.get_history_item("not-a-uuid")
231+
232+
@respx.mock
233+
async def test_prompt_id_valid_uuid_accepted(self, client):
234+
route = respx.get(
235+
"http://test-comfyui:8188/history/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
236+
).mock(
237+
return_value=httpx.Response(
238+
200,
239+
json={"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee": {"outputs": {}}},
240+
)
241+
)
242+
result = await client.get_history_item("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
243+
assert "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" in result
244+
assert route.call_count == 1
245+
246+
async def test_delete_queue_path_traversal_rejected(self, client):
247+
with pytest.raises(ValueError, match="Invalid prompt_id"):
248+
await client.delete_queue_item("../../free")
249+
250+
async def test_path_segment_traversal_rejected(self, client):
251+
with pytest.raises(ValueError, match="invalid characters"):
252+
await client.get_object_info("../../userdata")
253+
254+
async def test_path_segment_empty_rejected(self, client):
255+
with pytest.raises(ValueError, match="must not be empty"):
256+
await client.delete_download_task("")
257+
258+
async def test_http_method_injection_rejected(self, client):
259+
with pytest.raises(ValueError, match="HTTP method not allowed"):
260+
await client._request("CONNECT", "/queue")
261+
262+
223263
class TestModelManagerClient:
224264
@respx.mock
225265
async def test_get_model_manager_folders(self, client):

tests/test_integration.py

Lines changed: 43 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
11
"""End-to-end integration tests with mocked ComfyUI backend.
22
3-
These tests wire up the full server stack (config -> security -> tools -> client)
3+
These tests wire up tool registration directly (via register_*_tools return dicts)
44
and exercise tools through the same code paths used in production.
55
"""
66

77
import json
88

99
import httpx
1010
import respx
11+
from mcp.server.fastmcp import FastMCP
1112

12-
from comfyui_mcp.config import ComfyUISettings, SecuritySettings, Settings
13-
from comfyui_mcp.security.inspector import WorkflowBlockedError
14-
from comfyui_mcp.server import _build_server
13+
from comfyui_mcp.audit import AuditLogger
14+
from comfyui_mcp.client import ComfyUIClient
15+
from comfyui_mcp.security.inspector import WorkflowBlockedError, WorkflowInspector
16+
from comfyui_mcp.security.rate_limit import RateLimiter
17+
from comfyui_mcp.security.sanitizer import PathSanitizer
18+
from comfyui_mcp.tools.discovery import register_discovery_tools
19+
from comfyui_mcp.tools.generation import register_generation_tools
20+
from comfyui_mcp.tools.jobs import register_job_tools
1521

1622

1723
class TestImageGenerationFlow:
1824
@respx.mock
19-
async def test_generate_image_lists_models_then_generates(self):
25+
async def test_generate_image_lists_models_then_generates(self, tmp_path):
2026
"""Full flow: list models -> generate image -> check job."""
2127
respx.get("http://mock-comfyui:8188/models/checkpoints").mock(
2228
return_value=httpx.Response(200, json=["sd_v15.safetensors"])
@@ -47,58 +53,66 @@ async def test_generate_image_lists_models_then_generates(self):
4753
)
4854
)
4955

50-
settings = Settings(comfyui=ComfyUISettings(url="http://mock-comfyui:8188"))
51-
server, _ = _build_server(settings)
56+
client = ComfyUIClient(base_url="http://mock-comfyui:8188")
57+
audit = AuditLogger(audit_file=tmp_path / "audit.log")
58+
limiter = RateLimiter(max_per_minute=60)
59+
inspector = WorkflowInspector(mode="audit", dangerous_nodes=[], allowed_nodes=[])
60+
sanitizer = PathSanitizer(allowed_extensions=[".png", ".jpg", ".jpeg", ".webp"])
61+
mcp = FastMCP("test")
62+
63+
discovery_tools = register_discovery_tools(mcp, client, audit, limiter, sanitizer)
64+
gen_tools = register_generation_tools(mcp, client, audit, limiter, inspector)
65+
job_tools = register_job_tools(mcp, client, audit, limiter)
5266

5367
# Step 1: Discover available models
54-
tools = server._tool_manager._tools
55-
list_models_fn = tools["list_models"].fn
56-
models = await list_models_fn(folder="checkpoints")
68+
models = await discovery_tools["list_models"](folder="checkpoints")
5769
assert "sd_v15.safetensors" in models
5870

5971
# Step 2: Generate an image
60-
generate_fn = tools["generate_image"].fn
61-
result = await generate_fn(prompt="a sunset over mountains")
72+
result = await gen_tools["generate_image"](prompt="a sunset over mountains")
6273
assert "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" in result
6374

6475
# Step 3: Check the job
65-
get_job_fn = tools["get_job"].fn
66-
job = await get_job_fn(prompt_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
76+
job = await job_tools["get_job"](prompt_id="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
6777
assert "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" in job
6878

6979
@respx.mock
70-
async def test_run_workflow_with_dangerous_node_in_audit_mode(self):
80+
async def test_run_workflow_with_dangerous_node_in_audit_mode(self, tmp_path):
7181
"""Audit mode logs dangerous nodes but still submits the workflow."""
7282
respx.post("http://mock-comfyui:8188/prompt").mock(
7383
return_value=httpx.Response(
7484
200, json={"prompt_id": "11111111-2222-3333-4444-555555555555"}
7585
)
7686
)
7787

78-
settings = Settings(comfyui=ComfyUISettings(url="http://mock-comfyui:8188"))
79-
server, _ = _build_server(settings)
88+
client = ComfyUIClient(base_url="http://mock-comfyui:8188")
89+
audit = AuditLogger(audit_file=tmp_path / "audit.log")
90+
limiter = RateLimiter(max_per_minute=60)
91+
inspector = WorkflowInspector(mode="audit", dangerous_nodes=["Terminal"], allowed_nodes=[])
92+
mcp = FastMCP("test")
8093

81-
run_workflow_fn = server._tool_manager._tools["run_workflow"].fn
94+
tools = register_generation_tools(mcp, client, audit, limiter, inspector)
8295
workflow = json.dumps({"1": {"class_type": "Terminal", "inputs": {}}})
83-
result = await run_workflow_fn(workflow=workflow)
96+
result = await tools["run_workflow"](workflow=workflow)
8497
assert "11111111-2222-3333-4444-555555555555" in result
8598
assert "Terminal" in result
8699

87-
async def test_run_workflow_blocked_in_enforce_mode(self):
100+
async def test_run_workflow_blocked_in_enforce_mode(self, tmp_path):
88101
"""Enforce mode blocks workflows with unapproved nodes."""
89-
settings = Settings(
90-
comfyui=ComfyUISettings(url="http://mock-comfyui:8188"),
91-
security=SecuritySettings(
92-
mode="enforce",
93-
allowed_nodes=["KSampler", "CLIPTextEncode"],
94-
),
102+
client = ComfyUIClient(base_url="http://mock-comfyui:8188")
103+
audit = AuditLogger(audit_file=tmp_path / "audit.log")
104+
limiter = RateLimiter(max_per_minute=60)
105+
inspector = WorkflowInspector(
106+
mode="enforce",
107+
dangerous_nodes=[],
108+
allowed_nodes=["KSampler", "CLIPTextEncode"],
95109
)
96-
server, _ = _build_server(settings)
110+
mcp = FastMCP("test")
97111

98-
run_workflow_fn = server._tool_manager._tools["run_workflow"].fn
112+
tools = register_generation_tools(mcp, client, audit, limiter, inspector)
99113
workflow = json.dumps({"1": {"class_type": "MaliciousNode", "inputs": {}}})
100114
try:
101-
await run_workflow_fn(workflow=workflow)
115+
await tools["run_workflow"](workflow=workflow)
102116
raise AssertionError("Should have raised WorkflowBlockedError")
103117
except WorkflowBlockedError as e:
104118
assert "MaliciousNode" in str(e)

tests/test_model_manager.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import time
2+
13
import httpx
24
import pytest
35
import respx
46

57
from comfyui_mcp.client import ComfyUIClient
6-
from comfyui_mcp.model_manager import ModelManagerDetector, ModelManagerUnavailableError
8+
from comfyui_mcp.model_manager import (
9+
_NEGATIVE_TTL_SECONDS,
10+
ModelManagerDetector,
11+
ModelManagerUnavailableError,
12+
)
713

814

915
@pytest.fixture
@@ -103,3 +109,31 @@ async def test_validate_folder_unknown(self, client):
103109
detector = ModelManagerDetector(client)
104110
with pytest.raises(ValueError, match="not a valid model folder"):
105111
await detector.validate_folder("invalid_folder")
112+
113+
@respx.mock
114+
async def test_negative_cache_expires(self, client):
115+
"""After a failed probe, re-probes once the negative TTL expires."""
116+
route = respx.get("http://test:8188/model-manager/models")
117+
118+
# First call: Model Manager not available
119+
route.mock(return_value=httpx.Response(404))
120+
detector = ModelManagerDetector(client)
121+
assert await detector.is_available() is False
122+
assert route.call_count == 1
123+
124+
# Second call within TTL: should use cached negative result
125+
route.mock(
126+
return_value=httpx.Response(
127+
200,
128+
json={"success": True, "data": {"checkpoints": ["/models/checkpoints"]}},
129+
)
130+
)
131+
assert await detector.is_available() is False
132+
assert route.call_count == 1 # No new probe
133+
134+
# Simulate TTL expiry
135+
detector._last_failure = time.monotonic() - _NEGATIVE_TTL_SECONDS - 1
136+
137+
# Third call after TTL: should re-probe and succeed
138+
assert await detector.is_available() is True
139+
assert route.call_count == 2

0 commit comments

Comments
 (0)