Skip to content

Commit d26887c

Browse files
scottdhughesclaude
andcommitted
security: harden MCP server against 6 pentest findings
Four-model adversarial pentest (Codex, Sonnet, Claude, Qwen) found input validation, policy bypass, and replay race issues in the MCP server. This commit addresses all 6 findings plus 3 additional issues caught during Sonnet's post-fix review. Fixes: - Add pre-dispatch _validate_arguments() with type checks and size limits (1MB plaintext, 100KB keys) to prevent server crashes from malformed MCP arguments (envelope=[], plaintext=None, name=42) - Broaden exception handling: catch TypeError, AttributeError, KeyError, RuntimeError, OSError in hybrid dispatch; catch RuntimeError/OSError in import guards for graceful degradation - Close PQC_REQUIRE_KEY_HANDLES bypass: key_store_save now rejects key_data containing secret_key fields when policy is active - Add 100KB size limit on key_data in key_store_save - Fix replay cache TOCTOU: replace split check()/mark() with atomic check_and_mark() after verify+decrypt - Reject bool values for integer fields (bool is subclass of int) 25 new tests covering all fixes. 301 total tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a2cd1b8 commit d26887c

4 files changed

Lines changed: 444 additions & 12 deletions

File tree

pqc_mcp_server/__init__.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@
2929
import oqs # noqa: F401
3030

3131
HAS_LIBOQS = True
32-
except ImportError:
32+
except (ImportError, RuntimeError, OSError):
3333
HAS_LIBOQS = False
3434

3535
try:
3636
from cryptography.exceptions import InvalidTag
3737
from pqc_mcp_server.hybrid import SenderVerificationError
3838

3939
HAS_HYBRID = True
40-
except ImportError:
40+
except (ImportError, RuntimeError, OSError):
4141
HAS_HYBRID = False
4242

4343
class InvalidTag(Exception): # type: ignore[no-redef]
@@ -119,6 +119,99 @@ class SenderVerificationError(Exception): # type: ignore[no-redef]
119119
"pqc_key_store_delete": handle_key_store_delete,
120120
}
121121

