diff --git a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java index 44e267cf7..f0349d03f 100644 --- a/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java +++ b/reference/jsonrpc/src/main/java/org/a2aproject/sdk/server/apps/quarkus/A2AServerRoutes.java @@ -79,6 +79,8 @@ import org.a2aproject.sdk.spec.UnsupportedOperationError; import org.a2aproject.sdk.transport.jsonrpc.handler.JSONRPCHandler; import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * Quarkus routing configuration for JSON-RPC A2A protocol requests. @@ -170,6 +172,8 @@ @Singleton public class A2AServerRoutes { + private static final Logger LOG = LoggerFactory.getLogger(A2AServerRoutes.class); + @Inject JSONRPCHandler jsonRpcHandler; @@ -337,7 +341,8 @@ public void invokeJSONRPCHandler(String body, RoutingContext rc) { } catch (JsonSyntaxException | JsonProcessingException e) { error = new A2AErrorResponse(new JSONParseError(e.getMessage())); } catch (Throwable t) { - error = new A2AErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + error = new A2AErrorResponse(new InternalError("Internal error")); } finally { if (error != null) { rc.response() diff --git a/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java b/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java index e68bf055c..8cd9f7af3 100644 --- a/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java +++ b/reference/rest/src/main/java/org/a2aproject/sdk/server/rest/quarkus/A2AServerRoutes.java @@ -54,6 +54,8 @@ import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.handler.BodyHandler; import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import static org.a2aproject.sdk.spec.A2AMethods.DELETE_TASK_PUSH_NOTIFICATION_CONFIG_METHOD; import static org.a2aproject.sdk.spec.A2AMethods.GET_EXTENDED_AGENT_CARD_METHOD; @@ -125,6 +127,8 @@ @Singleton public class A2AServerRoutes { + private static final Logger LOG = LoggerFactory.getLogger(A2AServerRoutes.class); + private static final String HISTORY_LENGTH_PARAM = "historyLength"; private static final String PAGE_SIZE_PARAM = "pageSize"; private static final String PAGE_TOKEN_PARAM = "pageToken"; @@ -306,7 +310,8 @@ public void sendMessage(String body, RoutingContext rc) { try { response = jsonRestHandler.sendMessage(context, extractTenant(rc), body); } catch (Throwable t) { - response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); } finally { sendResponse(rc, response); } @@ -424,7 +429,8 @@ public void listTasks(RoutingContext rc) { } catch (IllegalArgumentException e) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("Invalid parameter value: " + e.getMessage())); } catch (Throwable t) { - response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); } finally { sendResponse(rc, response); } @@ -458,7 +464,8 @@ public void getTask(RoutingContext rc) { } catch (NumberFormatException e) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad historyLength")); } catch (Throwable t) { - response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); } finally { sendResponse(rc, response); } @@ -492,7 +499,8 @@ public void cancelTask(String body, RoutingContext rc) { if (t instanceof A2AError error) { response = jsonRestHandler.createErrorResponse(error); } else { - response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); } } finally { sendResponse(rc, response); @@ -602,7 +610,8 @@ public void createTaskPushNotificationConfiguration(String body, RoutingContext response = jsonRestHandler.createTaskPushNotificationConfiguration(context, extractTenant(rc), body, taskId); } } catch (Throwable t) { - response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); } finally { sendResponse(rc, response); } @@ -633,7 +642,8 @@ public void getTaskPushNotificationConfiguration(RoutingContext rc) { response = jsonRestHandler.getTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, configId); } } catch (Throwable t) { - response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); } finally { sendResponse(rc, response); } @@ -678,7 +688,8 @@ public void listTaskPushNotificationConfigurations(RoutingContext rc) { } catch (NumberFormatException e) { response = jsonRestHandler.createErrorResponse(new InvalidParamsError("bad " + PAGE_SIZE_PARAM)); } catch (Throwable t) { - response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); } finally { sendResponse(rc, response); } @@ -711,7 +722,8 @@ public void deleteTaskPushNotificationConfiguration(RoutingContext rc) { response = jsonRestHandler.deleteTaskPushNotificationConfiguration(context, extractTenant(rc), taskId, configId); } } catch (Throwable t) { - response = jsonRestHandler.createErrorResponse(new InternalError(t.getMessage())); + LOG.error("Internal error while processing request", t); + response = jsonRestHandler.createErrorResponse(new InternalError("Internal error")); } finally { sendResponse(rc, response); } diff --git a/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java b/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java index 4b38ed6bd..3f739f182 100644 --- a/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java +++ b/transport/grpc/src/main/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandler.java @@ -13,6 +13,7 @@ import java.util.concurrent.Executor; import java.util.concurrent.Flow; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; import java.util.logging.Logger; import jakarta.enterprise.inject.Vetoed; @@ -826,7 +827,11 @@ private void handleSecurityException(StreamObserver responseObserver, Sec } private void handleInternalError(StreamObserver responseObserver, Throwable t) { - handleError(responseObserver, new InternalError(t.getMessage())); + // Log the full exception server-side but send only a generic message to the client: + // leaking internal exception messages can expose file paths, library + // names, and other implementation details that aid server fingerprinting (CWE-209). + LOGGER.log(Level.SEVERE, "Internal error while processing gRPC request", t); + handleError(responseObserver, new InternalError("Internal error")); } diff --git a/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java b/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java index 5adb8cdee..974d99e00 100644 --- a/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java +++ b/transport/grpc/src/test/java/org/a2aproject/sdk/transport/grpc/handler/GrpcHandlerTest.java @@ -651,6 +651,30 @@ public void testOnMessageStreamInternalError() throws Exception { assertGrpcError(streamRecorder, Status.Code.INTERNAL); } + @Test + public void testOnMessageInternalErrorIsSanitized() throws Exception { + // A non-A2AError exception must not leak its message to the client + DefaultRequestHandler mocked = Mockito.mock(DefaultRequestHandler.class); + Mockito.doThrow(new RuntimeException("sensitive detail: /var/lib/secret/config.db")) + .when(mocked).onMessageSend(Mockito.any(MessageSendParams.class), Mockito.any(ServerCallContext.class)); + GrpcHandler handler = new TestGrpcHandler(AbstractA2ARequestHandlerTest.CARD, mocked, internalExecutor); + + org.a2aproject.sdk.grpc.SendMessageRequest request = org.a2aproject.sdk.grpc.SendMessageRequest.newBuilder() + .setMessage(GRPC_MESSAGE) + .build(); + StreamRecorder responseObserver = StreamRecorder.create(); + handler.sendMessage(request, responseObserver); + responseObserver.awaitCompletion(5, TimeUnit.SECONDS); + + Assertions.assertNotNull(responseObserver.getError()); + Assertions.assertInstanceOf(StatusRuntimeException.class, responseObserver.getError()); + StatusRuntimeException sre = (StatusRuntimeException) responseObserver.getError(); + Assertions.assertEquals(Status.Code.INTERNAL, sre.getStatus().getCode()); + Assertions.assertEquals("Internal error", sre.getStatus().getDescription()); + Assertions.assertFalse(sre.getStatus().getDescription().contains("sensitive"), + "Internal exception message must not be leaked to the client"); + } + @Test public void testListPushNotificationConfig() throws Exception { GrpcHandler handler = new TestGrpcHandler(AbstractA2ARequestHandlerTest.CARD, requestHandler, internalExecutor); diff --git a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java index ec9fd3b85..3c0448179 100644 --- a/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java +++ b/transport/jsonrpc/src/main/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandler.java @@ -5,6 +5,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.Flow; +import java.util.logging.Level; +import java.util.logging.Logger; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Instance; @@ -132,6 +134,8 @@ @ApplicationScoped public class JSONRPCHandler { + private static final Logger LOGGER = Logger.getLogger(JSONRPCHandler.class.getName()); + // Fields set by constructor injection cannot be final. We need a noargs constructor for // Jakarta compatibility, and it seems that making fields set by constructor injection // final, is not proxyable in all runtimes @@ -236,7 +240,7 @@ public SendMessageResponse onMessageSend(SendMessageRequest request, ServerCallC } catch (A2AError e) { return new SendMessageResponse(request.getId(), e); } catch (Throwable t) { - return new SendMessageResponse(request.getId(), new InternalError(t.getMessage())); + return new SendMessageResponse(request.getId(), internalError(t)); } } @@ -296,7 +300,7 @@ public Flow.Publisher onMessageSendStream( } catch (A2AError e) { return ZeroPublisher.fromItems(new SendStreamingMessageResponse(request.getId(), e)); } catch (Throwable throwable) { - return ZeroPublisher.fromItems(new SendStreamingMessageResponse(request.getId(), new InternalError(throwable.getMessage()))); + return ZeroPublisher.fromItems(new SendStreamingMessageResponse(request.getId(), internalError(throwable))); } } @@ -335,7 +339,7 @@ public CancelTaskResponse onCancelTask(CancelTaskRequest request, ServerCallCont } catch (A2AError e) { return new CancelTaskResponse(request.getId(), e); } catch (Throwable t) { - return new CancelTaskResponse(request.getId(), new InternalError(t.getMessage())); + return new CancelTaskResponse(request.getId(), internalError(t)); } } @@ -392,7 +396,7 @@ public Flow.Publisher onSubscribeToTask( // Other A2AError types - wrap inline as part of the stream return ZeroPublisher.fromItems(new SendStreamingMessageResponse(request.getId(), e)); } catch (Throwable throwable) { - return ZeroPublisher.fromItems(new SendStreamingMessageResponse(request.getId(), new InternalError(throwable.getMessage()))); + return ZeroPublisher.fromItems(new SendStreamingMessageResponse(request.getId(), internalError(throwable))); } } @@ -433,7 +437,7 @@ public GetTaskPushNotificationConfigResponse getPushNotificationConfig( } catch (A2AError e) { return new GetTaskPushNotificationConfigResponse(request.getId(), e); } catch (Throwable t) { - return new GetTaskPushNotificationConfigResponse(request.getId(), new InternalError(t.getMessage())); + return new GetTaskPushNotificationConfigResponse(request.getId(), internalError(t)); } } @@ -475,7 +479,7 @@ public CreateTaskPushNotificationConfigResponse setPushNotificationConfig( } catch (A2AError e) { return new CreateTaskPushNotificationConfigResponse(request.getId(), e); } catch (Throwable t) { - return new CreateTaskPushNotificationConfigResponse(request.getId(), new InternalError(t.getMessage())); + return new CreateTaskPushNotificationConfigResponse(request.getId(), internalError(t)); } } @@ -510,7 +514,7 @@ public GetTaskResponse onGetTask(GetTaskRequest request, ServerCallContext conte } catch (A2AError e) { return new GetTaskResponse(request.getId(), e); } catch (Throwable t) { - return new GetTaskResponse(request.getId(), new InternalError(t.getMessage())); + return new GetTaskResponse(request.getId(), internalError(t)); } } @@ -557,7 +561,7 @@ public ListTasksResponse onListTasks(ListTasksRequest request, ServerCallContext } catch (A2AError e) { return new ListTasksResponse(request.getId(), e); } catch (Throwable t) { - return new ListTasksResponse(request.getId(), new InternalError(t.getMessage())); + return new ListTasksResponse(request.getId(), internalError(t)); } } @@ -598,7 +602,7 @@ public ListTaskPushNotificationConfigsResponse listPushNotificationConfigs( } catch (A2AError e) { return new ListTaskPushNotificationConfigsResponse(request.getId(), e); } catch (Throwable t) { - return new ListTaskPushNotificationConfigsResponse(request.getId(), new InternalError(t.getMessage())); + return new ListTaskPushNotificationConfigsResponse(request.getId(), internalError(t)); } } @@ -639,7 +643,7 @@ public DeleteTaskPushNotificationConfigResponse deletePushNotificationConfig( } catch (A2AError e) { return new DeleteTaskPushNotificationConfigResponse(request.getId(), e); } catch (Throwable t) { - return new DeleteTaskPushNotificationConfigResponse(request.getId(), new InternalError(t.getMessage())); + return new DeleteTaskPushNotificationConfigResponse(request.getId(), internalError(t)); } } @@ -681,7 +685,7 @@ public GetExtendedAgentCardResponse onGetExtendedCardRequest( } catch (A2AError e) { return new GetExtendedAgentCardResponse(request.getId(), e); } catch (Throwable t) { - return new GetExtendedAgentCardResponse(request.getId(), new InternalError(t.getMessage())); + return new GetExtendedAgentCardResponse(request.getId(), internalError(t)); } } @@ -728,8 +732,7 @@ public void onError(Throwable throwable) { } else { tube.send( new SendStreamingMessageResponse( - requestId, new - InternalError(throwable.getMessage()))); + requestId, internalError(throwable))); } onComplete(); } @@ -746,4 +749,20 @@ public void onComplete() { public void authorizeTaskAccess(String requestedTaskId, ServerCallContext context, TaskOperation operation) { requestHandler.authorizeTaskAccess(requestedTaskId, context, operation); } + + /** + * Builds a client-safe {@link InternalError} for an unexpected exception. + *

+ * The original exception (class, message, stack trace) is logged server-side but the + * client receives only a generic message: leaking internal exception messages can + * expose file paths, library names, and other implementation details that aid server + * fingerprinting (CWE-209). + * + * @param t the unexpected exception + * @return a sanitized internal error with a generic message + */ + private static InternalError internalError(Throwable t) { + LOGGER.log(Level.SEVERE, "Internal error while processing request", t); + return new InternalError("Internal error"); + } } diff --git a/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java b/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java index 6cd6deb99..42f09868a 100644 --- a/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java +++ b/transport/jsonrpc/src/test/java/org/a2aproject/sdk/transport/jsonrpc/handler/JSONRPCHandlerTest.java @@ -1,6 +1,7 @@ package org.a2aproject.sdk.transport.jsonrpc.handler; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -1188,6 +1189,25 @@ public void testOnMessageSendInternalError() { assertInstanceOf(InternalError.class, response.getError()); } + @Test + public void testOnMessageSendSanitizesUnexpectedException() { + // A non-A2AError exception must not leak its message to the client + DefaultRequestHandler mocked = Mockito.mock(DefaultRequestHandler.class); + Mockito.doThrow(new RuntimeException("sensitive detail: /var/lib/secret/config.db")) + .when(mocked) + .onMessageSend(Mockito.any(MessageSendParams.class), Mockito.any(ServerCallContext.class)); + + JSONRPCHandler handler = new JSONRPCHandler(CARD, mocked, internalExecutor); + + SendMessageRequest request = new SendMessageRequest("1", new MessageSendParams(MESSAGE, defaultConfiguration(), null)); + SendMessageResponse response = handler.onMessageSend(request, callContext); + + assertInstanceOf(InternalError.class, response.getError()); + assertEquals("Internal error", response.getError().getMessage()); + assertFalse(response.getError().getMessage().contains("sensitive"), + "Internal exception message must not be leaked to the client"); + } + @Test public void testOnMessageStreamInternalError() { DefaultRequestHandler mocked = Mockito.mock(DefaultRequestHandler.class); diff --git a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java index f4f59dc58..20ce9afb8 100644 --- a/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java +++ b/transport/rest/src/main/java/org/a2aproject/sdk/transport/rest/handler/RestHandler.java @@ -237,7 +237,7 @@ public HTTPRestResponse sendMessage(ServerCallContext context, String tenant, St } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { - return createErrorResponse(new InternalError(throwable.getMessage())); + return createErrorResponse(internalError(throwable)); } } @@ -311,7 +311,7 @@ public HTTPRestResponse sendStreamingMessage(ServerCallContext context, String t } catch (A2AError e) { return new HTTPRestStreamingResponse(ZeroPublisher.fromItems(new HTTPRestErrorResponse(e).toJson())); } catch (Throwable throwable) { - return new HTTPRestStreamingResponse(ZeroPublisher.fromItems(new HTTPRestErrorResponse(new InternalError(throwable.getMessage())).toJson())); + return new HTTPRestStreamingResponse(ZeroPublisher.fromItems(new HTTPRestErrorResponse(internalError(throwable)).toJson())); } } @@ -354,7 +354,7 @@ public HTTPRestResponse cancelTask(ServerCallContext context, String tenant, Str } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { - return createErrorResponse(new InternalError(throwable.getMessage())); + return createErrorResponse(internalError(throwable)); } } @@ -386,7 +386,7 @@ public HTTPRestResponse createTaskPushNotificationConfiguration(ServerCallContex } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { - return createErrorResponse(new InternalError(throwable.getMessage())); + return createErrorResponse(internalError(throwable)); } } @@ -438,7 +438,7 @@ public HTTPRestResponse subscribeToTask(ServerCallContext context, String tenant } catch (A2AError e) { return new HTTPRestStreamingResponse(ZeroPublisher.fromItems(new HTTPRestErrorResponse(e).toJson())); } catch (Throwable throwable) { - return new HTTPRestStreamingResponse(ZeroPublisher.fromItems(new HTTPRestErrorResponse(new InternalError(throwable.getMessage())).toJson())); + return new HTTPRestStreamingResponse(ZeroPublisher.fromItems(new HTTPRestErrorResponse(internalError(throwable)).toJson())); } } @@ -464,7 +464,7 @@ public HTTPRestResponse getTask(ServerCallContext context, String tenant, String } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { - return createErrorResponse(new InternalError(throwable.getMessage())); + return createErrorResponse(internalError(throwable)); } } @@ -566,7 +566,7 @@ public HTTPRestResponse listTasks(ServerCallContext context, String tenant, } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { - return createErrorResponse(new InternalError(throwable.getMessage())); + return createErrorResponse(internalError(throwable)); } } @@ -590,7 +590,7 @@ public HTTPRestResponse getTaskPushNotificationConfiguration(ServerCallContext c } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { - return createErrorResponse(new InternalError(throwable.getMessage())); + return createErrorResponse(internalError(throwable)); } } @@ -615,7 +615,7 @@ public HTTPRestResponse listTaskPushNotificationConfigurations(ServerCallContext } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { - return createErrorResponse(new InternalError(throwable.getMessage())); + return createErrorResponse(internalError(throwable)); } } @@ -639,7 +639,7 @@ public HTTPRestResponse deleteTaskPushNotificationConfiguration(ServerCallContex } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable throwable) { - return createErrorResponse(new InternalError(throwable.getMessage())); + return createErrorResponse(internalError(throwable)); } } @@ -671,7 +671,8 @@ private HTTPRestResponse createSuccessResponse(int statusCode, com.google.protob String jsonBody = ProtoJsonUtils.toJson(JsonFormat.printer().alwaysPrintFieldsWithNoPresence(), builder); return new HTTPRestResponse(statusCode, APPLICATION_JSON, jsonBody); } catch (InvalidProtocolBufferException e) { - return createErrorResponse(new InternalError("Failed to serialize response: " + e.getMessage())); + log.log(Level.SEVERE, "Failed to serialize response", e); + return createErrorResponse(new InternalError("Internal error")); } } @@ -691,6 +692,22 @@ private HTTPRestResponse createErrorResponse(int statusCode, A2AError error) { return new HTTPRestResponse(statusCode, APPLICATION_JSON, jsonBody); } + /** + * Builds a client-safe {@link InternalError} for an unexpected exception. + *

+ * The original exception (class, message, stack trace) is logged server-side but the + * client receives only a generic message: leaking internal exception messages can + * expose file paths, library names, and other implementation details that aid server + * fingerprinting (CWE-209). + * + * @param t the unexpected exception + * @return a sanitized internal error with a generic message + */ + private static InternalError internalError(Throwable t) { + log.log(Level.SEVERE, "Internal error while processing request", t); + return new InternalError("Internal error"); + } + private HTTPRestStreamingResponse createStreamingResponse(Flow.Publisher publisher) { return new HTTPRestStreamingResponse(convertToSendStreamingMessageResponse(publisher)); } @@ -744,7 +761,7 @@ public void onError(Throwable throwable) { if (throwable instanceof A2AError jsonrpcError) { tube.send(new HTTPRestErrorResponse(jsonrpcError).toJson()); } else { - tube.send(new HTTPRestErrorResponse(new InternalError(throwable.getMessage())).toJson()); + tube.send(new HTTPRestErrorResponse(internalError(throwable)).toJson()); } onComplete(); } @@ -807,7 +824,7 @@ public HTTPRestResponse getExtendedAgentCard(ServerCallContext context, String t } catch (A2AError e) { return createErrorResponse(e); } catch (Throwable t) { - return createErrorResponse(500, new InternalError(t.getMessage())); + return createErrorResponse(500, internalError(t)); } } @@ -851,7 +868,7 @@ public HTTPRestResponse getAgentCard() { return new HTTPRestResponse(200, APPLICATION_JSON, JsonUtil.toJson(agentCard), cacheMetadata.getHttpHeadersMap()); } catch (Throwable t) { - return createErrorResponse(500, new InternalError(t.getMessage())); + return createErrorResponse(500, internalError(t)); } } diff --git a/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java b/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java index 890b59e53..26e24f7b0 100644 --- a/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java +++ b/transport/rest/src/test/java/org/a2aproject/sdk/transport/rest/handler/RestHandlerTest.java @@ -23,6 +23,7 @@ import org.a2aproject.sdk.server.auth.UnauthenticatedUser; import org.a2aproject.sdk.server.config.DefaultValuesConfigProvider; import org.a2aproject.sdk.server.requesthandlers.AbstractA2ARequestHandlerTest; +import org.a2aproject.sdk.server.requesthandlers.RequestHandler; import org.a2aproject.sdk.spec.AgentCapabilities; import org.a2aproject.sdk.spec.AgentCard; import org.a2aproject.sdk.spec.AgentExtension; @@ -31,6 +32,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.mockito.Mockito; @Timeout(value = 1, unit = TimeUnit.MINUTES) public class RestHandlerTest extends AbstractA2ARequestHandlerTest { @@ -1095,4 +1097,33 @@ private static void assertProblemDetail(RestHandler.HTTPRestResponse response, Assertions.assertEquals(expectedReason, detail.get("reason").getAsString(), "reason field mismatch"); Assertions.assertEquals("a2a-protocol.org", detail.get("domain").getAsString(), "domain field mismatch"); } + + @Test + public void testSendMessageSanitizesInternalError() { + // A non-A2AError exception must not leak its message to the client + RequestHandler mocked = Mockito.mock(RequestHandler.class); + Mockito.doThrow(new RuntimeException("sensitive detail: /var/lib/secret/config.db")) + .when(mocked).onMessageSend(Mockito.any(), Mockito.any()); + + RestHandler handler = new RestHandler(CARD, createCacheMetadata(), mocked, internalExecutor); + String requestBody = """ + { + "message": { + "messageId": "message-1234", + "contextId": "context-1234", + "role": "ROLE_USER", + "parts": [{"text": "hello"}], + "metadata": {} + } + }"""; + + RestHandler.HTTPRestResponse response = handler.sendMessage(callContext, "", requestBody); + + JsonObject body = JsonParser.parseString(response.getBody()).getAsJsonObject(); + JsonObject error = body.getAsJsonObject("error"); + Assertions.assertEquals(500, error.get("code").getAsInt()); + Assertions.assertEquals("Internal error", error.get("message").getAsString()); + Assertions.assertFalse(error.get("message").getAsString().contains("sensitive"), + "Internal exception message must not be leaked to the client"); + } }