From b6acc35288d54224dd48178ed3437d80c161f309 Mon Sep 17 00:00:00 2001 From: meraklbz Date: Mon, 10 Aug 2026 19:56:41 +0800 Subject: [PATCH 1/2] fix(security): enforce authorization for listTasks onListTasks in AuthorizationRequestHandlerDecorator delegated directly without any authorization check, unlike every other handler method. Add a list-scoped read check before delegation. The provider is invoked with an empty-string sentinel for the whole list scope (a null task ID would break ConcurrentHashMap-backed providers with an NPE on lookup). Per-task checkRead filtering in the TaskStore list() remains unchanged. --- .../AuthorizationRequestHandlerDecorator.java | 21 +++++++++++++++++ ...horizationRequestHandlerDecoratorTest.java | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecorator.java b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecorator.java index 39a7b5925..67007aef0 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecorator.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecorator.java @@ -173,6 +173,25 @@ private void enforceRead(ServerCallContext context, String taskId, TaskOperation } } + /** + * List-scoped read check for {@code onListTasks}. + *

+ * {@code LIST_TASKS} has no single task ID, so the provider is invoked with an + * empty-string sentinel representing the whole list scope (a {@code null} task ID + * would break providers that key lookups on the task ID, e.g. + * {@code ConcurrentHashMap}-backed stores). Denying the check rejects the call + * outright; otherwise per-task {@code checkRead} filtering is applied by the + * {@code TaskStore} during {@code list()}. + */ + private static final String LIST_TASKS_SCOPE_ID = ""; + + private void enforceListRead(ServerCallContext context) throws A2AError { + if (authorizationProvider != null + && !authorizationProvider.checkRead(context, LIST_TASKS_SCOPE_ID, TaskOperation.LIST_TASKS)) { + throw new TaskNotFoundError(); + } + } + @Override public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws A2AError { enforceRead(context, params.id(), TaskOperation.GET_TASK); @@ -181,6 +200,8 @@ public Task onGetTask(TaskQueryParams params, ServerCallContext context) throws @Override public ListTasksResult onListTasks(ListTasksParams params, ServerCallContext context) throws A2AError { + // List-scoped read check; per-task filtering is additionally applied by the TaskStore. + enforceListRead(context); return delegate.onListTasks(params, context); } diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecoratorTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecoratorTest.java index 0ebad3f4c..c4d2f2791 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecoratorTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/requesthandlers/AuthorizationRequestHandlerDecoratorTest.java @@ -186,6 +186,29 @@ void onListTaskPushNotificationConfigs_denied() throws A2AError { verifyNoInteractions(delegate); } + @Test + void onListTasks_denied() throws A2AError { + ListTasksParams params = new ListTasksParams(); + when(authorizationProvider.checkRead(context, "", TaskOperation.LIST_TASKS)).thenReturn(false); + + assertThrows(TaskNotFoundError.class, () -> decorator.onListTasks(params, context)); + verifyNoInteractions(delegate); + } + + @Test + void onListTasks_allowed() throws A2AError { + ListTasksParams params = new ListTasksParams(); + ListTasksResult expected = new ListTasksResult(Collections.emptyList(), 0, 0, null); + when(authorizationProvider.checkRead(context, "", TaskOperation.LIST_TASKS)).thenReturn(true); + when(delegate.onListTasks(params, context)).thenReturn(expected); + + ListTasksResult result = decorator.onListTasks(params, context); + + assertEquals(expected, result); + verify(authorizationProvider).checkRead(context, "", TaskOperation.LIST_TASKS); + verify(delegate).onListTasks(params, context); + } + @Test void authorizeTaskAccess_allowed() throws A2AError { when(authorizationProvider.checkRead(context, "task-1", TaskOperation.SUBSCRIBE_TO_TASK)).thenReturn(true); From 7d36cc6f90926a5b60babcf9caafd11dfb500896 Mon Sep 17 00:00:00 2001 From: meraklbz Date: Tue, 11 Aug 2026 21:18:52 +0800 Subject: [PATCH 2/2] docs: document the list-scoped authorization check and empty-string sentinel The onListTasks bullet in TaskAuthorizationProvider's behavior Javadoc no longer reflects the decorator, which now performs a list-scoped checkRead before delegation in addition to the per-task TaskStore filtering. Also document the empty-string sentinel convention for checkRead with LIST_TASKS so providers know what to return for the list scope. --- .../server/auth/TaskAuthorizationProvider.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/server-common/src/main/java/org/a2aproject/sdk/server/auth/TaskAuthorizationProvider.java b/server-common/src/main/java/org/a2aproject/sdk/server/auth/TaskAuthorizationProvider.java index 6390a9314..f764073ec 100644 --- a/server-common/src/main/java/org/a2aproject/sdk/server/auth/TaskAuthorizationProvider.java +++ b/server-common/src/main/java/org/a2aproject/sdk/server/auth/TaskAuthorizationProvider.java @@ -61,8 +61,10 @@ *

  • {@code onMessageSend}, {@code onMessageSendStream} — call {@link #checkWrite} if an * existing task ID is provided, otherwise call {@link #checkCreate}; after the delegate * returns, call {@link #recordOwnership} if a new task was created
  • - *
  • {@code onListTasks} — filtering is pushed down to the {@code TaskStore}, which calls - * {@link #checkRead} per task to exclude unauthorized entries
  • + *
  • {@code onListTasks} — a list-scoped read check is performed before delegation + * (see {@link #checkRead}); after the delegate returns, per-task filtering is also + * pushed down to the {@code TaskStore}, which calls {@link #checkRead} for each task + * to exclude unauthorized entries
  • * * Denied operations throw {@code TaskNotFoundError} — the caller cannot distinguish * "does not exist" from "not authorized", preventing information leakage. @@ -105,8 +107,15 @@ public interface TaskAuthorizationProvider { /** * Check whether the current user is allowed to read the given task. * + *

    For {@link TaskOperation#LIST_TASKS}, {@code taskId} is an empty-string sentinel + * representing the whole list scope: the decorator calls this method once before + * delegation (deny rejects the entire list), and the {@code TaskStore} subsequently + * calls it per task during {@code list()} filtering. Providers should treat {@code ""} + * with {@code LIST_TASKS} as "may this user list tasks at all" — returning {@code false} + * hides all tasks.

    + * * @param context the server call context containing the authenticated user - * @param taskId the task being accessed + * @param taskId the task being accessed, or {@code ""} for the list scope of {@code LIST_TASKS} * @param operation which RequestHandler method triggered the check * @return {@code true} to allow, {@code false} to deny * @throws A2AError if the authorization check itself fails