From 8f0c71935ed591a9c34128361b20a47c7f11bffd Mon Sep 17 00:00:00 2001 From: meraklbz Date: Mon, 10 Aug 2026 20:01:09 +0800 Subject: [PATCH 1/2] fix: validate task ID length and message content --- .../default_request_handler.py | 12 ++ .../default_request_handler_v2.py | 12 ++ src/a2a/utils/task.py | 56 +++++++++- .../test_default_request_handler.py | 104 ++++++++++++++++++ .../test_default_request_handler_v2.py | 104 ++++++++++++++++++ tests/utils/test_task.py | 48 ++++++++ 6 files changed, 335 insertions(+), 1 deletion(-) diff --git a/src/a2a/server/request_handlers/default_request_handler.py b/src/a2a/server/request_handlers/default_request_handler.py index ef61dcca7..e31c4b684 100644 --- a/src/a2a/server/request_handlers/default_request_handler.py +++ b/src/a2a/server/request_handlers/default_request_handler.py @@ -61,7 +61,9 @@ from a2a.utils.task import ( apply_history_length, validate_history_length, + validate_message_content, validate_page_size, + validate_task_id, ) from a2a.utils.telemetry import SpanKind, trace_class @@ -146,6 +148,7 @@ async def on_get_task( ) -> Task | None: """Default handler for 'tasks/get'.""" validate_history_length(params) + validate_task_id(params.id) task_id = params.id task: Task | None = await self.task_store.get(task_id, context) @@ -187,6 +190,7 @@ async def on_cancel_task( Attempts to cancel the task managed by the `AgentExecutor`. """ task_id = params.id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError @@ -265,6 +269,9 @@ async def _setup_message_execution( # Create task manager and validate existing task # Proto empty strings should be treated as None task_id = params.message.task_id or None + if task_id: + validate_task_id(task_id) + validate_message_content(params.message) context_id = params.message.context_id or None task_manager = TaskManager( task_id=task_id, @@ -523,6 +530,7 @@ async def on_create_task_push_notification_config( raise PushNotificationNotSupportedError task_id = params.task_id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError @@ -555,6 +563,7 @@ async def on_get_task_push_notification_config( task_id = params.task_id config_id = params.id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError @@ -585,6 +594,7 @@ async def on_subscribe_to_task( Requires the task and its queue to still be active. """ task_id = params.id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError @@ -635,6 +645,7 @@ async def on_list_task_push_notification_configs( raise PushNotificationNotSupportedError task_id = params.task_id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError @@ -667,6 +678,7 @@ async def on_delete_task_push_notification_config( task_id = params.task_id config_id = params.id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError diff --git a/src/a2a/server/request_handlers/default_request_handler_v2.py b/src/a2a/server/request_handlers/default_request_handler_v2.py index 872a3bfa2..8ebb35f2e 100644 --- a/src/a2a/server/request_handlers/default_request_handler_v2.py +++ b/src/a2a/server/request_handlers/default_request_handler_v2.py @@ -49,7 +49,9 @@ from a2a.utils.task import ( apply_history_length, validate_history_length, + validate_message_content, validate_page_size, + validate_task_id, ) from a2a.utils.telemetry import SpanKind, trace_class @@ -128,6 +130,7 @@ async def on_get_task( # noqa: D102 context: ServerCallContext, ) -> Task | None: validate_history_length(params) + validate_task_id(params.id) task_id = params.id task: Task | None = await self.task_store.get(task_id, context) @@ -164,6 +167,7 @@ async def on_cancel_task( # noqa: D102 context: ServerCallContext, ) -> Task | None: task_id = params.id + validate_task_id(task_id) try: active_task = await self._active_task_registry.get_or_create( @@ -197,6 +201,9 @@ async def _setup_active_task( validate_history_length(params.configuration) original_task_id = params.message.task_id or None + if original_task_id: + validate_task_id(original_task_id) + validate_message_content(params.message) original_context_id = params.message.context_id or None if original_task_id: @@ -341,6 +348,7 @@ async def on_create_task_push_notification_config( # noqa: D102 raise PushNotificationNotSupportedError task_id = params.task_id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError @@ -369,6 +377,7 @@ async def on_get_task_push_notification_config( # noqa: D102 task_id = params.task_id config_id = params.id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError @@ -394,6 +403,7 @@ async def on_subscribe_to_task( # noqa: D102 context: ServerCallContext, ) -> AsyncGenerator[Event, None]: task_id = params.id + validate_task_id(task_id) active_task = await self._active_task_registry.get_or_create( task_id, @@ -419,6 +429,7 @@ async def on_list_task_push_notification_configs( # noqa: D102 raise PushNotificationNotSupportedError task_id = params.task_id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError @@ -447,6 +458,7 @@ async def on_delete_task_push_notification_config( # noqa: D102 task_id = params.task_id config_id = params.id + validate_task_id(task_id) task: Task | None = await self.task_store.get(task_id, context) if not task: raise TaskNotFoundError diff --git a/src/a2a/utils/task.py b/src/a2a/utils/task.py index 4acf54e46..a1c0fa560 100644 --- a/src/a2a/utils/task.py +++ b/src/a2a/utils/task.py @@ -5,11 +5,65 @@ from base64 import b64decode, b64encode from typing import Literal, Protocol, runtime_checkable -from a2a.types.a2a_pb2 import Task +from a2a.types.a2a_pb2 import Message, Part, Task from a2a.utils.constants import MAX_LIST_TASKS_PAGE_SIZE from a2a.utils.errors import InvalidParamsError +MAX_TASK_ID_LENGTH = 1000 +"""Maximum allowed length of a task ID.""" + + +def validate_task_id(task_id: str) -> None: + """Validates that a task ID is non-empty and within the length limit. + + Raises: + InvalidParamsError: If the task ID is empty or longer than + ``MAX_TASK_ID_LENGTH`` characters. + """ + if not task_id: + raise InvalidParamsError(message='task ID must be non-empty') + if len(task_id) > MAX_TASK_ID_LENGTH: + raise InvalidParamsError( + message=f'task ID must be at most {MAX_TASK_ID_LENGTH} characters' + ) + + +def validate_message_content(message: Message) -> None: + """Validates that a message carries actual content. + + A message must contain at least one part, and every part must have + content (text, raw bytes, a URL or data) rather than being empty. + + Raises: + InvalidParamsError: If the message has no parts or contains an + empty part. + """ + if not message.parts: + raise InvalidParamsError( + message='message must contain at least one part' + ) + for part in message.parts: + if not _part_has_content(part): + raise InvalidParamsError(message='message parts must not be empty') + + +def _part_has_content(part: Part) -> bool: + """Returns True if a part carries actual content. + + A part has content if it has text, raw bytes or a URL, or a + non-null ``data`` payload (``google.protobuf.Value`` defaults to + null/empty). + """ + if part.text or part.raw or part.url: + return True + return ( + part.data.WhichOneof('kind') not in (None, 'null_value') + if part.HasField('data') + else False + ) + + @runtime_checkable class HistoryLengthConfig(Protocol): """Protocol for configuration arguments containing history_length field.""" diff --git a/tests/server/request_handlers/test_default_request_handler.py b/tests/server/request_handlers/test_default_request_handler.py index 727679e7c..79d8d0902 100644 --- a/tests/server/request_handlers/test_default_request_handler.py +++ b/tests/server/request_handlers/test_default_request_handler.py @@ -3143,3 +3143,107 @@ async def test_on_get_task_push_notification_config_is_owner_scoped( ), _ctx('bob'), ) + + +@pytest.mark.asyncio +async def test_on_get_task_overlong_task_id_error(agent_card): + """A task ID longer than the limit is rejected with InvalidParamsError.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandler( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=agent_card, + ) + params = GetTaskRequest(id='a' * 1001) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError): + await request_handler.on_get_task(params, context) + mock_task_store.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_cancel_task_overlong_task_id_error(agent_card): + """A task ID longer than the limit is rejected before touching the store.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandler( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=agent_card, + ) + params = CancelTaskRequest(id='a' * 1001) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError): + await request_handler.on_cancel_task(params, context) + mock_task_store.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_message_send_empty_parts_error(agent_card): + """A message with no parts is rejected with InvalidParamsError.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandler( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=agent_card, + ) + params = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id='msg1', + parts=[], + ), + ) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError): + await request_handler.on_message_send(params, context) + mock_task_store.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_message_send_empty_part_error(agent_card): + """A message whose only part is empty is rejected with InvalidParamsError.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandler( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=agent_card, + ) + params = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id='msg1', + parts=[Part(media_type='text/plain')], + ), + ) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError) as exc_info: + await request_handler.on_message_send(params, context) + + assert 'not be empty' in exc_info.value.message + + +@pytest.mark.asyncio +async def test_on_message_send_overlong_task_id_error(agent_card): + """A message targeting an over-long task ID is rejected.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandler( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=agent_card, + ) + params = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id='msg1', + task_id='a' * 1001, + parts=[Part(text='hello')], + ), + ) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError): + await request_handler.on_message_send(params, context) diff --git a/tests/server/request_handlers/test_default_request_handler_v2.py b/tests/server/request_handlers/test_default_request_handler_v2.py index b276fb77a..b075b4a79 100644 --- a/tests/server/request_handlers/test_default_request_handler_v2.py +++ b/tests/server/request_handlers/test_default_request_handler_v2.py @@ -1746,3 +1746,107 @@ async def test_aclose_is_idempotent_and_handles_empty(): await handler.aclose() await handler.aclose() + + +@pytest.mark.asyncio +async def test_on_get_task_overlong_task_id_error(): + """A task ID longer than the limit is rejected with InvalidParamsError.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandlerV2( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=create_default_agent_card(), + ) + params = GetTaskRequest(id='a' * 1001) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError): + await request_handler.on_get_task(params, context) + mock_task_store.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_cancel_task_overlong_task_id_error(): + """A task ID longer than the limit is rejected before touching the store.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandlerV2( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=create_default_agent_card(), + ) + params = CancelTaskRequest(id='a' * 1001) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError): + await request_handler.on_cancel_task(params, context) + mock_task_store.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_message_send_empty_parts_error(): + """A message with no parts is rejected with InvalidParamsError.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandlerV2( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=create_default_agent_card(), + ) + params = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id='msg1', + parts=[], + ), + ) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError): + await request_handler.on_message_send(params, context) + mock_task_store.get.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_on_message_send_empty_part_error(): + """A message whose only part is empty is rejected with InvalidParamsError.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandlerV2( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=create_default_agent_card(), + ) + params = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id='msg1', + parts=[Part(media_type='text/plain')], + ), + ) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError) as exc_info: + await request_handler.on_message_send(params, context) + + assert 'not be empty' in exc_info.value.message + + +@pytest.mark.asyncio +async def test_on_message_send_overlong_task_id_error(): + """A message targeting an over-long task ID is rejected.""" + mock_task_store = AsyncMock(spec=TaskStore) + request_handler = DefaultRequestHandlerV2( + agent_executor=AsyncMock(spec=AgentExecutor), + task_store=mock_task_store, + agent_card=create_default_agent_card(), + ) + params = SendMessageRequest( + message=Message( + role=Role.ROLE_USER, + message_id='msg1', + task_id='a' * 1001, + parts=[Part(text='hello')], + ), + ) + context = create_server_call_context() + + with pytest.raises(InvalidParamsError): + await request_handler.on_message_send(params, context) diff --git a/tests/utils/test_task.py b/tests/utils/test_task.py index 8124955d1..1e53d9174 100644 --- a/tests/utils/test_task.py +++ b/tests/utils/test_task.py @@ -14,9 +14,12 @@ ) from a2a.utils.errors import InvalidParamsError from a2a.utils.task import ( + MAX_TASK_ID_LENGTH, apply_history_length, decode_page_token, encode_page_token, + validate_message_content, + validate_task_id, ) @@ -89,5 +92,50 @@ def test_zero_history_length_returns_empty_history(self): self.assertEqual(len(result.history), 0) +class TestValidateTaskId(unittest.TestCase): + def test_valid_task_id_passes(self): + # Does not raise + validate_task_id('task-123') + validate_task_id('a' * MAX_TASK_ID_LENGTH) + + def test_empty_task_id_raises(self): + with pytest.raises(InvalidParamsError) as excinfo: + validate_task_id('') + assert 'non-empty' in str(excinfo.value) + + def test_overlong_task_id_raises(self): + with pytest.raises(InvalidParamsError) as excinfo: + validate_task_id('a' * (MAX_TASK_ID_LENGTH + 1)) + assert str(MAX_TASK_ID_LENGTH) in str(excinfo.value) + + +class TestValidateMessageContent(unittest.TestCase): + def _message(self, parts: list[Part]) -> Message: + return Message( + message_id='m1', role=Role.ROLE_USER, parts=parts + ) + + def test_message_with_text_part_passes(self): + validate_message_content(self._message([Part(text='hello')])) + + def test_message_with_url_part_passes(self): + validate_message_content(self._message([Part(url='http://x.com/f')])) + + def test_message_with_data_part_passes(self): + part = Part() + part.data.string_value = 'x' + validate_message_content(self._message([part])) + + def test_message_without_parts_raises(self): + with pytest.raises(InvalidParamsError) as excinfo: + validate_message_content(self._message([])) + assert 'at least one part' in str(excinfo.value) + + def test_message_with_empty_part_raises(self): + with pytest.raises(InvalidParamsError) as excinfo: + validate_message_content(self._message([Part(media_type='text')])) + assert 'not be empty' in str(excinfo.value) + + if __name__ == '__main__': unittest.main() From fe58ececa844f7fece222bd44b2731b4d290a4a9 Mon Sep 17 00:00:00 2001 From: meraklbz Date: Tue, 11 Aug 2026 00:39:34 +0800 Subject: [PATCH 2/2] style: apply ruff formatting --- tests/utils/test_task.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/utils/test_task.py b/tests/utils/test_task.py index 1e53d9174..b1ab92606 100644 --- a/tests/utils/test_task.py +++ b/tests/utils/test_task.py @@ -111,9 +111,7 @@ def test_overlong_task_id_raises(self): class TestValidateMessageContent(unittest.TestCase): def _message(self, parts: list[Part]) -> Message: - return Message( - message_id='m1', role=Role.ROLE_USER, parts=parts - ) + return Message(message_id='m1', role=Role.ROLE_USER, parts=parts) def test_message_with_text_part_passes(self): validate_message_content(self._message([Part(text='hello')]))