122+
# --- Pre-dispatch input validation ---
123+
124+
# Size limits (bytes/chars)
125+
_MAX_PLAINTEXT_SIZE = 1_048_576 # 1 MB
126+
_MAX_MESSAGE_SIZE = 1_048_576 # 1 MB
127+
_MAX_KEY_FIELD_SIZE = 102_400 # 100 KB
128+
129+
# Expected types by field name
130+
_STRING_FIELDS = frozenset(
131+
{
132+
"algorithm",
133+
"public_key",
134+
"secret_key",
135+
"ciphertext",
136+
"message",
137+
"signature",
138+
"plaintext",
139+
"plaintext_base64",
140+
"name",
141+
"store_as",
142+
"key_store_name",
143+
"classical_public_key",
144+
"pqc_public_key",
145+
"classical_secret_key",
146+
"pqc_secret_key",
147+
"x25519_ephemeral_public_key",
148+
"pqc_ciphertext",
149+
"sender_secret_key",
150+
"sender_public_key",
151+
"expected_sender_public_key",
152+
"expected_sender_fingerprint",
153+
"sender_key_store_name",
154+
"recipient_key_store_name",
155+
"type",
156+
}
157+
)
158+
_DICT_FIELDS = frozenset({"envelope", "key_data"})
159+
_BOOL_FIELDS = frozenset({"overwrite", "include_secret_key"})
160+
_INT_FIELDS = frozenset({"iterations", "max_age_seconds"})
161+
162+
_SIZE_LIMITS: dict[str, int] = {
163+
"plaintext": _MAX_PLAINTEXT_SIZE,
164+
"plaintext_base64": _MAX_PLAINTEXT_SIZE,
165+
"message": _MAX_MESSAGE_SIZE,
166+
"public_key": _MAX_KEY_FIELD_SIZE,
167+
"secret_key": _MAX_KEY_FIELD_SIZE,
168+
"classical_public_key": _MAX_KEY_FIELD_SIZE,
169+
"pqc_public_key": _MAX_KEY_FIELD_SIZE,
170+
"classical_secret_key": _MAX_KEY_FIELD_SIZE,
171+
"pqc_secret_key": _MAX_KEY_FIELD_SIZE,
172+
"sender_secret_key": _MAX_KEY_FIELD_SIZE,
173+
"sender_public_key": _MAX_KEY_FIELD_SIZE,
174+
"ciphertext": _MAX_PLAINTEXT_SIZE,
175+
"x25519_ephemeral_public_key": _MAX_KEY_FIELD_SIZE,
176+
"pqc_ciphertext": _MAX_KEY_FIELD_SIZE,
177+
"expected_sender_public_key": _MAX_KEY_FIELD_SIZE,
178+
"signature": _MAX_PLAINTEXT_SIZE,
179+
}
180+
181+
182+
def _validate_arguments(arguments: dict[str, Any]) -> None:
183+
"""Validate argument types and sizes before handler dispatch.
184+
185+
Raises ValueError on type mismatch or size violation.
186+
Unknown fields pass through silently (future-proof).
187+
"""
188+
if not isinstance(arguments, dict):
189+
raise ValueError("arguments must be a JSON object")
190+
191+
for key, value in arguments.items():
192+
if value is None:
193+
continue # Optional fields may be absent/null
194+
195+
if key in _STRING_FIELDS:
196+
if not isinstance(value, str):
197+
raise ValueError(f"Parameter '{key}' must be a string, got {type(value).__name__}")
198+
limit = _SIZE_LIMITS.get(key)
199+
if limit is not None and len(value) > limit:
200+
raise ValueError(f"Parameter '{key}' exceeds size limit ({limit} chars)")
201+
elif key in _DICT_FIELDS:
202+
if not isinstance(value, dict):
203+
raise ValueError(
204+
f"Parameter '{key}' must be a JSON object, got {type(value).__name__}"
205+
)
206+
elif key in _BOOL_FIELDS:
207+
if not isinstance(value, bool):
208+
raise ValueError(f"Parameter '{key}' must be a boolean, got {type(value).__name__}")
209+
elif key in _INT_FIELDS:
210+
# bool is a subclass of int in Python — reject it explicitly
211+
if isinstance(value, bool) or not isinstance(value, (int, float)):
212+
raise ValueError(f"Parameter '{key}' must be a number, got {type(value).__name__}")
213+
214+
122215
# --- MCP Server ---
123216

124217
server = Server("pqc-mcp-server")
@@ -135,6 +228,12 @@ async def list_tools() -> list[Tool]:
135228

