From 2435e0f1c76b0e88452eae458832b08daf397e14 Mon Sep 17 00:00:00 2001 From: AlgoVoi Date: Sun, 20 Sep 2026 06:29:03 +0000 Subject: [PATCH 1/2] fix(http-client-vertx): resolve relative redirect Location against the request URI The opt-in redirect follower in VertxA2AHttpClient passed the raw Location header value straight to WebClient.getAbs()/postAbs(). RFC 9110 (10.2.2) and RFC 7231 permit a relative Location such as /collect, so a relative value was handed to an absolute-URI request and failed with MalformedURLException instead of being followed. Resolve the Location against the original request URI using RFC 3986 (5) reference resolution (URI.create(url).resolve(location)) before building the redirected request. Absolute Location values are returned unchanged, so existing behavior is preserved; a malformed base or location falls back to the raw value. No redirect-target validation is added or weakened. Adds a Vert.x redirect test that serves a relative Location from a single origin and asserts it is resolved and followed; the inherited absolute-Location tests continue to pass. Fixes #1142. Signed-off-by: AlgoVoi --- .../client/http/vertx/VertxA2AHttpClient.java | 29 +++++++++++- .../vertx/VertxA2AHttpClientRedirectTest.java | 46 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) 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..f6d388163 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,27 @@ 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 + */ + private 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 +593,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)); + } } From b4a01096e3ca29ccfd93d0f58af7848dbdd5076d Mon Sep 17 00:00:00 2001 From: AlgoVoi Date: Tue, 22 Sep 2026 01:49:33 +0000 Subject: [PATCH 2/2] test(http-client-vertx): unit-test resolveRedirectLocation directly (#1162) Make resolveRedirectLocation package-private so RFC 3986 reference resolution can be unit-tested without a mock server, per review. Add a focused test class covering absolute-path, relative-path, dot-segment, query, protocol-relative, already-absolute, and malformed base/location (raw fallback) cases. The existing mock-server integration test is kept. Signed-off-by: AlgoVoi --- .../client/http/vertx/VertxA2AHttpClient.java | 3 +- ...HttpClientResolveRedirectLocationTest.java | 75 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 extras/http-client-vertx/src/test/java/org/a2aproject/sdk/client/http/vertx/VertxA2AHttpClientResolveRedirectLocationTest.java 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 f6d388163..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 @@ -547,7 +547,8 @@ public CompletableFuture getAsyncSSE( * @param location the raw {@code Location} header value * @return the absolute redirect target */ - private static String resolveRedirectLocation(String requestUri, String location) { + // 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) { 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)); + } +}