From 0a40d3f407807fe26a2a010b53df2f4ecaa50817 Mon Sep 17 00:00:00 2001 From: Houyun Sun <18763177732@139.com> Date: Thu, 5 Nov 2026 05:37:58 +0800 Subject: [PATCH] shared: return None (not ValueError) for a non-ASCII base64 header decode_header_value only caught binascii.Error, but base64.b64decode raises a plain ValueError for a non-ASCII payload ("string argument should contain only ASCII characters"). A raw Mcp-Name like `=?base64?\xe9?=` therefore escaped the modern POST handler and turned into a 500 instead of the HEADER_MISMATCH rejection the docstring promises. Same for Mcp-Param-* headers, which go through the same function. Catching ValueError covers both cases since binascii.Error subclasses it. Checked with a small ASGI repro: the high-byte header now answers 400 / -32020 like every other undecodable sentinel, and the fuzzer-shaped input no longer reaches the server's error path. Fixes #3817 --- src/mcp/shared/inbound.py | 5 ++++- tests/server/test_streamable_http_modern.py | 18 ++++++++++++++++++ tests/shared/test_inbound.py | 11 ++++++++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index e33d6f502f..a6673c5886 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -176,7 +176,10 @@ def decode_header_value(value: str | None) -> str | None: payload = m.group("payload") try: decoded = base64.b64decode(payload, validate=True) - except binascii.Error: + except (binascii.Error, ValueError): + # b64decode raises binascii.Error for bad alphabet/padding but a plain + # ValueError for non-ASCII text ("string argument should contain only + # ASCII characters"), so a raw header with high bytes must be caught too. return None # Reject non-canonical base64 (e.g. non-zero trailing bits), which # `validate=True` tolerates; the encoder only ever emits canonical form. diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index a539720d58..011673e284 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -444,6 +444,24 @@ async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and assert response.json()["error"]["code"] == HEADER_MISMATCH +async def test_handle_modern_request_rejects_non_ascii_base64_name_header_with_400() -> None: + """A `=?base64?...?=` payload holding raw high bytes is malformed, not a server fault: it must come + back as HEADER_MISMATCH, the same as any other undecodable sentinel, instead of escaping the + handler as a 500.""" + body = _list_tools_body() + body["method"] = "tools/call" + body["params"]["name"] = "real" + body["params"]["arguments"] = {} + async with _asgi_client(Server("test")) as http: + response = await http.post( + "/mcp", + json=body, + headers=[(MCP_METHOD_HEADER, "tools/call"), (MCP_NAME_HEADER.encode(), b"=?base64?\xe9?=")], + ) + assert response.status_code == 400 + assert response.json()["error"]["code"] == HEADER_MISMATCH + + # --- SSE response mode --------------------------------------------------------- diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 561eff24b0..4c74b2d66b 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -422,7 +422,16 @@ def test_decode_header_value_passes_none_and_plain_through() -> None: assert decode_header_value("plain") == "plain" -@pytest.mark.parametrize("bad", ["=?base64?not base64!?=", "=?base64?gA==?="]) +@pytest.mark.parametrize( + "bad", + [ + "=?base64?not base64!?=", + "=?base64?gA==?=", + # Raw header bytes that are not ASCII at all: b64decode rejects these with + # ValueError rather than binascii.Error, and they used to escape as a 500. + "=?base64?\xe9?=", + ], +) def test_decode_header_value_returns_none_for_malformed_sentinel(bad: str) -> None: """SDK-defined: a sentinel with bad base64 or bad UTF-8 decodes to `None`, so it can never match a body value.""" assert decode_header_value(bad) is None