136229
@server.call_tool()
137230
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
231+
# Pre-dispatch input validation (type checks + size limits)
232+
try:
233+
_validate_arguments(arguments)
234+
except (ValueError, TypeError) as e:
235+
return _json_response({"error": str(e)})
236+
138237
if not HAS_LIBOQS:
139238
return _json_response(
140239
{
@@ -172,6 +271,12 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
172271
return _json_response(
173272
{"error": "Decryption failed: ciphertext, key, or envelope metadata is invalid"}
174273
)
274+
except (TypeError, AttributeError, KeyError) as e:
275+
return _json_response({"error": f"Invalid argument type: {e}"})
276+
except RuntimeError as e:
277+
return _json_response({"error": f"Internal error: {e}"})
278+
except OSError as e:
279+
return _json_response({"error": f"I/O error: {e}"})
175280

176281
return _json_response({"error": f"Unknown tool: {name}"})
177282

pqc_mcp_server/handlers_hybrid.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -202,16 +202,16 @@ def handle_hybrid_auth_open(arguments: dict[str, Any]) -> dict[str, Any]:
202202

203203
_validate_envelope_size(envelope)
204204

205-
# Replay dedup: check BEFORE verification, mark AFTER success.
206-
# CONCURRENCY NOTE: Two concurrent opens of the same valid envelope can
207-
# both pass check() before either calls mark(). This is a documented
208-
# tradeoff for single-process async — avoids junk pre-blocking at the
209-
# cost of not guaranteeing one-time acceptance under concurrency.
210-
# For multi-process, add a lock or two-phase reserve/finalize pattern.
205+
# Replay dedup: compute digest early (cheap SHA3-256), but check_and_mark
206+
# runs AFTER verify+decrypt. This prevents junk pre-blocking (invalid
207+
# envelopes never enter cache) while closing the TOCTOU window between
208+
# the old separate check()/mark() calls. Safety guarantee: no `await`
209+
# exists in this handler's call path (all crypto is synchronous liboqs),
210+
# so the event loop cannot preempt between digest computation and
211+
# check_and_mark. If an async operation is ever added here, this
212+
# guarantee must be re-evaluated.
211213
cache = get_replay_cache()
212214
digest = signature_digest(envelope)
213-
if cache.check(digest):
214-
raise ValueError("Duplicate envelope (replay detected)")
215215

216216
classical_sk, pqc_sk = _resolve_hybrid_secret(arguments)
217217
expected_pk = (
@@ -240,8 +240,12 @@ def handle_hybrid_auth_open(arguments: dict[str, Any]) -> dict[str, Any]:
240240
kwargs["max_age_seconds"] = max_age_int
241241
result = hybrid_auth_open(envelope, classical_sk, pqc_sk, **kwargs)
242242

243-
# Mark AFTER successful verify+decrypt — only verified envelopes enter cache
244-
cache.mark(digest)
243+
# Atomic check+mark AFTER successful verify+decrypt. Only verified
244+
# envelopes enter the cache. Closes the TOCTOU window: under concurrent
245+
# async, only the first coroutine to reach check_and_mark succeeds.
246+
if cache.check_and_mark(digest):
247+
raise ValueError("Duplicate envelope (replay detected)")
248+
245249
return result
246250

247251

pqc_mcp_server/key_store.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@
77

88
import base64
99
import hashlib
10+
import json as _json
1011
from typing import Any
1112

13+
_MAX_KEY_DATA_SIZE = 102_400 # 100 KB
14+
1215
# Module-level store (lives for the server's lifetime)
1316
_STORE: dict[str, dict[str, Any]] = {}
1417

@@ -87,6 +90,27 @@ def _require_flat_kem(keys: dict[str, Any], name: str) -> None:
8790
)
8891

8992

93+
def _reject_secret_fields(key_data: dict[str, Any]) -> None:
94+
"""Reject key_data containing secret_key fields when handle-only policy is active.
95+
96+
Checks flat key structures and hybrid bundle sub-dicts.
97+
"""
98+
if "secret_key" in key_data:
99+
raise ValueError(
100+
"key_store_save rejected: key_data contains 'secret_key'. "
101+
"PQC_REQUIRE_KEY_HANDLES policy prohibits importing raw secrets. "
102+
"Use pqc_generate_keypair with store_as or pqc_hybrid_keygen with store_as."
103+
)
104+
for component in ("classical", "pqc"):
105+
sub = key_data.get(component)
106+
if isinstance(sub, dict) and "secret_key" in sub:
107+
raise ValueError(
108+
f"key_store_save rejected: key_data['{component}'] contains 'secret_key'. "
109+
"PQC_REQUIRE_KEY_HANDLES policy prohibits importing raw secrets. "
110+
"Use pqc_hybrid_keygen with store_as to generate keys as opaque handles."
111+
)
112+
113+
90114
def handle_key_store_save(arguments: dict[str, Any]) -> dict[str, Any]:
91115
"""Save a keygen output by name."""
92116
name = arguments["name"]
@@ -95,6 +119,17 @@ def handle_key_store_save(arguments: dict[str, Any]) -> dict[str, Any]:
95119
if not isinstance(key_data, dict):
96120
raise ValueError("key_data must be a JSON object (e.g., output of pqc_hybrid_keygen)")
97121

122+
# Policy enforcement: reject raw secrets when handle-only mode is active
123+
from pqc_mcp_server.security_policy import get_policy
124+
125+
if get_policy().require_key_handles:
126+
_reject_secret_fields(key_data)
127+
128+
# Size limit on key_data (prevents memory exhaustion via oversized dicts)
129+
key_data_size = len(_json.dumps(key_data))
130+
if key_data_size > _MAX_KEY_DATA_SIZE:
131+
raise ValueError(f"key_data is {key_data_size} bytes (max {_MAX_KEY_DATA_SIZE})")
132+
98133
entry = {
99134
"name": name,
100135
"key_data": key_data,

0 commit comments

Comments
 (0)