Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions server-common/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.a2aproject.sdk.client.http.A2AHttpClient;
import org.a2aproject.sdk.client.http.A2AHttpClientFactory;
import org.a2aproject.sdk.jsonrpc.common.json.JsonUtil;
import org.a2aproject.sdk.spec.AuthenticationInfo;
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsParams;
import org.a2aproject.sdk.spec.ListTaskPushNotificationConfigsResult;
import org.a2aproject.sdk.spec.Message;
Expand Down Expand Up @@ -194,11 +195,27 @@ private boolean dispatchNotification(StreamingEventKind event,

A2AHttpClient.PostBuilder postBuilder = httpClient.createPost();
if (token != null && !token.isBlank()) {
try {
rejectCrlf(token, X_A2A_NOTIFICATION_TOKEN);
} catch (IllegalArgumentException e) {
LOGGER.warn("Rejecting push notification to {}: {}", url, e.getMessage());
return false;
}
postBuilder.addHeader(X_A2A_NOTIFICATION_TOKEN, token);
}
if (pushInfo.authentication() != null && pushInfo.authentication().credentials() != null) {
postBuilder.addHeader("Authorization",
pushInfo.authentication().scheme() + " " + pushInfo.authentication().credentials());
AuthenticationInfo authentication = pushInfo.authentication();
if (authentication != null) {
String credentials = authentication.credentials();
if (credentials != null) {
String authorizationHeader;
try {
authorizationHeader = buildAuthorizationHeader(authentication.scheme(), credentials);
} catch (IllegalArgumentException e) {
LOGGER.warn("Rejecting push notification to {}: {}", url, e.getMessage());
return false;
}
postBuilder.addHeader("Authorization", authorizationHeader);
}
}

try {
Expand All @@ -213,4 +230,39 @@ private boolean dispatchNotification(StreamingEventKind event,
}
return true;
}

/**
* Builds the Authorization header value for a push notification config.
*
* <p>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.</p>
*
* @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.
*
* <p>Prevents HTTP header injection (CWE-113) for client-controlled header values.</p>
*
* @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");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {

Expand Down Expand Up @@ -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<String, String> sentHeaders = testHttpClient.headers.get(0);
assertEquals("Bearer token123", sentHeaders.get("Authorization"));
}

static Stream<Arguments> crlfAuthVectors() {
return Stream.of(
Arguments.of("CRLF in credentials", "Bearer", "token\r\nX-Injected: 1"),
Arguments.of("LF in scheme", "Bearer\nX-Injected: 1", "token"),
Arguments.of("bare CR in credentials", "Bearer", "token\rX-Injected: 1"),
Arguments.of("bare CR in scheme", "Bearer\rX-Injected: 1", "token"));
}

@ParameterizedTest(name = "{0}")
@MethodSource("crlfAuthVectors")
public void testSendNotificationRejectsCrlfInAuthFields(String description, String scheme, String credentials) {
String taskId = "task_send_crlf_auth";
Task taskData = createSampleTask(taskId, TaskState.TASK_STATE_COMPLETED);
TaskPushNotificationConfig config = TaskPushNotificationConfig.builder()
.url("http://notify.me/here")
.id("cfg-crlf")
.taskId(taskId)
.authentication(new AuthenticationInfo(scheme, credentials))
.build();
configStore.setInfo(config);

sender.sendNotification(taskData, null);

assertTrue(testHttpClient.events.isEmpty(), "Notification with " + description + " must not be dispatched");
assertTrue(testHttpClient.headers.isEmpty(), "No headers should have been sent");
assertTrue(testHttpClient.rawBodies.isEmpty(), "No body should have been sent");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@


import org.a2aproject.sdk.util.Assert;
import org.jspecify.annotations.Nullable;

/**
* Authentication information for agent authentication and push notification endpoints.
Expand All @@ -21,7 +22,7 @@
* @see SecurityScheme for security scheme definitions
* @see <a href="https://a2a-protocol.org/latest/">A2A Protocol Specification</a>
*/
public record AuthenticationInfo(String scheme, String credentials) {
public record AuthenticationInfo(String scheme, @Nullable String credentials) {

/**
* Compact constructor that validates required fields.
Expand Down
Loading