diff --git a/client/transport/spi/src/main/java/org/a2aproject/sdk/client/transport/spi/interceptors/auth/AuthInterceptor.java b/client/transport/spi/src/main/java/org/a2aproject/sdk/client/transport/spi/interceptors/auth/AuthInterceptor.java index 21eb6f02d..1854eb497 100644 --- a/client/transport/spi/src/main/java/org/a2aproject/sdk/client/transport/spi/interceptors/auth/AuthInterceptor.java +++ b/client/transport/spi/src/main/java/org/a2aproject/sdk/client/transport/spi/interceptors/auth/AuthInterceptor.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallContext; import org.a2aproject.sdk.client.transport.spi.interceptors.ClientCallInterceptor; @@ -28,6 +29,22 @@ public class AuthInterceptor extends ClientCallInterceptor { public static final String AUTHORIZATION = "Authorization"; private static final String BEARER = "Bearer "; private static final String BASIC = "Basic "; + + /** + * Allowlist of header names that are safe for API key injection. + * This prevents credential leakage through malicious header names that could + * be forwarded to third-party origins during redirects or other scenarios. + * Only standard authentication-related headers are permitted. + * Header names are stored in lowercase for case-insensitive comparison. + */ + private static final Set SAFE_API_KEY_HEADER_NAMES = Set.of( + "authorization", + "x-api-key", + "api-key", + "x-auth-token", + "x-authentication" + ); + private final CredentialService credentialService; public AuthInterceptor(final CredentialService credentialService) { @@ -66,8 +83,13 @@ public PayloadAndHeaders intercept(String methodName, @Nullable Object payload, updatedHeaders.put(AUTHORIZATION, getBearerValue(credential)); return new PayloadAndHeaders(payload, updatedHeaders); } else if (securityScheme instanceof APIKeySecurityScheme apiKeySecurityScheme) { - updatedHeaders.put(apiKeySecurityScheme.name(), credential); - return new PayloadAndHeaders(payload, updatedHeaders); + // Only inject API key if it's intended for header transport and the header name is safe + if (apiKeySecurityScheme.location() == APIKeySecurityScheme.Location.HEADER + && isSafeHeaderName(apiKeySecurityScheme.name())) { + updatedHeaders.put(apiKeySecurityScheme.name(), credential); + return new PayloadAndHeaders(payload, updatedHeaders); + } + // Skip credential injection for unsafe header names or non-header locations } } } @@ -82,4 +104,17 @@ private static String getBearerValue(String credential) { private static String getBasicValue(String credential) { return BASIC + credential; } + + /** + * Validates that a header name is safe for API key injection. + * This prevents credential leakage by rejecting header names that could + * be exploited to forward credentials to unintended destinations. + * Header name comparison is case-insensitive per RFC 7230. + * + * @param headerName the header name to validate + * @return true if the header name is in the safe allowlist, false otherwise + */ + private static boolean isSafeHeaderName(String headerName) { + return SAFE_API_KEY_HEADER_NAMES.contains(headerName.toLowerCase(Locale.ROOT)); + } } diff --git a/client/transport/spi/src/test/java/org/a2aproject/sdk/client/transport/spi/interceptors/auth/AuthInterceptorTest.java b/client/transport/spi/src/test/java/org/a2aproject/sdk/client/transport/spi/interceptors/auth/AuthInterceptorTest.java index b8195149f..3983eff9a 100644 --- a/client/transport/spi/src/test/java/org/a2aproject/sdk/client/transport/spi/interceptors/auth/AuthInterceptorTest.java +++ b/client/transport/spi/src/test/java/org/a2aproject/sdk/client/transport/spi/interceptors/auth/AuthInterceptorTest.java @@ -75,6 +75,50 @@ private static class AuthTestCase { @Test public void testAPIKeySecurityScheme() { + AuthTestCase authTestCase = new AuthTestCase( + "http://agent.com/rpc", + "session-id", + APIKeySecurityScheme.TYPE, + "secret-api-key", + new APIKeySecurityScheme(APIKeySecurityScheme.Location.HEADER, "X-API-Key", "API Key authentication"), + "X-API-Key", + "secret-api-key" + ); + testSecurityScheme(authTestCase); + } + + @Test + public void testAPIKeySecurityScheme_SafeHeaderName_Authorization() { + AuthTestCase authTestCase = new AuthTestCase( + "http://agent.com/rpc", + "session-id", + APIKeySecurityScheme.TYPE, + "secret-api-key", + new APIKeySecurityScheme(APIKeySecurityScheme.Location.HEADER, "Authorization", "API Key authentication"), + "Authorization", + "secret-api-key" + ); + testSecurityScheme(authTestCase); + } + + @Test + public void testAPIKeySecurityScheme_SafeHeaderName_XAuthToken() { + AuthTestCase authTestCase = new AuthTestCase( + "http://agent.com/rpc", + "session-id", + APIKeySecurityScheme.TYPE, + "secret-api-key", + new APIKeySecurityScheme(APIKeySecurityScheme.Location.HEADER, "X-Auth-Token", "API Key authentication"), + "X-Auth-Token", + "secret-api-key" + ); + testSecurityScheme(authTestCase); + } + + + @Test + public void testAPIKeySecurityScheme_CaseInsensitiveHeaderName() { + // Test that lowercase header names are accepted (case-insensitive comparison) AuthTestCase authTestCase = new AuthTestCase( "http://agent.com/rpc", "session-id", @@ -87,6 +131,124 @@ public void testAPIKeySecurityScheme() { testSecurityScheme(authTestCase); } + @Test + public void testAPIKeySecurityScheme_CaseInsensitiveHeaderName_Authorization() { + // Test that lowercase "authorization" is accepted + AuthTestCase authTestCase = new AuthTestCase( + "http://agent.com/rpc", + "session-id", + APIKeySecurityScheme.TYPE, + "secret-api-key", + new APIKeySecurityScheme(APIKeySecurityScheme.Location.HEADER, "authorization", "API Key authentication"), + "authorization", + "secret-api-key" + ); + testSecurityScheme(authTestCase); + } + + + @Test + public void testAPIKeySecurityScheme_UnsafeHeaderName_Rejected() { + String sessionId = "session-id"; + String schemeName = APIKeySecurityScheme.TYPE; + String credential = "secret-api-key"; + + credentialStore.setCredential(sessionId, schemeName, credential); + + // Use an unsafe header name that's not in the allowlist + SecurityScheme securityScheme = new APIKeySecurityScheme( + APIKeySecurityScheme.Location.HEADER, + "X-Malicious-Redirect-Header", + "Unsafe header" + ); + AgentCard agentCard = createAgentCard(schemeName, securityScheme); + + Map requestPayload = Map.of("test", "payload"); + Map headers = Map.of(); + ClientCallContext context = new ClientCallContext(Map.of("sessionId", sessionId), Map.of()); + + PayloadAndHeaders result = authInterceptor.intercept( + "SendMessage", + requestPayload, + headers, + agentCard, + context + ); + + assertEquals(requestPayload, result.getPayload()); + // Credential should NOT be injected for unsafe header name + assertNull(result.getHeaders().get("X-Malicious-Redirect-Header")); + assertEquals(0, result.getHeaders().size()); + } + + @Test + public void testAPIKeySecurityScheme_QueryLocation_NotInjectedInHeader() { + String sessionId = "session-id"; + String schemeName = APIKeySecurityScheme.TYPE; + String credential = "secret-api-key"; + + credentialStore.setCredential(sessionId, schemeName, credential); + + // API key in query parameter location should not be injected as header + SecurityScheme securityScheme = new APIKeySecurityScheme( + APIKeySecurityScheme.Location.QUERY, + "api_key", + "Query parameter API key" + ); + AgentCard agentCard = createAgentCard(schemeName, securityScheme); + + Map requestPayload = Map.of("test", "payload"); + Map headers = Map.of(); + ClientCallContext context = new ClientCallContext(Map.of("sessionId", sessionId), Map.of()); + + PayloadAndHeaders result = authInterceptor.intercept( + "SendMessage", + requestPayload, + headers, + agentCard, + context + ); + + assertEquals(requestPayload, result.getPayload()); + // Credential should NOT be injected as header for query location + assertNull(result.getHeaders().get("api_key")); + assertEquals(0, result.getHeaders().size()); + } + + @Test + public void testAPIKeySecurityScheme_CookieLocation_NotInjectedInHeader() { + String sessionId = "session-id"; + String schemeName = APIKeySecurityScheme.TYPE; + String credential = "secret-api-key"; + + credentialStore.setCredential(sessionId, schemeName, credential); + + // API key in cookie location should not be injected as header + SecurityScheme securityScheme = new APIKeySecurityScheme( + APIKeySecurityScheme.Location.COOKIE, + "session_token", + "Cookie-based API key" + ); + AgentCard agentCard = createAgentCard(schemeName, securityScheme); + + Map requestPayload = Map.of("test", "payload"); + Map headers = Map.of(); + ClientCallContext context = new ClientCallContext(Map.of("sessionId", sessionId), Map.of()); + + PayloadAndHeaders result = authInterceptor.intercept( + "SendMessage", + requestPayload, + headers, + agentCard, + context + ); + + assertEquals(requestPayload, result.getPayload()); + // Credential should NOT be injected as header for cookie location + assertNull(result.getHeaders().get("session_token")); + assertEquals(0, result.getHeaders().size()); + } + @Test public void testOAuth2SecurityScheme() { AuthTestCase authTestCase = new AuthTestCase( @@ -238,9 +400,9 @@ void testAvailableSecuritySchemeNotInAgentCardSecuritySchemes() { String schemeName = "missing"; String sessionId = "session-id"; String credential = "dummy-token"; - + credentialStore.setCredential(sessionId, schemeName, credential); - + // Create agent card with security requirement but no scheme definition AgentCard agentCard = AgentCard.builder() .name("missing") @@ -254,7 +416,7 @@ void testAvailableSecuritySchemeNotInAgentCardSecuritySchemes() { .securityRequirements(List.of(SecurityRequirement.builder().scheme(schemeName, List.of()).build())) .securitySchemes(Map.of()) // no security schemes .build(); - + Map requestPayload = Map.of("foo", "bar"); Map headers = Map.of("fizz", "buzz"); ClientCallContext context = new ClientCallContext(Map.of("sessionId", sessionId), Map.of()); @@ -276,7 +438,7 @@ void testNoCredentialAvailable() { String schemeName = "apikey"; SecurityScheme securityScheme = new APIKeySecurityScheme(APIKeySecurityScheme.Location.HEADER, "X-API-Key", "API Key authentication"); AgentCard agentCard = createAgentCard(schemeName, securityScheme); - + Map requestPayload = Map.of("test", "payload"); Map headers = Map.of(); ClientCallContext context = new ClientCallContext(Map.of("sessionId", "session-id"), Map.of()); @@ -307,7 +469,7 @@ void testNoAgentCardSecuritySpecified() { .skills(List.of()) .securityRequirements(null) // no security info .build(); - + Map requestPayload = Map.of("test", "payload"); Map headers = Map.of(); ClientCallContext context = new ClientCallContext(Map.of("sessionId", "session-id"), Map.of()); diff --git a/compat-0.3/client/transport/spi/src/main/java/org/a2aproject/sdk/compat03/client/transport/spi/interceptors/auth/AuthInterceptor_v0_3.java b/compat-0.3/client/transport/spi/src/main/java/org/a2aproject/sdk/compat03/client/transport/spi/interceptors/auth/AuthInterceptor_v0_3.java index 2e889951b..5ff604a18 100644 --- a/compat-0.3/client/transport/spi/src/main/java/org/a2aproject/sdk/compat03/client/transport/spi/interceptors/auth/AuthInterceptor_v0_3.java +++ b/compat-0.3/client/transport/spi/src/main/java/org/a2aproject/sdk/compat03/client/transport/spi/interceptors/auth/AuthInterceptor_v0_3.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallContext_v0_3; import org.a2aproject.sdk.compat03.client.transport.spi.interceptors.ClientCallInterceptor_v0_3; @@ -27,6 +28,22 @@ public class AuthInterceptor_v0_3 extends ClientCallInterceptor_v0_3 { public static final String AUTHORIZATION = "Authorization"; private static final String BEARER = "Bearer "; private static final String BASIC = "Basic "; + + /** + * Allowlist of header names that are safe for API key injection. + * This prevents credential leakage through malicious header names that could + * be forwarded to third-party origins during redirects or other scenarios. + * Only standard authentication-related headers are permitted. + * Header names are stored in lowercase for case-insensitive comparison. + */ + private static final Set SAFE_API_KEY_HEADER_NAMES = Set.of( + "authorization", + "x-api-key", + "api-key", + "x-auth-token", + "x-authentication" + ); + private final CredentialService_v0_3 credentialService; public AuthInterceptor_v0_3(final CredentialService_v0_3 credentialService) { @@ -62,8 +79,13 @@ public PayloadAndHeaders_v0_3 intercept(String methodName, @Nullable Object payl updatedHeaders.put(AUTHORIZATION, getBearerValue(credential)); return new PayloadAndHeaders_v0_3(payload, updatedHeaders); } else if (securityScheme instanceof APIKeySecurityScheme_v0_3 apiKeySecurityScheme) { - updatedHeaders.put(apiKeySecurityScheme.name(), credential); - return new PayloadAndHeaders_v0_3(payload, updatedHeaders); + // Only inject API key if it's intended for header transport and the header name is safe + if ("header".equals(apiKeySecurityScheme.in()) + && isSafeHeaderName(apiKeySecurityScheme.name())) { + updatedHeaders.put(apiKeySecurityScheme.name(), credential); + return new PayloadAndHeaders_v0_3(payload, updatedHeaders); + } + // Skip credential injection for unsafe header names or non-header locations } } } @@ -78,4 +100,17 @@ private static String getBearerValue(String credential) { private static String getBasicValue(String credential) { return BASIC + credential; } + + /** + * Validates that a header name is safe for API key injection. + * This prevents credential leakage by rejecting header names that could + * be exploited to forward credentials to unintended destinations. + * Header name comparison is case-insensitive per RFC 7230. + * + * @param headerName the header name to validate + * @return true if the header name is in the safe allowlist, false otherwise + */ + private static boolean isSafeHeaderName(String headerName) { + return SAFE_API_KEY_HEADER_NAMES.contains(headerName.toLowerCase(Locale.ROOT)); + } } diff --git a/compat-0.3/client/transport/spi/src/test/java/org/a2aproject/sdk/compat03/client/transport/spi/interceptors/auth/AuthInterceptor_v0_3_Test.java b/compat-0.3/client/transport/spi/src/test/java/org/a2aproject/sdk/compat03/client/transport/spi/interceptors/auth/AuthInterceptor_v0_3_Test.java index 5027ce493..c987c9197 100644 --- a/compat-0.3/client/transport/spi/src/test/java/org/a2aproject/sdk/compat03/client/transport/spi/interceptors/auth/AuthInterceptor_v0_3_Test.java +++ b/compat-0.3/client/transport/spi/src/test/java/org/a2aproject/sdk/compat03/client/transport/spi/interceptors/auth/AuthInterceptor_v0_3_Test.java @@ -74,6 +74,49 @@ private static class AuthTestCase { @Test public void testAPIKeySecurityScheme() { + AuthTestCase authTestCase = new AuthTestCase( + "http://agent.com/rpc", + "session-id", + APIKeySecurityScheme_v0_3.TYPE, + "secret-api-key", + new APIKeySecurityScheme_v0_3("header", "X-API-Key", "API Key authentication"), + "X-API-Key", + "secret-api-key" + ); + testSecurityScheme(authTestCase); + } + + @Test + public void testAPIKeySecurityScheme_SafeHeaderName_Authorization() { + AuthTestCase authTestCase = new AuthTestCase( + "http://agent.com/rpc", + "session-id", + APIKeySecurityScheme_v0_3.TYPE, + "secret-api-key", + new APIKeySecurityScheme_v0_3("header", "Authorization", "API Key authentication"), + "Authorization", + "secret-api-key" + ); + testSecurityScheme(authTestCase); + } + + @Test + public void testAPIKeySecurityScheme_SafeHeaderName_XAuthToken() { + AuthTestCase authTestCase = new AuthTestCase( + "http://agent.com/rpc", + "session-id", + APIKeySecurityScheme_v0_3.TYPE, + "secret-api-key", + new APIKeySecurityScheme_v0_3("header", "X-Auth-Token", "API Key authentication"), + "X-Auth-Token", + "secret-api-key" + ); + testSecurityScheme(authTestCase); + } + + @Test + public void testAPIKeySecurityScheme_CaseInsensitiveHeaderName() { + // Test that lowercase header names are accepted (case-insensitive comparison) AuthTestCase authTestCase = new AuthTestCase( "http://agent.com/rpc", "session-id", @@ -86,6 +129,123 @@ public void testAPIKeySecurityScheme() { testSecurityScheme(authTestCase); } + @Test + public void testAPIKeySecurityScheme_CaseInsensitiveHeaderName_Authorization() { + // Test that lowercase "authorization" is accepted + AuthTestCase authTestCase = new AuthTestCase( + "http://agent.com/rpc", + "session-id", + APIKeySecurityScheme_v0_3.TYPE, + "secret-api-key", + new APIKeySecurityScheme_v0_3("header", "authorization", "API Key authentication"), + "authorization", + "secret-api-key" + ); + testSecurityScheme(authTestCase); + } + + @Test + public void testAPIKeySecurityScheme_UnsafeHeaderName_Rejected() { + String sessionId = "session-id"; + String schemeName = APIKeySecurityScheme_v0_3.TYPE; + String credential = "secret-api-key"; + + credentialStore.setCredential(sessionId, schemeName, credential); + + // Use an unsafe header name that's not in the allowlist + SecurityScheme_v0_3 securityScheme = new APIKeySecurityScheme_v0_3( + "header", + "X-Malicious-Redirect-Header", + "Unsafe header" + ); + AgentCard_v0_3 agentCard = createAgentCard(schemeName, securityScheme); + + Map requestPayload = Map.of("test", "payload"); + Map headers = Map.of(); + ClientCallContext_v0_3 context = new ClientCallContext_v0_3(Map.of("sessionId", sessionId), Map.of()); + + PayloadAndHeaders_v0_3 result = authInterceptor.intercept( + "message/send", + requestPayload, + headers, + agentCard, + context + ); + + assertEquals(requestPayload, result.getPayload()); + // Credential should NOT be injected for unsafe header name + assertNull(result.getHeaders().get("X-Malicious-Redirect-Header")); + assertEquals(0, result.getHeaders().size()); + } + + @Test + public void testAPIKeySecurityScheme_QueryLocation_NotInjectedInHeader() { + String sessionId = "session-id"; + String schemeName = APIKeySecurityScheme_v0_3.TYPE; + String credential = "secret-api-key"; + + credentialStore.setCredential(sessionId, schemeName, credential); + + // API key in query parameter location should not be injected as header + SecurityScheme_v0_3 securityScheme = new APIKeySecurityScheme_v0_3( + "query", + "api_key", + "Query parameter API key" + ); + AgentCard_v0_3 agentCard = createAgentCard(schemeName, securityScheme); + + Map requestPayload = Map.of("test", "payload"); + Map headers = Map.of(); + ClientCallContext_v0_3 context = new ClientCallContext_v0_3(Map.of("sessionId", sessionId), Map.of()); + + PayloadAndHeaders_v0_3 result = authInterceptor.intercept( + "message/send", + requestPayload, + headers, + agentCard, + context + ); + + assertEquals(requestPayload, result.getPayload()); + // Credential should NOT be injected as header for query location + assertNull(result.getHeaders().get("api_key")); + assertEquals(0, result.getHeaders().size()); + } + + @Test + public void testAPIKeySecurityScheme_CookieLocation_NotInjectedInHeader() { + String sessionId = "session-id"; + String schemeName = APIKeySecurityScheme_v0_3.TYPE; + String credential = "secret-api-key"; + + credentialStore.setCredential(sessionId, schemeName, credential); + + // API key in cookie location should not be injected as header + SecurityScheme_v0_3 securityScheme = new APIKeySecurityScheme_v0_3( + "cookie", + "session_token", + "Cookie-based API key" + ); + AgentCard_v0_3 agentCard = createAgentCard(schemeName, securityScheme); + + Map requestPayload = Map.of("test", "payload"); + Map headers = Map.of(); + ClientCallContext_v0_3 context = new ClientCallContext_v0_3(Map.of("sessionId", sessionId), Map.of()); + + PayloadAndHeaders_v0_3 result = authInterceptor.intercept( + "message/send", + requestPayload, + headers, + agentCard, + context + ); + + assertEquals(requestPayload, result.getPayload()); + // Credential should NOT be injected as header for cookie location + assertNull(result.getHeaders().get("session_token")); + assertEquals(0, result.getHeaders().size()); + } + @Test public void testOAuth2SecurityScheme() { AuthTestCase authTestCase = new AuthTestCase( @@ -222,9 +382,9 @@ void testAvailableSecuritySchemeNotInAgentCardSecuritySchemes() { String schemeName = "missing"; String sessionId = "session-id"; String credential = "dummy-token"; - + credentialStore.setCredential(sessionId, schemeName, credential); - + // Create agent card with security requirement but no scheme definition AgentCard_v0_3 agentCard = new AgentCard_v0_3.Builder() .name("missing") @@ -238,7 +398,7 @@ void testAvailableSecuritySchemeNotInAgentCardSecuritySchemes() { .security(List.of(Map.of(schemeName, List.of()))) .securitySchemes(Map.of()) // no security schemes .build(); - + Map requestPayload = Map.of("foo", "bar"); Map headers = Map.of("fizz", "buzz"); ClientCallContext_v0_3 context = new ClientCallContext_v0_3(Map.of("sessionId", sessionId), Map.of()); @@ -260,7 +420,7 @@ void testNoCredentialAvailable() { String schemeName = "apikey"; SecurityScheme_v0_3 securityScheme = new APIKeySecurityScheme_v0_3("header", "X-API-Key", "API Key authentication"); AgentCard_v0_3 agentCard = createAgentCard(schemeName, securityScheme); - + Map requestPayload = Map.of("test", "payload"); Map headers = Map.of(); ClientCallContext_v0_3 context = new ClientCallContext_v0_3(Map.of("sessionId", "session-id"), Map.of()); @@ -291,7 +451,7 @@ void testNoAgentCardSecuritySpecified() { .skills(List.of()) .security(null) // no security info .build(); - + Map requestPayload = Map.of("test", "payload"); Map headers = Map.of(); ClientCallContext_v0_3 context = new ClientCallContext_v0_3(Map.of("sessionId", "session-id"), Map.of()); diff --git a/docs/content/dev/security.md b/docs/content/dev/security.md new file mode 100644 index 000000000..4c6fc0f39 --- /dev/null +++ b/docs/content/dev/security.md @@ -0,0 +1,82 @@ +--- +title: Security +description: Security features and best practices for the A2A Java SDK +--- + +# Security + +The A2A Java SDK implements several security hardening measures to protect against credential leakage and other security vulnerabilities. + +## API Key Header Name Validation + +When using API key authentication with header-based transport, the SDK validates the header name provided by the remote agent's security scheme against a safe allowlist. This prevents malicious agents from exploiting custom header names to leak credentials to unintended destinations. + +**Safe Header Names (comparison is case-insensitive):** +- `Authorization` +- `X-API-Key` +- `API-Key` +- `X-Auth-Token` +- `X-Authentication` + +Header names are matched case-insensitively per [RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2), so `X-Api-Key`, `x-api-key`, and `X-API-Key` are all treated as the same entry. If an agent specifies an API key header name that is not in this allowlist, the SDK will skip credential injection for that scheme. This behavior applies to both the current protocol version and the v0.3 compatibility layer. + +**Example:** +```java +// Safe: Uses an allowed header name +APIKeySecurityScheme scheme = new APIKeySecurityScheme( + APIKeySecurityScheme.Location.HEADER, + "X-API-Key", + "API Key authentication" +); +// Credential will be injected + +// Unsafe: Uses a non-standard header name +APIKeySecurityScheme unsafeScheme = new APIKeySecurityScheme( + APIKeySecurityScheme.Location.HEADER, + "X-Custom-Header", + "Custom header" +); +// Credential will NOT be injected +``` + +Additionally, API keys are only injected when the security scheme explicitly specifies header-based transport (`Location.HEADER`). Query parameter and cookie-based API keys are not injected as HTTP headers. + +## HTTP Redirect Handling + +SDK-managed HTTP clients do not follow redirects automatically by default. This prevents credentials from being inadvertently forwarded to third-party origins during redirect chains. + +### JDK HTTP Client + +The default `JdkA2AHttpClient` is configured with `HttpClient.Redirect.NEVER`. If your application requires redirect following, provide a custom `HttpClient` instance: + +```java +HttpClient customClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + +JdkA2AHttpClient client = new JdkA2AHttpClient(customClient); +``` + +### Vert.x HTTP Client + +The default `VertxA2AHttpClient` is configured with `setFollowRedirects(false)`. If redirect following is needed, create a custom `WebClient` with the desired policy and pass the underlying `Vertx` instance to the constructor. + +### Android HTTP Client + +The `AndroidA2AHttpClient` disables automatic redirect following by calling `setInstanceFollowRedirects(false)` on all `HttpURLConnection` instances. Applications requiring redirect handling must implement it manually. + +## Best Practices + +1. **Use Standard Authentication Headers**: When implementing custom agents, use standard authentication header names from the safe allowlist to ensure credentials are properly injected. + +2. **Validate Agent Cards**: Before connecting to an agent, review its security schemes to ensure they use appropriate authentication methods and header names. + +3. **Handle Redirects Carefully**: If your application requires redirect following, implement it with caution and ensure credentials are not forwarded to untrusted origins. + +4. **Keep Dependencies Updated**: Regularly update the SDK and its dependencies to receive the latest security patches. + +5. **Use TLS**: Always use HTTPS/TLS for agent communication in production environments to protect credentials in transit. + +## Reporting Security Issues + +If you discover a security vulnerability in the A2A Java SDK, please report it according to the guidelines in [SECURITY.md](https://github.com/a2a-protocol/a2a-java/blob/main/SECURITY.md). diff --git a/docs/data/versions/dev.yml b/docs/data/versions/dev.yml index 8e93f7630..a415e1159 100644 --- a/docs/data/versions/dev.yml +++ b/docs/data/versions/dev.yml @@ -19,6 +19,9 @@ menu: - title: "Authorization" path: "/authorization" icon: "fa-solid fa-shield-halved" + - title: "Security" + path: "/security" + icon: "fa-solid fa-lock" - title: "Compatibility" path: "/compatibility" icon: "fa-solid fa-code-branch" diff --git a/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/android/AndroidA2AHttpClient.java b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/android/AndroidA2AHttpClient.java index 3e4a45892..359bd7612 100644 --- a/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/android/AndroidA2AHttpClient.java +++ b/extras/http-client-android/src/main/java/org/a2aproject/sdk/client/http/android/AndroidA2AHttpClient.java @@ -33,7 +33,13 @@ import org.a2aproject.sdk.spec.A2AClientHTTPError; import org.jspecify.annotations.Nullable; -/** Android-specific implementation of {@link A2AHttpClient} using {@link HttpURLConnection}. */ +/** + * Android-specific implementation of {@link A2AHttpClient} using {@link HttpURLConnection}. + * + *

Security Note: This client does not follow HTTP redirects automatically + * to prevent credential leakage to third-party origins. Applications requiring redirect + * following must handle redirects manually. + */ public class AndroidA2AHttpClient implements A2AHttpClient { private static final Executor NET_EXECUTOR = Executors.newCachedThreadPool(r -> { @@ -97,6 +103,8 @@ protected HttpURLConnection createConnection(String method, boolean isSSE) throw connection.setRequestMethod(method); connection.setConnectTimeout(15000); // 15 seconds connection.setReadTimeout(60000); // 60 seconds + // Security: Disable automatic redirect following to prevent credential leakage + connection.setInstanceFollowRedirects(false); for (Map.Entry header : headers.entrySet()) { connection.setRequestProperty(header.getKey(), header.getValue()); } diff --git a/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClient.java b/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClient.java index c38c9c117..0a6371537 100644 --- a/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClient.java +++ b/extras/http-client-vertx/src/main/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClient.java @@ -85,6 +85,13 @@ * Vert.x WebClient automatically negotiates HTTP/2 when supported by the server * via ALPN. No explicit configuration is required. * + *

Security

+ *

+ * The default client does not follow HTTP redirects automatically to prevent + * credential leakage to third-party origins. If redirect following is required, + * create a custom {@link WebClient} with the desired redirect policy and pass + * the underlying {@link Vertx} instance to {@link #VertxA2AHttpClient(Vertx)}. + * *

Usage Examples

* *

Simple GET Request

@@ -143,8 +150,9 @@ public class VertxA2AHttpClient implements A2AHttpClient, AutoCloseable { * *

* The client creates a new {@link Vertx} instance and {@link WebClient} configured - * with HTTP keep-alive and automatic redirect following. When {@link #close()} is called, - * both the WebClient and Vertx instance are closed. + * with HTTP keep-alive and no automatic redirect following (security hardening to + * prevent credential leakage). When {@link #close()} is called, both the WebClient + * and Vertx instance are closed. * *

* Important: Always call {@link #close()} when done with this client @@ -155,7 +163,7 @@ public class VertxA2AHttpClient implements A2AHttpClient, AutoCloseable { public VertxA2AHttpClient() { this.vertx = createVertx(); WebClientOptions options = new WebClientOptions() - .setFollowRedirects(true) + .setFollowRedirects(false) .setKeepAlive(true); this.webClient = WebClient.create(vertx, options); this.httpClient = vertx.createHttpClient(new HttpClientOptions().setKeepAlive(true)); @@ -183,9 +191,10 @@ private Vertx createVertx() { * Creates a new VertxA2AHttpClient using an externally managed Vert.x instance. * *

- * The client creates a {@link WebClient} using the provided {@link Vertx} instance. - * When {@link #close()} is called, only the WebClient is closed; the Vertx instance - * remains open and must be managed by the caller. + * The client creates a {@link WebClient} using the provided {@link Vertx} instance, + * configured with no automatic redirect following (security hardening to prevent + * credential leakage). When {@link #close()} is called, only the WebClient is closed; + * the Vertx instance remains open and must be managed by the caller. * *

* This constructor is useful in environments where Vert.x is already managed, @@ -198,7 +207,7 @@ public VertxA2AHttpClient(Vertx vertx) { this.vertx = Assert.checkNotNullParam("vertx", vertx); this.ownsVertx = false; WebClientOptions options = new WebClientOptions() - .setFollowRedirects(true) + .setFollowRedirects(false) .setKeepAlive(true); this.webClient = WebClient.create(vertx, options); this.httpClient = vertx.createHttpClient(new HttpClientOptions().setKeepAlive(true)); diff --git a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java index 58a6ecce1..960f5c2d2 100644 --- a/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java +++ b/http-client/src/main/java/org/a2aproject/sdk/client/http/JdkA2AHttpClient.java @@ -37,9 +37,13 @@ *

  • HTTP/2 with automatic fallback to HTTP/1.1
  • *
  • Synchronous GET, POST, and DELETE requests
  • *
  • Asynchronous Server-Sent Events (SSE) streaming
  • - *
  • Automatic redirect following
  • * * + *

    Security Note: The default client does not follow HTTP redirects + * automatically to prevent credential leakage to third-party origins. If redirect + * following is required, provide a custom {@link HttpClient} via the constructor + * {@link #JdkA2AHttpClient(HttpClient)}. + * *

    Provider Priority: 0 (lowest - used as fallback) * *

    This implementation is registered via {@link JdkA2AHttpClientProvider} @@ -56,24 +60,31 @@ public class JdkA2AHttpClient implements A2AHttpClient { private volatile @Nullable HttpClient noRedirectClient; /** - * Creates a new JDK-based HTTP client. + * Creates a new JDK-based HTTP client with secure defaults. * *

    Configures the client with: *

      *
    • HTTP/2 preferred (with HTTP/1.1 fallback)
    • - *
    • Normal redirect following
    • + *
    • No automatic redirect following (security hardening to prevent credential leakage)
    • *
    + * + *

    If redirect following is required, use {@link #JdkA2AHttpClient(HttpClient)} + * with a custom {@link HttpClient} configured appropriately. */ public JdkA2AHttpClient() { this(HttpClient.newBuilder() .version(HttpClient.Version.HTTP_2) - .followRedirects(HttpClient.Redirect.NORMAL) + .followRedirects(HttpClient.Redirect.NEVER) .build()); } /** * Creates a new JDK-based HTTP client using a caller-provided JDK {@link HttpClient}. * + *

    This constructor allows full control over the {@link HttpClient} configuration, + * including redirect policy. The caller is responsible for ensuring the client is + * configured securely. + * * @param httpClient the JDK HTTP client to delegate requests to * @throws IllegalArgumentException if {@code httpClient} is {@code null} */ diff --git a/http-client/src/test/java/org/a2aproject/sdk/client/http/JdkA2AHttpClientTest.java b/http-client/src/test/java/org/a2aproject/sdk/client/http/JdkA2AHttpClientTest.java index 471f6b643..0a8780119 100644 --- a/http-client/src/test/java/org/a2aproject/sdk/client/http/JdkA2AHttpClientTest.java +++ b/http-client/src/test/java/org/a2aproject/sdk/client/http/JdkA2AHttpClientTest.java @@ -121,25 +121,53 @@ public void testPostFollowRedirectsFalseDoesNotFollowRedirect() throws Exception } @Test - public void testPostDefaultFollowsRedirect() throws Exception { + public void testDefaultClientDoesNotFollowRedirects() throws Exception { server = ClientAndServer.startClientAndServer(0); - int port = server.getLocalPort(); - server.when(request().withMethod("POST").withPath("/redirect")) + + server.when(request().withMethod("GET").withPath("/redirect")) .respond(response() - .withStatusCode(307) - .withHeader("Location", "http://localhost:" + port + "/target")); - server.when(request().withPath("/target")) + .withStatusCode(302) + .withHeader("Location", "http://localhost:" + server.getLocalPort() + "/target")); + + server.when(request().withMethod("GET").withPath("/target")) .respond(response().withStatusCode(200).withBody("redirected")); JdkA2AHttpClient client = new JdkA2AHttpClient(); - A2AHttpResponse response = client.createPost() - .url("http://localhost:" + port + "/redirect") - .body("{}") - .post(); + A2AHttpResponse response = client.createGet() + .url("http://localhost:" + server.getLocalPort() + "/redirect") + .get(); - assertEquals(200, response.status(), - "By default, redirects should be followed"); + assertEquals(302, response.status()); + assertFalse(response.success()); + String expectedLocation = "http://localhost:" + server.getLocalPort() + "/target"; + assertEquals(expectedLocation, response.headers().firstValue("Location")); + } + + @Test + public void testCustomClientCanFollowRedirects() throws Exception { + server = ClientAndServer.startClientAndServer(0); + + server.when(request().withMethod("GET").withPath("/redirect")) + .respond(response() + .withStatusCode(302) + .withHeader("Location", "http://localhost:" + server.getLocalPort() + "/target")); + + server.when(request().withMethod("GET").withPath("/target")) + .respond(response().withStatusCode(200).withBody("redirected")); + + HttpClient customClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + JdkA2AHttpClient client = new JdkA2AHttpClient(customClient); + + A2AHttpResponse response = client.createGet() + .url("http://localhost:" + server.getLocalPort() + "/redirect") + .get(); + + assertEquals(200, response.status()); + assertTrue(response.success()); assertEquals("redirected", response.body()); }