The {@code scheme} and {@code credentials} are client-controlled values that are + * concatenated directly into the header. Rejecting CR/LF characters here prevents + * HTTP header injection (CWE-113). The {@link A2AHttpClient} SPI is pluggable, so we + * cannot rely on every implementation (or the underlying HTTP client) to validate + * header values.
+ * + * @param scheme the authentication scheme + * @param credentials the authentication credentials + * @return the assembled {@code "scheme credentials"} header value + * @throws IllegalArgumentException if either field contains CR or LF + */ + private static String buildAuthorizationHeader(String scheme, String credentials) { + rejectCrlf(scheme, "Authorization scheme"); + rejectCrlf(credentials, "Authorization credentials"); + return scheme + " " + credentials; + } + + /** + * Throws {@link IllegalArgumentException} if {@code value} contains CR or LF. + * + *Prevents HTTP header injection (CWE-113) for client-controlled header values.
+ * + * @param value non-null string to validate + * @param label human-readable description of the field, used in the exception message + */ + private static void rejectCrlf(String value, String label) { + if (value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0) { + throw new IllegalArgumentException( + label + " must not contain CR/LF characters"); + } + } } diff --git a/server-common/src/test/java/org/a2aproject/sdk/server/tasks/PushNotificationSenderTest.java b/server-common/src/test/java/org/a2aproject/sdk/server/tasks/PushNotificationSenderTest.java index 87226524d..2bf4a531f 100644 --- a/server-common/src/test/java/org/a2aproject/sdk/server/tasks/PushNotificationSenderTest.java +++ b/server-common/src/test/java/org/a2aproject/sdk/server/tasks/PushNotificationSenderTest.java @@ -15,6 +15,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; +import java.util.stream.Stream; import org.a2aproject.sdk.client.http.A2AHttpClient; import org.a2aproject.sdk.client.http.A2AHttpResponse; @@ -23,6 +24,7 @@ import org.a2aproject.sdk.jsonrpc.common.json.JsonProcessingException; import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil; import org.a2aproject.sdk.spec.Artifact; +import org.a2aproject.sdk.spec.AuthenticationInfo; import org.a2aproject.sdk.spec.Message; import org.a2aproject.sdk.spec.StreamingEventKind; import org.a2aproject.sdk.spec.Task; @@ -35,6 +37,9 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; public class PushNotificationSenderTest { @@ -516,4 +521,71 @@ public void testSendNotificationSkipsWhenFormatterReturnsNull() throws Interrupt assertTrue(testHttpClient.rawBodies.isEmpty()); } + + @Test + public void testSendNotificationRejectsCrlfInToken() { + String taskId = "task_send_crlf_token"; + Task taskData = createSampleTask(taskId, TaskState.TASK_STATE_COMPLETED); + TaskPushNotificationConfig config = createSamplePushConfig(taskId, "http://notify.me/here", "cfg-crlf-token", + "token\r\nX-Injected: 1"); + configStore.setInfo(config); + + // No latch needed: sendNotification() calls dispatchResult.get(), which blocks until + // all CompletableFuture dispatches complete, including the CRLF rejection path. + sender.sendNotification(taskData, null); + + assertTrue(testHttpClient.events.isEmpty(), "Notification with CRLF token must not be dispatched"); + assertTrue(testHttpClient.headers.isEmpty(), "No headers should have been sent"); + assertTrue(testHttpClient.rawBodies.isEmpty(), "No body should have been sent"); + } + + @Test + public void testSendNotificationWithAuthHeader() throws InterruptedException { + String taskId = "task_send_auth"; + Task taskData = createSampleTask(taskId, TaskState.TASK_STATE_COMPLETED); + TaskPushNotificationConfig config = TaskPushNotificationConfig.builder() + .url("http://notify.me/here") + .id("cfg-auth") + .taskId(taskId) + .authentication(new AuthenticationInfo("Bearer", "token123")) + .build(); + configStore.setInfo(config); + + testHttpClient.latch = new CountDownLatch(1); + sender.sendNotification(taskData, null); + + assertTrue(testHttpClient.latch.await(5, TimeUnit.SECONDS), "HTTP call should complete within 5 seconds"); + assertEquals(1, testHttpClient.events.size()); + assertEquals(1, testHttpClient.headers.size()); + Map