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
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ public class GrpcTransport implements ClientTransport {
AuthInterceptor.AUTHORIZATION,
Metadata.ASCII_STRING_MARSHALLER);
private static final Metadata.Key<String> EXTENSIONS_KEY = Metadata.Key.of(
A2AHeaders.X_A2A_EXTENSIONS,
A2AHeaders.A2A_EXTENSIONS.toLowerCase(),
Metadata.ASCII_STRING_MARSHALLER);
private static final Metadata.Key<String> VERSION_KEY = Metadata.Key.of(
A2AHeaders.X_A2A_VERSION,
A2AHeaders.A2A_VERSION.toLowerCase(),
Metadata.ASCII_STRING_MARSHALLER);
private final A2AServiceBlockingV2Stub blockingStub;
private final A2AServiceStub asyncStub;
Expand Down Expand Up @@ -380,8 +380,8 @@ private io.a2a.grpc.SendMessageRequest createGrpcSendMessageRequest(MessageSendP

/**
* Creates gRPC metadata from ClientCallContext headers.
* Extracts headers like X-A2A-Extensions and sets them as gRPC metadata.
*
* Extracts headers like a2a-extensions and sets them as gRPC metadata.
* The headers are lower-cased (compared to the HTTP headers).
* @param context the client call context containing headers, may be null
* @param payloadAndHeaders the payload and headers wrapper, may be null
* @return the gRPC metadata
Expand All @@ -390,14 +390,14 @@ private Metadata createGrpcMetadata(@Nullable ClientCallContext context, @Nullab
Metadata metadata = new Metadata();

if (context != null && context.getHeaders() != null) {
// Set X-A2A-Version header if present
String versionHeader = context.getHeaders().get(A2AHeaders.X_A2A_VERSION);
// Set a2a-version header if present
String versionHeader = context.getHeaders().get(A2AHeaders.A2A_VERSION.toLowerCase());
if (versionHeader != null) {
metadata.put(VERSION_KEY, versionHeader);
}

// Set X-A2A-Extensions header if present
String extensionsHeader = context.getHeaders().get(A2AHeaders.X_A2A_EXTENSIONS);
// Set a2a-extensions header if present
String extensionsHeader = context.getHeaders().get(A2AHeaders.A2A_EXTENSIONS.toLowerCase());
if (extensionsHeader != null) {
metadata.put(EXTENSIONS_KEY, extensionsHeader);
}
Comment on lines +393 to 403
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Retrieving headers from the ClientCallContext using lowercase keys is fragile. HTTP header names are case-insensitive, and it's common for users to provide them with standard casing (e.g., A2A-Version). The current implementation would fail to find such headers, leading to them being silently ignored.

To make this more robust, it's better to iterate through the headers and perform a case-insensitive comparison.

Suggested change
// Set a2a-version header if present
String versionHeader = context.getHeaders().get(A2AHeaders.A2A_VERSION.toLowerCase());
if (versionHeader != null) {
metadata.put(VERSION_KEY, versionHeader);
}
// Set X-A2A-Extensions header if present
String extensionsHeader = context.getHeaders().get(A2AHeaders.X_A2A_EXTENSIONS);
// Set a2a-extensions header if present
String extensionsHeader = context.getHeaders().get(A2AHeaders.A2A_EXTENSIONS.toLowerCase());
if (extensionsHeader != null) {
metadata.put(EXTENSIONS_KEY, extensionsHeader);
}
// Set a2a-version and a2a-extensions headers if present, ignoring case
for (Map.Entry<String, String> header : context.getHeaders().entrySet()) {
if (A2AHeaders.A2A_VERSION.equalsIgnoreCase(header.getKey())) {
metadata.put(VERSION_KEY, header.getValue());
} else if (A2AHeaders.A2A_EXTENSIONS.equalsIgnoreCase(header.getKey())) {
metadata.put(EXTENSIONS_KEY, header.getValue());
}
}

Expand Down
4 changes: 2 additions & 2 deletions common/src/main/java/io/a2a/common/A2AHeaders.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ public final class A2AHeaders {
* HTTP header name for A2A protocol version.
* Used to communicate the protocol version that the client is using.
*/
public static final String X_A2A_VERSION = "X-A2A-Version";
public static final String A2A_VERSION = "A2A-Version";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

This header name contains uppercase letters, which are not allowed in gRPC metadata keys. In gRPC Java, the Metadata.Key.of() method strictly validates that the name is composed only of lowercase alphanumeric characters, hyphens, underscores, or periods. Using this constant in gRPC metadata will cause an IllegalArgumentException at runtime. It is recommended to use lowercase names for all A2A protocol headers to ensure compatibility across all transports, including gRPC, while remaining valid for HTTP-based transports (REST and JSON-RPC) which are case-insensitive.

Suggested change
public static final String A2A_VERSION = "A2A-Version";
public static final String A2A_VERSION = "a2a-version";

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/**
* HTTP header name for A2A extensions.
* Used to communicate which extensions are requested by the client.
*/
public static final String X_A2A_EXTENSIONS = "X-A2A-Extensions";
public static final String A2A_EXTENSIONS = "A2A-Extensions";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

This header name contains uppercase letters, which are not allowed in gRPC metadata keys. Using this constant in Metadata.Key.of() will cause an IllegalArgumentException at runtime. It is recommended to use lowercase names for all A2A protocol headers to ensure compatibility across all transports, including gRPC.

Suggested change
public static final String A2A_EXTENSIONS = "A2A-Extensions";
public static final String A2A_EXTENSIONS = "a2a-extensions";

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini, you are wrong considering the following documentation https://grpc.github.io/grpc-java/javadoc/io/grpc/Metadata.Key.html


/**
* HTTP header name for a push notification token.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
*
* <h2>Captured Information</h2>
* <ul>
* <li><b>A2A Protocol Version</b>: {@code X-A2A-Version} header</li>
* <li><b>A2A Extensions</b>: {@code X-A2A-Extensions} header</li>
* <li><b>A2A Protocol Version</b>: {@code a2a-version} header</li>
* <li><b>A2A Extensions</b>: {@code a2a-extensions} header</li>
* <li><b>Complete Metadata</b>: All request headers via {@link io.grpc.Metadata}</li>
* <li><b>Method Name</b>: gRPC method being invoked</li>
* <li><b>Peer Information</b>: Client connection details</li>
Expand Down Expand Up @@ -60,6 +60,13 @@
@ApplicationScoped
public class A2AExtensionsInterceptor implements ServerInterceptor {

private static final Metadata.Key<String> EXTENSIONS_KEY = Metadata.Key.of(
A2AHeaders.A2A_EXTENSIONS.toLowerCase(),
Metadata.ASCII_STRING_MARSHALLER);
private static final Metadata.Key<String> VERSION_KEY = Metadata.Key.of(
A2AHeaders.A2A_VERSION.toLowerCase(),
Metadata.ASCII_STRING_MARSHALLER);

/**
* Intercepts incoming gRPC calls to capture metadata and context information.
*
Expand All @@ -68,8 +75,8 @@ public class A2AExtensionsInterceptor implements ServerInterceptor {
*
* <p><b>Extraction Process:</b>
* <ol>
* <li>Extract {@code X-A2A-Version} header from metadata</li>
* <li>Extract {@code X-A2A-Extensions} header from metadata</li>
* <li>Extract {@code a2a-version} header from metadata</li>
* <li>Extract {@code a2a-extensions} header from metadata</li>
* <li>Capture complete {@link Metadata} object</li>
* <li>Capture gRPC method name from {@link ServerCall}</li>
* <li>Map gRPC method to A2A protocol method name</li>
Expand All @@ -92,14 +99,9 @@ public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
ServerCallHandler<ReqT, RespT> serverCallHandler) {

// Extract A2A protocol version header
Metadata.Key<String> versionKey =
Metadata.Key.of(A2AHeaders.X_A2A_VERSION, Metadata.ASCII_STRING_MARSHALLER);
String version = metadata.get(versionKey);

String version = metadata.get(VERSION_KEY);
// Extract A2A extensions header
Metadata.Key<String> extensionsKey =
Metadata.Key.of(A2AHeaders.X_A2A_EXTENSIONS, Metadata.ASCII_STRING_MARSHALLER);
String extensions = metadata.get(extensionsKey);
String extensions = metadata.get(EXTENSIONS_KEY);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The gRPC interceptor uses metadata.get(EXTENSIONS_KEY) to extract the A2A-Extensions header. In gRPC-Java, metadata.get() only returns the last value if multiple headers with the same name are present. In contrast, the REST and JSON-RPC implementations correctly use getAll() to capture all values. This inconsistency allows an attacker to "hide" certain extensions from the gRPC server by appending another A2A-Extensions header to the request. If the server logic relies on the presence of an extension for security enforcement, this could lead to a bypass.


// Create enhanced context with rich information (equivalent to Python's ServicerContext)
Context context = Context.current()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -513,11 +513,11 @@ public String getUsername() {
state.put(TENANT_KEY, extractTenant(rc));
state.put(TRANSPORT_KEY, TransportProtocol.JSONRPC);

// Extract requested protocol version from X-A2A-Version header
String requestedVersion = rc.request().getHeader(A2AHeaders.X_A2A_VERSION);
// Extract requested protocol version from A2A-Version header
String requestedVersion = rc.request().getHeader(A2AHeaders.A2A_VERSION);

// Extract requested extensions from X-A2A-Extensions header
List<String> extensionHeaderValues = rc.request().headers().getAll(A2AHeaders.X_A2A_EXTENSIONS);
// Extract requested extensions from A2A-Extensions header
List<String> extensionHeaderValues = rc.request().headers().getAll(A2AHeaders.A2A_EXTENSIONS);
Set<String> requestedExtensions = A2AExtensions.getRequestedExtensions(extensionHeaderValues);

return new ServerCallContext(user, state, requestedExtensions, requestedVersion);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -731,11 +731,11 @@ public String getUsername() {
state.put(TENANT_KEY, extractTenant(rc));
state.put(TRANSPORT_KEY, TransportProtocol.HTTP_JSON);

// Extract requested protocol version from X-A2A-Version header
String requestedVersion = rc.request().getHeader(A2AHeaders.X_A2A_VERSION);
// Extract requested protocol version from A2A-Version header
String requestedVersion = rc.request().getHeader(A2AHeaders.A2A_VERSION);

// Extract requested extensions from X-A2A-Extensions header
List<String> extensionHeaderValues = rc.request().headers().getAll(A2AHeaders.X_A2A_EXTENSIONS);
// Extract requested extensions from A2A-Extensions header
List<String> extensionHeaderValues = rc.request().headers().getAll(A2AHeaders.A2A_EXTENSIONS);
Set<String> requestedExtensions = A2AExtensions.getRequestedExtensions(extensionHeaderValues);

return new ServerCallContext(user, state, requestedExtensions, requestedVersion);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package io.a2a.transport.grpc.context;


import static java.util.Locale.ROOT;

import java.util.Locale;
import java.util.Map;

import io.a2a.common.A2AHeaders;
import io.a2a.spec.A2AMethods;
import io.grpc.Context;

Expand Down Expand Up @@ -40,18 +44,18 @@
public final class GrpcContextKeys {

/**
* Context key for storing the X-A2A-Version header value.
* Context key for storing the a2a-version header value.
* Set by server interceptors and accessed by service handlers.
*/
public static final Context.Key<String> VERSION_HEADER_KEY =
Context.key("x-a2a-version");
Context.key(A2AHeaders.A2A_VERSION.toLowerCase(ROOT));

/**
* Context key for storing the X-A2A-Extensions header value.
* Context key for storing the a2a-extensions header value.
* Set by server interceptors and accessed by service handlers.
*/
public static final Context.Key<String> EXTENSIONS_HEADER_KEY =
Context.key("x-a2a-extensions");
Context.key(A2AHeaders.A2A_EXTENSIONS.toLowerCase(ROOT));

/**
* Context key for storing the complete gRPC Metadata object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
* <li>User authentication from security context</li>
* <li>gRPC metadata (headers)</li>
* <li>Method name and peer information</li>
* <li>A2A protocol version from {@code X-A2A-Version} header</li>
* <li>Required extensions from {@code X-A2A-Extensions} header</li>
* <li>A2A protocol version from {@code A2A-Version} header</li>
* <li>Required extensions from {@code A2A-Extensions} header</li>
* <li>Response observer for gRPC streaming</li>
* </ul>
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -600,8 +600,8 @@ public void deleteTaskPushNotificationConfig(io.a2a.grpc.DeleteTaskPushNotificat
* <li>HTTP headers extracted from metadata</li>
* <li>gRPC method name</li>
* <li>Peer information (client connection details)</li>
* <li>A2A protocol version from {@code X-A2A-Version} header (via context)</li>
* <li>Required extensions from {@code X-A2A-Extensions} header (via context)</li>
* <li>A2A protocol version from {@code A2A-Version} header (via context)</li>
* <li>Required extensions from {@code A2A-Extensions} header (via context)</li>
* </ul>
*
* <p><b>Custom Context Creation:</b>
Expand Down Expand Up @@ -901,7 +901,7 @@ public static void setStreamingSubscribedRunnable(Runnable runnable) {
protected abstract Executor getExecutor();

/**
* Attempts to extract the X-A2A-Version header from the current gRPC context.
* Attempts to extract the A2A-Version header from the current gRPC context.
* This will only work if a server interceptor has been configured to capture
* the metadata and store it in the context.
*
Expand All @@ -917,7 +917,7 @@ public static void setStreamingSubscribedRunnable(Runnable runnable) {
}

/**
* Attempts to extract the X-A2A-Extensions header from the current gRPC context.
* Attempts to extract the A2A-Extensions header from the current gRPC context.
* This will only work if a server interceptor has been configured to capture
* the metadata and store it in the context.
*
Expand Down