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 0fa73735d..4bb25198f 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 @@ -25,6 +25,7 @@ import jakarta.enterprise.inject.spi.BeanManager; import jakarta.enterprise.inject.spi.CDI; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.List; @@ -533,6 +534,28 @@ public CompletableFuture getAsyncSSE( } } + /** + * Resolves an HTTP {@code Location} header value against the original request URI. + * + *

Per RFC 9110 10.2.2 (and RFC 7231 before it) a {@code Location} may be a relative + * reference such as {@code /collect}; RFC 3986 5 reference resolution turns it into the + * effective target URI. An already-absolute {@code Location} is returned unchanged. A + * malformed base or location falls back to the raw value so any existing handling still + * applies. + * + * @param requestUri the original (absolute) request URI the redirect responded to + * @param location the raw {@code Location} header value + * @return the absolute redirect target + */ + // Package-private for direct unit testing of RFC 3986 reference resolution. + static String resolveRedirectLocation(String requestUri, String location) { + try { + return URI.create(requestUri).resolve(location).toString(); + } catch (IllegalArgumentException e) { + return location; + } + } + private class VertxPostBuilder extends VertxBuilder implements A2AHttpClient.PostBuilder { private String body = ""; @@ -571,12 +594,15 @@ public A2AHttpResponse post() throws IOException, InterruptedException { int statusCode = response.status(); String location = response.headers().firstValue("Location"); if (location != null) { + // RFC 9110 10.2.2 permits a relative Location (e.g. /collect); resolve it + // against the original request URI per RFC 3986 5 before following. + String resolvedLocation = resolveRedirectLocation(url, location); if (statusCode == 301 || statusCode == 302 || statusCode == 303) { // RFC 7231: 301/302/303 redirect POST as GET; 307/308 would preserve the method - return executeSyncRequest(webClient.getAbs(location), headers, null); + return executeSyncRequest(webClient.getAbs(resolvedLocation), headers, null); } else if (statusCode == 307 || statusCode == 308) { // RFC 7538: 307/308 must repeat the request with the original method and body - return executeSyncRequest(webClient.postAbs(location), headers, bodyBuffer); + return executeSyncRequest(webClient.postAbs(resolvedLocation), headers, bodyBuffer); } } } diff --git a/extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClientRedirectTest.java b/extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClientRedirectTest.java index 7a10cbe92..5e9dc2fc9 100644 --- a/extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClientRedirectTest.java +++ b/extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClientRedirectTest.java @@ -1,12 +1,58 @@ package org.a2aproject.sdk.client.http.vertx; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.verify.VerificationTimes.exactly; + import org.a2aproject.sdk.client.http.A2AHttpClient; +import org.a2aproject.sdk.client.http.A2AHttpResponse; import org.a2aproject.sdk.client.http.AbstractA2AHttpClientRedirectTest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockserver.integration.ClientAndServer; public class VertxA2AHttpClientRedirectTest extends AbstractA2AHttpClientRedirectTest { + private ClientAndServer relativeServer; + @Override protected A2AHttpClient createClient() { return new VertxA2AHttpClient(); } + + @AfterEach + public void stopRelativeServer() { + if (relativeServer != null) { + relativeServer.stop(); + } + } + + /** + * A relative {@code Location} value (permitted by RFC 9110 10.2.2, resolved per RFC 3986 5) + * must be resolved against the original request URI and followed, rather than handed verbatim + * to an absolute request where it fails. The redirect target is served by the same origin so + * that {@code /collect} only resolves correctly when the base URI is applied. + * + *

Regression test for a2aproject/a2a-java#1142. + */ + @Test + public void synchronousPostFollowsRelativeRedirectLocation() throws Exception { + relativeServer = ClientAndServer.startClientAndServer(0); + relativeServer.when(request().withMethod("POST").withPath("/agent")) + .respond(response().withStatusCode(302).withHeader("Location", "/collect")); + relativeServer.when(request().withPath("/collect")) + .respond(response().withStatusCode(200).withBody("redirected")); + + A2AHttpResponse result = createClient().createPost() + .url("http://127.0.0.1:" + relativeServer.getLocalPort() + "/agent") + .addHeader("X-API-Key", "FULCRUM-SYNTHETIC-RELATIVE") + .body("{}") + .followRedirects(true) + .post(); + + assertEquals(200, result.status(), + "Synchronous POST must resolve and follow a relative Location against the request URI"); + relativeServer.verify(request().withPath("/collect"), exactly(1)); + } } diff --git a/extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClientResolveRedirectLocationTest.java b/extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClientResolveRedirectLocationTest.java new file mode 100644 index 000000000..6d90204f4 --- /dev/null +++ b/extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClientResolveRedirectLocationTest.java @@ -0,0 +1,75 @@ +package org.a2aproject.sdk.client.http.vertx; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link VertxA2AHttpClient#resolveRedirectLocation(String, String)}, the RFC 3986 + * section 5 reference resolution applied to a redirect {@code Location} before the redirected + * request is built. + * + *

Covers a2aproject/a2a-java#1142: a relative {@code Location} must resolve against the original + * request URI rather than be handed verbatim to an absolute request. + */ +public class VertxA2AHttpClientResolveRedirectLocationTest { + + @Test + public void absolutePathResolvesAgainstOrigin() { + assertEquals( + "http://host:3000/collect", + VertxA2AHttpClient.resolveRedirectLocation("http://host:3000/rpc/send", "/collect")); + } + + @Test + public void relativePathResolvesAgainstCurrentDirectory() { + assertEquals( + "http://host:3000/rpc/collect", + VertxA2AHttpClient.resolveRedirectLocation("http://host:3000/rpc/send", "collect")); + } + + @Test + public void relativePathWithQueryIsResolved() { + assertEquals( + "http://host:3000/a/c?x=1", + VertxA2AHttpClient.resolveRedirectLocation("http://host:3000/a/b", "c?x=1")); + } + + @Test + public void dotSegmentsAreResolved() { + assertEquals( + "http://host:3000/a/d", + VertxA2AHttpClient.resolveRedirectLocation("http://host:3000/a/b/c", "../d")); + } + + @Test + public void absoluteLocationIsReturnedUnchanged() { + assertEquals( + "https://elsewhere.example/xyz", + VertxA2AHttpClient.resolveRedirectLocation( + "http://host:3000/rpc", "https://elsewhere.example/xyz")); + } + + @Test + public void protocolRelativeLocationKeepsScheme() { + assertEquals( + "https://elsewhere.example/p", + VertxA2AHttpClient.resolveRedirectLocation( + "https://host/rpc", "//elsewhere.example/p")); + } + + @Test + public void malformedLocationFallsBackToRawValue() { + // A space is illegal in a URI reference, so URI.create throws and the raw + // Location is returned so any existing downstream handling still applies. + String raw = "/pa th"; + assertEquals(raw, VertxA2AHttpClient.resolveRedirectLocation("http://host/rpc", raw)); + } + + @Test + public void malformedBaseFallsBackToRawLocation() { + String location = "/collect"; + assertEquals( + location, VertxA2AHttpClient.resolveRedirectLocation("ht tp://host/rpc", location)); + } +}