Skip to content

Commit 56ec855

Browse files
fix(server): treat request bodies that are not valid UTF-8 as parse errors (#1401)
# Description A request body that is not valid UTF-8 is reported as a server fault: JSON-RPC answers `-32603` Internal Error (logged with a traceback at ERROR), and REST `message:send`, `message:stream` and `pushNotificationConfigs` (v1 and v0.3) answer 500. Malformed JSON already gets `-32700` / 400. `request.json()` and protobuf's `Parse(bytes)` raise `UnicodeDecodeError` before any JSON error, so neither path catches it. The JSON-RPC dispatcher now catches `UnicodeDecodeError` with `JSONDecodeError`, and `parse_lenient` turns invalid UTF-8 bytes into `ParseError`; the REST handlers use it instead of calling `Parse` directly (all of them already passed `ignore_unknown_fields=True`). Valid bodies parse exactly as before. Not covered: v0.3 `message:stream` returns an empty 200 stream for any malformed body, including plain bad JSON; that's a separate, existing problem. - New tests (JSON-RPC, v1 REST x3, v0.3 REST x2, `parse_lenient`) fail on main and pass with the fix. - `ruff check`, `ruff format --check`, `ty check` (no new diagnostics), and `uv run pytest --cov=src` on Linux pass. - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/a2aproject/a2a-python/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - [x] Ensure the tests and linter pass - [ ] Appropriate docs were updated (not needed) Co-authored-by: Mateusz Iwaniuk <45974414+Iwaniukooo11@users.noreply.github.com>
1 parent 6168d59 commit 56ec855

8 files changed

Lines changed: 70 additions & 10 deletions

File tree

‎src/a2a/compat/v0_3/rest_handler.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import AsyncIterator
44
from typing import TYPE_CHECKING, Any
55

6-
from google.protobuf.json_format import MessageToDict, Parse
6+
from google.protobuf.json_format import MessageToDict
77

88

99
if TYPE_CHECKING:
@@ -29,6 +29,7 @@
2929
from a2a.server.context import ServerCallContext
3030
from a2a.utils import constants
3131
from a2a.utils.errors import InvalidParamsError
32+
from a2a.utils.proto_utils import parse_lenient
3233
from a2a.utils.telemetry import SpanKind, trace_class
3334
from a2a.utils.version_validator import validate_version
3435

@@ -68,7 +69,7 @@ async def on_message_send(
6869
"""
6970
body = await request.body()
7071
v03_pb_msg = pb2_v03.SendMessageRequest()
71-
Parse(body, v03_pb_msg, ignore_unknown_fields=True)
72+
parse_lenient(body, v03_pb_msg)
7273
v03_params_msg = proto_utils.FromProto.message_send_params(v03_pb_msg)
7374
rpc_req = types_v03.SendMessageRequest(id='', params=v03_params_msg)
7475

@@ -94,7 +95,7 @@ async def on_message_send_stream(
9495
"""
9596
body = await request.body()
9697
v03_pb_msg = pb2_v03.SendMessageRequest()
97-
Parse(body, v03_pb_msg, ignore_unknown_fields=True)
98+
parse_lenient(body, v03_pb_msg)
9899
v03_params_msg = proto_utils.FromProto.message_send_params(v03_pb_msg)
99100
rpc_req = types_v03.SendMessageRequest(id='', params=v03_params_msg)
100101

@@ -212,7 +213,7 @@ async def set_push_notification(
212213
body = await request.body()
213214

214215
v03_pb_push = pb2_v03.CreateTaskPushNotificationConfigRequest()
215-
Parse(body, v03_pb_push, ignore_unknown_fields=True)
216+
parse_lenient(body, v03_pb_push)
216217

217218
v03_params_push = (
218219
proto_utils.FromProto.task_push_notification_config_request(

‎src/a2a/server/routes/jsonrpc_dispatcher.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ async def handle_requests(self, request: Request) -> Response: # noqa: PLR0911,
340340
except A2AError as e:
341341
handler_result = build_error_response(request_id, e)
342342
return self._create_response(call_context, handler_result)
343-
except json.decoder.JSONDecodeError as e:
343+
except (json.decoder.JSONDecodeError, UnicodeDecodeError) as e:
344344
return self._generate_error_response(
345345
None, JSONParseError(message=str(e))
346346
)

‎src/a2a/server/routes/rest_dispatcher.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from collections.abc import AsyncIterator, Awaitable, Callable
44
from typing import TYPE_CHECKING, Any, TypeVar
55

6-
from google.protobuf.json_format import MessageToDict, Parse
6+
from google.protobuf.json_format import MessageToDict
77

88
from a2a.server.context import ServerCallContext
99
from a2a.server.request_handlers.request_handler import RequestHandler
@@ -180,7 +180,7 @@ async def _handler(
180180
) -> a2a_pb2.SendMessageResponse:
181181
body = await request.body()
182182
params = a2a_pb2.SendMessageRequest()
183-
Parse(body, params, ignore_unknown_fields=True)
183+
proto_utils.parse_lenient(body, params)
184184
task_or_message = await self.request_handler.on_message_send(
185185
params, context
186186
)
@@ -203,7 +203,7 @@ async def _handler(
203203
) -> AsyncIterator[dict[str, Any]]:
204204
body = await request.body()
205205
params = a2a_pb2.SendMessageRequest()
206-
Parse(body, params, ignore_unknown_fields=True)
206+
proto_utils.parse_lenient(body, params)
207207
async for event in self.request_handler.on_message_send_stream(
208208
params, context
209209
):
@@ -315,7 +315,7 @@ async def _handler(
315315
) -> a2a_pb2.TaskPushNotificationConfig:
316316
body = await request.body()
317317
params = a2a_pb2.TaskPushNotificationConfig()
318-
Parse(body, params, ignore_unknown_fields=True)
318+
proto_utils.parse_lenient(body, params)
319319
params.task_id = request.path_params['id']
320320
return await self.request_handler.on_create_task_push_notification_config(
321321
params, context

‎src/a2a/utils/proto_utils.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,17 @@ def parse_lenient(
6767
6868
Returns:
6969
The parsed message.
70+
71+
Raises:
72+
ParseError: If the data is not valid ProtoJSON, including bytes that
73+
are not valid UTF-8.
7074
"""
71-
if isinstance(data, str | bytes):
75+
if isinstance(data, bytes):
76+
try:
77+
data = data.decode('utf-8')
78+
except UnicodeDecodeError as e:
79+
raise ParseError(f'Failed to load JSON: {e}.') from e
80+
if isinstance(data, str):
7281
return Parse(data, message, ignore_unknown_fields=True)
7382
return ParseDict(data, message, ignore_unknown_fields=True)
7483

‎tests/compat/v0_3/test_rest_routes_compat.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,22 @@ async def test_get_task_v03_non_integer_history_length(
210210
request_handler.on_get_task.assert_not_called()
211211

212212

213+
@pytest.mark.anyio
214+
@pytest.mark.parametrize(
215+
'path',
216+
['/v1/message:send', '/v1/tasks/test_task_id/pushNotificationConfigs'],
217+
)
218+
async def test_invalid_utf8_body_v03_is_bad_request(
219+
client: AsyncClient, path: str
220+
) -> None:
221+
response = await client.post(
222+
path, content=b'{"message": {"messageId": "\xff"}}'
223+
)
224+
225+
assert response.status_code == 400
226+
assert response.json()['error']['status'] == 'INVALID_ARGUMENT'
227+
228+
213229
@pytest.mark.anyio
214230
async def test_list_tasks_not_in_v03_adapter_routes(
215231
request_handler: RequestHandler,

‎tests/server/routes/test_jsonrpc_dispatcher.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,16 @@ def test_parse_diagnostic_survives_in_metadata(self, parse_error):
194194

195195

196196
class TestMalformedJsonBody:
197+
def test_invalid_utf8_is_a_parse_error(self, client):
198+
response = client.post(
199+
'/',
200+
content=b'{"jsonrpc": "2.0", "id": 1, "method": "GetTask", '
201+
b'"params": {"id": "\xff"}}',
202+
headers={'Content-Type': 'application/json'},
203+
)
204+
205+
assert response.json()['error']['code'] == -32700
206+
197207
def test_reported_through_logging_not_stderr(self, client, capsys, caplog):
198208
response = client.post(
199209
'/',

‎tests/server/routes/test_rest_routes.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,25 @@ def test_rest_endpoints_routing(agent_card, mock_handler):
5959
assert mock_handler.on_message_send.called
6060

6161

62+
@pytest.mark.parametrize(
63+
'path',
64+
['/message:send', '/message:stream', '/tasks/t1/pushNotificationConfigs'],
65+
)
66+
def test_rest_invalid_utf8_body_is_bad_request(mock_handler, path):
67+
"""A body that is not UTF-8 is malformed JSON, so 400 rather than 500."""
68+
app = Starlette(routes=create_rest_routes(request_handler=mock_handler))
69+
client = TestClient(app)
70+
71+
response = client.post(
72+
path,
73+
content=b'{"message": {"messageId": "\xff"}}',
74+
headers={'A2A-Version': '1.0'},
75+
)
76+
77+
assert response.status_code == 400
78+
assert response.json()['error']['status'] == 'INVALID_ARGUMENT'
79+
80+
6281
def test_rest_endpoints_routing_tenant(agent_card, mock_handler):
6382
"""Tests that mounted routes with {tenant} route to the handler endpoints."""
6483
mock_handler.on_message_send.return_value = Task(id='123')

‎tests/utils/test_proto_utils.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,3 +477,8 @@ def test_malformed_known_field_still_raises(self) -> None:
477477
"""Leniency covers unknown names, not a known field of the wrong type."""
478478
with pytest.raises(ParseError):
479479
proto_utils.parse_lenient({'messageId': 5}, Message())
480+
481+
def test_invalid_utf8_bytes_raise_parse_error(self) -> None:
482+
"""Bytes that are not UTF-8 are malformed JSON, not an internal error."""
483+
with pytest.raises(ParseError):
484+
proto_utils.parse_lenient(b'{"messageId": "\xff"}', Message())

0 commit comments

Comments
 (0)