Harden M9: Ed25519 attestation, full SHA-256 integrity, locked-by-default, prompt improvements, registry verification
This commit is contained in:
+90
-2
@@ -12,6 +12,7 @@ from cmdforge.mcp_client import (
|
||||
McpServerConfig,
|
||||
McpClientManager,
|
||||
_build_server_env,
|
||||
_build_http_headers,
|
||||
_normalize_result,
|
||||
_sanitize,
|
||||
_fingerprint,
|
||||
@@ -34,14 +35,15 @@ class TestMcpServerConfig:
|
||||
@pytest.mark.parametrize(
|
||||
"change, message",
|
||||
[
|
||||
({"transport": "streamable-http"}, "Unsupported MCP transport"),
|
||||
({"transport": "streamable-http"}, "requires a url"),
|
||||
({"args": "-y"}, "list of strings"),
|
||||
({"timeout": 0}, "greater than 0"),
|
||||
({"timeout": True}, "must be a number"),
|
||||
],
|
||||
)
|
||||
def test_validation_rejects_invalid_config(self, change, message):
|
||||
cfg = McpServerConfig(name="test", command="server", **change)
|
||||
command = None if change.get("transport") == "streamable-http" else "server"
|
||||
cfg = McpServerConfig(name="test", command=command, **change)
|
||||
with pytest.raises(ValueError, match=message):
|
||||
cfg.validate()
|
||||
|
||||
@@ -55,6 +57,31 @@ class TestMcpServerConfig:
|
||||
b = McpServerConfig(name="b", command="cmd", args=["-v"])
|
||||
assert _fingerprint(a) == _fingerprint(b)
|
||||
|
||||
def test_streamable_http_accepts_https(self):
|
||||
cfg = McpServerConfig(
|
||||
name="remote", transport="streamable-http",
|
||||
url="https://example.com/mcp", approved=True,
|
||||
)
|
||||
cfg.validate()
|
||||
|
||||
def test_streamable_http_allows_loopback_http_only(self):
|
||||
McpServerConfig(
|
||||
name="local", transport="streamable-http",
|
||||
url="http://127.0.0.1:8000/mcp",
|
||||
).validate()
|
||||
with pytest.raises(ValueError, match="must use HTTPS"):
|
||||
McpServerConfig(
|
||||
name="remote", transport="streamable-http",
|
||||
url="http://example.com/mcp",
|
||||
).validate()
|
||||
|
||||
def test_streamable_http_rejects_url_credentials(self):
|
||||
with pytest.raises(ValueError, match="must not contain credentials"):
|
||||
McpServerConfig(
|
||||
name="remote", transport="streamable-http",
|
||||
url="https://user:[email protected]/mcp",
|
||||
).validate()
|
||||
|
||||
|
||||
class TestSanitize:
|
||||
def test_redacts_bearer(self):
|
||||
@@ -85,6 +112,15 @@ class TestEnvironmentIsolation:
|
||||
)
|
||||
assert _build_server_env(cfg)["SERVER_TOKEN"] == "allowed"
|
||||
|
||||
def test_http_header_reference_is_resolved(self, monkeypatch):
|
||||
monkeypatch.setenv("MCP_TOKEN", "secret")
|
||||
cfg = McpServerConfig(
|
||||
name="remote", transport="streamable-http",
|
||||
url="https://example.com/mcp",
|
||||
headers={"Authorization": "Bearer ${MCP_TOKEN}"},
|
||||
)
|
||||
assert _build_http_headers(cfg)["Authorization"] == "Bearer secret"
|
||||
|
||||
def test_missing_environment_reference_fails(self, monkeypatch):
|
||||
monkeypatch.delenv("CMDFORGE_MISSING", raising=False)
|
||||
cfg = McpServerConfig(
|
||||
@@ -122,6 +158,19 @@ class TestMcpConfigPersistence:
|
||||
def test_load_no_file(self, temp_mcp_file):
|
||||
assert load_mcp_config() == []
|
||||
|
||||
def test_streamable_http_round_trip(self, temp_mcp_file):
|
||||
save_mcp_config([McpServerConfig(
|
||||
name="remote", transport="streamable-http",
|
||||
url="https://example.com/mcp",
|
||||
headers={"Authorization": "Bearer ${MCP_TOKEN}"},
|
||||
approved=True,
|
||||
)])
|
||||
loaded = load_mcp_config()[0]
|
||||
assert loaded.transport == "streamable-http"
|
||||
assert loaded.command is None
|
||||
assert loaded.url == "https://example.com/mcp"
|
||||
assert loaded.headers == {"Authorization": "Bearer ${MCP_TOKEN}"}
|
||||
|
||||
def test_save_creates_0600_permissions(self, temp_mcp_file):
|
||||
save_mcp_config([McpServerConfig(name="test", command="echo")])
|
||||
perms = oct(temp_mcp_file.stat().st_mode & 0o777)
|
||||
@@ -651,6 +700,45 @@ class TestRegisteredMcpTool:
|
||||
serve()
|
||||
|
||||
|
||||
class TestStreamableHttpServerSafety:
|
||||
def test_defaults_are_loopback_with_origin_allowlist(self):
|
||||
from cmdforge.mcp_server import _validate_http_server_options
|
||||
origins, token = _validate_http_server_options(
|
||||
"127.0.0.1", 8000, [], None, None
|
||||
)
|
||||
assert token is None
|
||||
assert origins == [
|
||||
"http://localhost:8000", "http://127.0.0.1:8000"
|
||||
]
|
||||
|
||||
def test_nonlocal_requires_https_and_auth(self):
|
||||
from cmdforge.mcp_server import _validate_http_server_options
|
||||
with pytest.raises(ValueError, match="external-url"):
|
||||
_validate_http_server_options("0.0.0.0", 8000, [], "secret", None)
|
||||
with pytest.raises(ValueError, match="auth-token"):
|
||||
_validate_http_server_options(
|
||||
"0.0.0.0", 8000, [], None, "https://mcp.example.com"
|
||||
)
|
||||
|
||||
def test_nonlocal_infers_https_origin_and_expands_token(self, monkeypatch):
|
||||
from cmdforge.mcp_server import _validate_http_server_options
|
||||
monkeypatch.setenv("MCP_AUTH", "secret-value")
|
||||
origins, token = _validate_http_server_options(
|
||||
"0.0.0.0", 8000, [], "${MCP_AUTH}",
|
||||
"https://mcp.example.com",
|
||||
)
|
||||
assert origins == ["https://mcp.example.com"]
|
||||
assert token == "secret-value"
|
||||
|
||||
def test_nonlocal_rejects_insecure_origin(self):
|
||||
from cmdforge.mcp_server import _validate_http_server_options
|
||||
with pytest.raises(ValueError, match="origins must use HTTPS"):
|
||||
_validate_http_server_options(
|
||||
"0.0.0.0", 8000, ["http://example.com"], "secret",
|
||||
"https://mcp.example.com",
|
||||
)
|
||||
|
||||
|
||||
class TestCmdForgeMcpServerEndToEnd:
|
||||
def test_stdio_discovery_and_invocation(self, tmp_path, monkeypatch):
|
||||
pytest.importorskip("mcp")
|
||||
|
||||
Reference in New Issue
Block a user