Skip to content

Commit 0a40d3f

Browse files
committed
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
1 parent af4c6f4 commit 0a40d3f

3 files changed

Lines changed: 32 additions & 2 deletions

File tree

‎src/mcp/shared/inbound.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,10 @@ def decode_header_value(value: str | None) -> str | None:
176176
payload = m.group("payload")
177177
try:
178178
decoded = base64.b64decode(payload, validate=True)
179-
except binascii.Error:
179+
except (binascii.Error, ValueError):
180+
# b64decode raises binascii.Error for bad alphabet/padding but a plain
181+
# ValueError for non-ASCII text ("string argument should contain only
182+
# ASCII characters"), so a raw header with high bytes must be caught too.
180183
return None
181184
# Reject non-canonical base64 (e.g. non-zero trailing bits), which
182185
# `validate=True` tolerates; the encoder only ever emits canonical form.

‎tests/server/test_streamable_http_modern.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,24 @@ async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and
444444
assert response.json()["error"]["code"] == HEADER_MISMATCH
445445

446446

447+
async def test_handle_modern_request_rejects_non_ascii_base64_name_header_with_400() -> None:
448+
"""A `=?base64?...?=` payload holding raw high bytes is malformed, not a server fault: it must come
449+
back as HEADER_MISMATCH, the same as any other undecodable sentinel, instead of escaping the
450+
handler as a 500."""
451+
body = _list_tools_body()
452+
body["method"] = "tools/call"
453+
body["params"]["name"] = "real"
454+
body["params"]["arguments"] = {}
455+
async with _asgi_client(Server("test")) as http:
456+
response = await http.post(
457+
"/mcp",
458+
json=body,
459+
headers=[(MCP_METHOD_HEADER, "tools/call"), (MCP_NAME_HEADER.encode(), b"=?base64?\xe9?=")],
460+
)
461+
assert response.status_code == 400
462+
assert response.json()["error"]["code"] == HEADER_MISMATCH
463+
464+
447465
# --- SSE response mode ---------------------------------------------------------
448466

449467

‎tests/shared/test_inbound.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,16 @@ def test_decode_header_value_passes_none_and_plain_through() -> None:
422422
assert decode_header_value("plain") == "plain"
423423

424424

425-
@pytest.mark.parametrize("bad", ["=?base64?not base64!?=", "=?base64?gA==?="])
425+
@pytest.mark.parametrize(
426+
"bad",
427+
[
428+
"=?base64?not base64!?=",
429+
"=?base64?gA==?=",
430+
# Raw header bytes that are not ASCII at all: b64decode rejects these with
431+
# ValueError rather than binascii.Error, and they used to escape as a 500.
432+
"=?base64?\xe9?=",
433+
],
434+
)
426435
def test_decode_header_value_returns_none_for_malformed_sentinel(bad: str) -> None:
427436
"""SDK-defined: a sentinel with bad base64 or bad UTF-8 decodes to `None`, so it can never match a body value."""
428437
assert decode_header_value(bad) is None

0 commit comments

Comments
 (0)