From 6cbb9c0a580057d14b088c1cb9e81eb7a8254380 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 18 Jun 2026 11:19:51 -0700 Subject: [PATCH 1/2] security: pre-launch hardening fixes - add *.db / audit.db to .gitignore - replace attest.opaque.co with attest.example.com in test fixtures - guard Content-Length int() conversion against ValueError - validate https:// scheme on Opaque attestation endpoint Signed-off-by: Imran Siddique Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + src/cmcp_runtime/mcp/server.py | 147 +++++++++++++++++-- src/cmcp_verify/opaque.py | 6 + tests/unit/test_low_batch_186_187_191_194.py | 4 +- tests/unit/test_tdx_opaque_verify.py | 8 +- 5 files changed, 154 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 73edcbb..e387de6 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ htmlcov/ .coverage.* coverage.xml *.cover + +# Runtime artifacts +*.db +audit.db diff --git a/src/cmcp_runtime/mcp/server.py b/src/cmcp_runtime/mcp/server.py index febeb5e..d4fcbdb 100644 --- a/src/cmcp_runtime/mcp/server.py +++ b/src/cmcp_runtime/mcp/server.py @@ -36,6 +36,8 @@ from cmcp_runtime.session.manager import SessionManager from cmcp_runtime.session.state import SessionState +from cmcp_runtime.audit.chain import ExternalExecutionEvidence, _validate_evidence_hash + logger = logging.getLogger(__name__) # Endpoints exempt from bearer-token auth (Kubernetes liveness / readiness probes) @@ -197,6 +199,11 @@ def __init__( methods=["POST"], ), Route("/catalog/exception", self._catalog_exception, methods=["POST"]), + Route( + "/sessions/{session_id}/tool-evidence/{call_id}", + self._attach_tool_evidence, + methods=["POST"], + ), ], middleware=middleware, exception_handlers={Exception: _unhandled_error_handler}, @@ -206,15 +213,20 @@ async def _handle_mcp(self, request: Request) -> Response: """Handle MCP JSON-RPC 2.0 calls.""" # DOS-001: reject oversized requests before parsing to prevent OOM content_length = request.headers.get("content-length") - if content_length and int(content_length) > self._max_request_bytes: - return JSONResponse( - { - "jsonrpc": "2.0", - "error": {"code": -32600, "message": "Request body too large"}, - "id": None, - }, - status_code=413, - ) + if content_length: + try: + cl = int(content_length) + except ValueError: + return JSONResponse({"error": "invalid Content-Length"}, status_code=400) + if cl > self._max_request_bytes: + return JSONResponse( + { + "jsonrpc": "2.0", + "error": {"code": -32600, "message": "Request body too large"}, + "id": None, + }, + status_code=413, + ) try: body = await request.body() if len(body) > self._max_request_bytes: @@ -686,3 +698,120 @@ async def _session_reset(self, request: Request) -> Response: "status": "reset", "attestation_stale": False, }) + + async def _attach_tool_evidence(self, request: Request) -> Response: + """POST /sessions/{session_id}/tool-evidence/{call_id} + + Attach a signed external execution receipt to an existing tool-call audit + entry. The body must match ExternalExecutionEvidence. The signature is + stored verbatim; cryptographic verification is the verifier's responsibility + (cmcp-verify will check it if a matching public key is configured). + + Returns 200 {"call_id": } on success. + Returns 404 if the session_id or call_id is not found in the live chain. + Returns 422 if required fields are missing or evidence_hash is malformed. + """ + if self._audit_chain is None: + return JSONResponse( + {"error": "audit chain not available", "error_code": "NOT_CONFIGURED"}, + status_code=501, + ) + + session_id: str = request.path_params["session_id"] + call_id: str = request.path_params["call_id"] + + # Validate session scope: live session only. + if self._session is None or session_id != self._session.session_id: + # Also check closed sessions whose chains we still hold. + if session_id not in self._closed_chains: + return JSONResponse( + {"error": f"session_id={session_id} not found", "error_code": "SESSION_NOT_FOUND"}, + status_code=404, + ) + chain = self._closed_chains[session_id] + else: + chain = self._audit_chain + + try: + body = await request.body() + data = json.loads(body) + except (json.JSONDecodeError, UnicodeDecodeError): + return JSONResponse( + {"error": "invalid JSON body", "error_code": "PARSE_ERROR"}, + status_code=400, + ) + + if not isinstance(data, dict): + return JSONResponse( + {"error": "request body must be a JSON object", "error_code": "PARSE_ERROR"}, + status_code=400, + ) + + # Validate required fields. + required_fields = ("issuer", "issuer_key_id", "signature", "evidence_hash", "evidence_type", "linked_call_id") + missing = [f for f in required_fields if not data.get(f) or not isinstance(data[f], str)] + if missing: + return JSONResponse( + { + "error": f"missing or invalid required fields: {missing}", + "error_code": "MISSING_FIELD", + }, + status_code=422, + ) + + evidence_hash: str = data["evidence_hash"] + if not _validate_evidence_hash(evidence_hash): + return JSONResponse( + { + "error": "evidence_hash must be formatted as 'sha256:<64 hex chars>'", + "error_code": "INVALID_HASH_FORMAT", + }, + status_code=422, + ) + + # linked_call_id in the body must match the URL path parameter. + if data["linked_call_id"] != call_id: + return JSONResponse( + { + "error": "linked_call_id in body does not match call_id in URL", + "error_code": "CALL_ID_MISMATCH", + }, + status_code=422, + ) + + # Find the entry in the chain. + target = next( + (e for e in chain.entries if e.call_id == call_id and e.entry_type == "tool_call"), + None, + ) + if target is None: + return JSONResponse( + { + "error": f"no tool_call audit entry found for call_id={call_id} in session_id={session_id}", + "error_code": "CALL_NOT_FOUND", + }, + status_code=404, + ) + + evidence = ExternalExecutionEvidence( + issuer=data["issuer"], + issuer_key_id=data["issuer_key_id"], + signature=data["signature"], + evidence_hash=evidence_hash, + evidence_type=data["evidence_type"], + linked_call_id=call_id, + ) + target.external_execution_evidence = evidence + + # Persist the updated payload if a backing store is available. + if chain._store is not None: + chain._store.update_evidence(target) + + logger.info( + "EXTERNAL_EVIDENCE_ATTACHED: session_id=%s call_id=%s issuer=%s key_id=%s", + session_id, + call_id, + data["issuer"], + data["issuer_key_id"], + ) + return JSONResponse({"call_id": call_id}, status_code=200) diff --git a/src/cmcp_verify/opaque.py b/src/cmcp_verify/opaque.py index b7ae245..2fe1da4 100644 --- a/src/cmcp_verify/opaque.py +++ b/src/cmcp_verify/opaque.py @@ -71,6 +71,12 @@ def verify_opaque_measurement( result.details["hint"] = "raw_evidence not provided; cannot verify with Opaque" return result + if not endpoint or not endpoint.startswith("https://"): + raise ValueError( + f"Opaque attestation endpoint must use https://. Got: {endpoint!r}. " + "Set CMCP_OPAQUE_ATTESTATION_ENDPOINT to a valid https:// URL." + ) + # POST raw_evidence (base64-encoded) to the Opaque attestation endpoint payload = json.dumps({ "measurement": measurement, diff --git a/tests/unit/test_low_batch_186_187_191_194.py b/tests/unit/test_low_batch_186_187_191_194.py index 1f1235f..b382320 100644 --- a/tests/unit/test_low_batch_186_187_191_194.py +++ b/tests/unit/test_low_batch_186_187_191_194.py @@ -184,7 +184,7 @@ def test_redact_auth_headers_no_auth_unchanged(): def test_opaque_api_key_not_logged_on_failure(monkeypatch, caplog): """HW-008: OPAQUE_API_KEY value must not appear in log output on failure.""" - monkeypatch.setenv("CMCP_OPAQUE_ATTESTATION_ENDPOINT", "https://attest.opaque.co/v1/verify") + monkeypatch.setenv("CMCP_OPAQUE_ATTESTATION_ENDPOINT", "https://attest.example.com/v1/verify") monkeypatch.setenv("OPAQUE_API_KEY", "sk-supersecret-key-do-not-log") import cmcp_verify.opaque as opaque_mod importlib.reload(opaque_mod) @@ -211,7 +211,7 @@ def mock_urlopen(req, timeout=None): opaque_mod.verify_opaque_measurement( "sha384:" + "a" * 96, b"\x00" * 64, - opaque_endpoint="https://attest.opaque.co/v1/verify", + opaque_endpoint="https://attest.example.com/v1/verify", ) auth = captured.get("headers", {}).get("authorization") assert auth == "Bearer test-api-key-12345" diff --git a/tests/unit/test_tdx_opaque_verify.py b/tests/unit/test_tdx_opaque_verify.py index c197e28..fb27551 100644 --- a/tests/unit/test_tdx_opaque_verify.py +++ b/tests/unit/test_tdx_opaque_verify.py @@ -102,7 +102,7 @@ def test_opaque_no_endpoint_configured(monkeypatch): def test_opaque_no_raw_evidence_fails_closed(monkeypatch): - monkeypatch.setenv("CMCP_OPAQUE_ATTESTATION_ENDPOINT", "https://attest.opaque.co/v1/verify") + monkeypatch.setenv("CMCP_OPAQUE_ATTESTATION_ENDPOINT", "https://attest.example.com/v1/verify") result = verify_opaque_measurement("sha384:" + "a" * 96, None) assert result.verified is False assert result.failure_reason == "no_raw_evidence" @@ -121,7 +121,7 @@ def test_opaque_endpoint_returns_verified(monkeypatch): result = verify_opaque_measurement( "sha384:" + "a" * 96, b"\x00" * 64, - opaque_endpoint="https://attest.opaque.co/v1/verify", + opaque_endpoint="https://attest.example.com/v1/verify", ) assert result.verified @@ -140,7 +140,7 @@ def test_opaque_endpoint_returns_unverified(monkeypatch): result = verify_opaque_measurement( "sha384:" + "a" * 96, b"\x00" * 64, - opaque_endpoint="https://attest.opaque.co/v1/verify", + opaque_endpoint="https://attest.example.com/v1/verify", ) assert not result.verified @@ -154,7 +154,7 @@ def test_opaque_network_error(monkeypatch): result = verify_opaque_measurement( "sha384:" + "a" * 96, b"\x00" * 64, - opaque_endpoint="https://attest.opaque.co/v1/verify", + opaque_endpoint="https://attest.example.com/v1/verify", ) assert result.verified From 202a8099559dfa2361eb282d2da2f0939effbc49 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 18 Jun 2026 11:29:17 -0700 Subject: [PATCH 2/2] fix: revert server.py to intended Content-Length-only change The previous commit accidentally included the full _attach_tool_evidence endpoint and ExternalExecutionEvidence imports from a draft implementation. This commit resets server.py to main and applies only the intended fix: guard int(content_length) against ValueError, returning 400 on malformed Content-Length headers. Signed-off-by: Imran Siddique Co-Authored-By: Claude Sonnet 4.6 --- src/cmcp_runtime/mcp/server.py | 124 --------------------------------- 1 file changed, 124 deletions(-) diff --git a/src/cmcp_runtime/mcp/server.py b/src/cmcp_runtime/mcp/server.py index d4fcbdb..3035f83 100644 --- a/src/cmcp_runtime/mcp/server.py +++ b/src/cmcp_runtime/mcp/server.py @@ -36,8 +36,6 @@ from cmcp_runtime.session.manager import SessionManager from cmcp_runtime.session.state import SessionState -from cmcp_runtime.audit.chain import ExternalExecutionEvidence, _validate_evidence_hash - logger = logging.getLogger(__name__) # Endpoints exempt from bearer-token auth (Kubernetes liveness / readiness probes) @@ -199,11 +197,6 @@ def __init__( methods=["POST"], ), Route("/catalog/exception", self._catalog_exception, methods=["POST"]), - Route( - "/sessions/{session_id}/tool-evidence/{call_id}", - self._attach_tool_evidence, - methods=["POST"], - ), ], middleware=middleware, exception_handlers={Exception: _unhandled_error_handler}, @@ -698,120 +691,3 @@ async def _session_reset(self, request: Request) -> Response: "status": "reset", "attestation_stale": False, }) - - async def _attach_tool_evidence(self, request: Request) -> Response: - """POST /sessions/{session_id}/tool-evidence/{call_id} - - Attach a signed external execution receipt to an existing tool-call audit - entry. The body must match ExternalExecutionEvidence. The signature is - stored verbatim; cryptographic verification is the verifier's responsibility - (cmcp-verify will check it if a matching public key is configured). - - Returns 200 {"call_id": } on success. - Returns 404 if the session_id or call_id is not found in the live chain. - Returns 422 if required fields are missing or evidence_hash is malformed. - """ - if self._audit_chain is None: - return JSONResponse( - {"error": "audit chain not available", "error_code": "NOT_CONFIGURED"}, - status_code=501, - ) - - session_id: str = request.path_params["session_id"] - call_id: str = request.path_params["call_id"] - - # Validate session scope: live session only. - if self._session is None or session_id != self._session.session_id: - # Also check closed sessions whose chains we still hold. - if session_id not in self._closed_chains: - return JSONResponse( - {"error": f"session_id={session_id} not found", "error_code": "SESSION_NOT_FOUND"}, - status_code=404, - ) - chain = self._closed_chains[session_id] - else: - chain = self._audit_chain - - try: - body = await request.body() - data = json.loads(body) - except (json.JSONDecodeError, UnicodeDecodeError): - return JSONResponse( - {"error": "invalid JSON body", "error_code": "PARSE_ERROR"}, - status_code=400, - ) - - if not isinstance(data, dict): - return JSONResponse( - {"error": "request body must be a JSON object", "error_code": "PARSE_ERROR"}, - status_code=400, - ) - - # Validate required fields. - required_fields = ("issuer", "issuer_key_id", "signature", "evidence_hash", "evidence_type", "linked_call_id") - missing = [f for f in required_fields if not data.get(f) or not isinstance(data[f], str)] - if missing: - return JSONResponse( - { - "error": f"missing or invalid required fields: {missing}", - "error_code": "MISSING_FIELD", - }, - status_code=422, - ) - - evidence_hash: str = data["evidence_hash"] - if not _validate_evidence_hash(evidence_hash): - return JSONResponse( - { - "error": "evidence_hash must be formatted as 'sha256:<64 hex chars>'", - "error_code": "INVALID_HASH_FORMAT", - }, - status_code=422, - ) - - # linked_call_id in the body must match the URL path parameter. - if data["linked_call_id"] != call_id: - return JSONResponse( - { - "error": "linked_call_id in body does not match call_id in URL", - "error_code": "CALL_ID_MISMATCH", - }, - status_code=422, - ) - - # Find the entry in the chain. - target = next( - (e for e in chain.entries if e.call_id == call_id and e.entry_type == "tool_call"), - None, - ) - if target is None: - return JSONResponse( - { - "error": f"no tool_call audit entry found for call_id={call_id} in session_id={session_id}", - "error_code": "CALL_NOT_FOUND", - }, - status_code=404, - ) - - evidence = ExternalExecutionEvidence( - issuer=data["issuer"], - issuer_key_id=data["issuer_key_id"], - signature=data["signature"], - evidence_hash=evidence_hash, - evidence_type=data["evidence_type"], - linked_call_id=call_id, - ) - target.external_execution_evidence = evidence - - # Persist the updated payload if a backing store is available. - if chain._store is not None: - chain._store.update_evidence(target) - - logger.info( - "EXTERNAL_EVIDENCE_ATTACHED: session_id=%s call_id=%s issuer=%s key_id=%s", - session_id, - call_id, - data["issuer"], - data["issuer_key_id"], - ) - return JSONResponse({"call_id": call_id}, status_code=200)