This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/main by this push:
new 49ca42792152 CAMEL-23876: camel-a2a - pin push notification webhooks
to the validated address (#25004)
49ca42792152 is described below
commit 49ca42792152c7e0987a8f3dc9f8d60c758ffe59
Author: Andrea Cosentino <[email protected]>
AuthorDate: Wed Jul 22 15:02:40 2026 +0200
CAMEL-23876: camel-a2a - pin push notification webhooks to the validated
address (#25004)
Deliver push notifications with Apache HttpClient 5, whose HttpHost carries
both an explicit InetAddress and the hostname: the connection is opened to the
address that passed SSRF validation, while the Host header, TLS SNI and
certificate hostname verification still use the hostname. Validation and
resolution now run on every attempt.
Co-authored-by: Claude Fable 5 <[email protected]>
---
components/camel-ai/camel-a2a/pom.xml | 6 +
.../apache/camel/component/a2a/A2AEndpoint.java | 31 ++++-
.../a2a/push/PushNotificationDispatcher.java | 139 +++++++++++++--------
.../component/a2a/util/WebhookUrlValidator.java | 20 ++-
.../a2a/push/PushNotificationDispatcherTest.java | 42 +++++--
.../push/PushNotificationTargetPinningTest.java | 68 ++++++++++
.../ROOT/pages/camel-4x-upgrade-guide-4_22.adoc | 20 +++
7 files changed, 261 insertions(+), 65 deletions(-)
diff --git a/components/camel-ai/camel-a2a/pom.xml
b/components/camel-ai/camel-a2a/pom.xml
index 96a944a85b67..e4171df1f8e2 100644
--- a/components/camel-ai/camel-a2a/pom.xml
+++ b/components/camel-ai/camel-a2a/pom.xml
@@ -64,6 +64,12 @@
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
+ <!-- used to dispatch push notifications to a connection pinned to the
validated webhook address -->
+ <dependency>
+ <groupId>org.apache.httpcomponents.client5</groupId>
+ <artifactId>httpclient5</artifactId>
+ <version>${httpclient-version}</version>
+ </dependency>
<!-- Test dependencies -->
<dependency>
diff --git
a/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/A2AEndpoint.java
b/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/A2AEndpoint.java
index dc5f8153d934..a0f8cca59ddc 100644
---
a/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/A2AEndpoint.java
+++
b/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/A2AEndpoint.java
@@ -53,6 +53,10 @@ import org.apache.camel.spi.UriParam;
import org.apache.camel.spi.UriPath;
import org.apache.camel.support.DefaultEndpoint;
import org.apache.camel.support.service.ServiceHelper;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.apache.hc.core5.util.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -86,6 +90,7 @@ public class A2AEndpoint extends DefaultEndpoint {
private ScheduledExecutorService pushDispatcherExecutor;
private ExecutorService httpClientExecutor;
private HttpClient httpClient;
+ private CloseableHttpAsyncClient pushWebhookHttpClient;
private Map<String, A2AExtensionHandler> extensionHandlers = Map.of();
private boolean producerCreated;
private boolean consumerCreated;
@@ -180,8 +185,10 @@ public class A2AEndpoint extends DefaultEndpoint {
if (initializeConsumerServices) {
pushDispatcherExecutor =
getCamelContext().getExecutorServiceManager()
.newScheduledThreadPool(this, "A2APushDispatcher", 4);
+ pushWebhookHttpClient = createPushWebhookHttpClient();
+ pushWebhookHttpClient.start();
pushDispatcher = new PushNotificationDispatcher(
- createHttpClient(HttpClient.Redirect.NEVER), taskStore,
+ pushWebhookHttpClient, taskStore,
configuration.getPushRetryAttempts(),
configuration.getPushRetryBackoffMs(),
pushDispatcherExecutor,
@@ -251,6 +258,20 @@ public class A2AEndpoint extends DefaultEndpoint {
.build();
}
+ /**
+ * Creates the client used to deliver push notifications to
caller-registered webhooks. Redirects are never
+ * followed: a redirect would be resolved and connected to by the client
itself, escaping the SSRF validation
+ * applied to the registered URL. The dispatcher pins each request to the
address it validated.
+ */
+ private CloseableHttpAsyncClient createPushWebhookHttpClient() {
+ return HttpAsyncClients.custom()
+ .disableRedirectHandling()
+ .setDefaultRequestConfig(RequestConfig.custom()
+
.setConnectTimeout(Timeout.ofMilliseconds(configuration.getConnectTimeout()))
+ .build())
+ .build();
+ }
+
private void cleanupEndpointResources() {
if (taskStoreOwned && taskStore != null) {
try {
@@ -263,6 +284,14 @@ public class A2AEndpoint extends DefaultEndpoint {
pushDispatcher.shutdown();
}
pushDispatcher = null;
+ if (pushWebhookHttpClient != null) {
+ try {
+ pushWebhookHttpClient.close();
+ } catch (Exception e) {
+ LOG.debug("Error closing A2A push webhook HTTP client: {}",
e.getMessage());
+ }
+ pushWebhookHttpClient = null;
+ }
if (pushDispatcherExecutor != null) {
getCamelContext().getExecutorServiceManager().shutdownNow(pushDispatcherExecutor);
pushDispatcherExecutor = null;
diff --git
a/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/push/PushNotificationDispatcher.java
b/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/push/PushNotificationDispatcher.java
index 344dc73c742b..711714a42361 100644
---
a/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/push/PushNotificationDispatcher.java
+++
b/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/push/PushNotificationDispatcher.java
@@ -16,12 +16,8 @@
*/
package org.apache.camel.component.a2a.push;
-import java.io.IOException;
-import java.io.InputStream;
+import java.net.InetAddress;
import java.net.URI;
-import java.net.http.HttpClient;
-import java.net.http.HttpRequest;
-import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Set;
@@ -44,6 +40,17 @@ import
org.apache.camel.component.a2a.model.TaskPushNotificationConfig;
import org.apache.camel.component.a2a.state.A2ATaskStore;
import org.apache.camel.component.a2a.util.A2AJsonMapper;
import org.apache.camel.component.a2a.util.WebhookUrlValidator;
+import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.async.methods.SimpleRequestProducer;
+import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer;
+import org.apache.hc.client5.http.config.RequestConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.core5.concurrent.FutureCallback;
+import org.apache.hc.core5.http.ContentType;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.util.Timeout;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -62,7 +69,7 @@ public class PushNotificationDispatcher {
private static final long MAX_RETRY_DELAY_MS = TimeUnit.HOURS.toMillis(1);
private static final int MAX_PENDING_WORK = 1024;
- private final HttpClient httpClient;
+ private final CloseableHttpAsyncClient httpClient;
private final A2ATaskStore store;
private final int maxRetries;
private final long initialBackoffMs;
@@ -73,7 +80,7 @@ public class PushNotificationDispatcher {
private final Set<CompletableFuture<?>> inFlightRequests =
ConcurrentHashMap.newKeySet();
private final Set<ScheduledFuture<?>> scheduledRetries =
ConcurrentHashMap.newKeySet();
- public PushNotificationDispatcher(HttpClient httpClient, A2ATaskStore
store,
+ public PushNotificationDispatcher(CloseableHttpAsyncClient httpClient,
A2ATaskStore store,
int maxRetries, long initialBackoffMs,
ScheduledExecutorService executor,
boolean allowLocalWebhookUrls) {
@@ -133,51 +140,90 @@ public class PushNotificationDispatcher {
}
private void dispatchToWebhook(TaskPushNotificationConfig config, byte[]
body) {
- if (closed.get()) {
- return;
- }
- try {
- WebhookUrlValidator.validate(config.getUrl(),
allowLocalWebhookUrls);
- } catch (IllegalArgumentException e) {
- LOG.warn("Skipping invalid push notification webhook for
taskId={}: {}", config.getTaskId(), e.getMessage());
- return;
- }
+ sendWithRetryAttempt(config, body, 0);
+ }
- HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
- .uri(URI.create(config.getUrl()))
- .header("Content-Type", A2AConstants.CONTENT_TYPE)
- .timeout(REQUEST_TIMEOUT)
- .POST(HttpRequest.BodyPublishers.ofByteArray(body));
+ /**
+ * Builds a request target pinned to the address the webhook URL was just
validated against.
+ * <p>
+ * The host is resolved once, by the validator, and the resulting address
is carried on the {@link HttpHost} so the
+ * connection is opened to exactly that address. The original hostname is
kept on the same {@link HttpHost}, so the
+ * {@code Host} header, TLS SNI and certificate hostname verification
still use the hostname. Without this the HTTP
+ * client would resolve the hostname again at connection time and could
reach a different address than the one that
+ * passed the SSRF checks (DNS rebinding).
+ */
+ static HttpHost pinnedTarget(URI uri, InetAddress validatedAddress) {
+ String scheme = uri.getScheme();
+ int port = uri.getPort();
+ if (port == -1) {
+ port = "https".equalsIgnoreCase(scheme) ? 443 : 80;
+ }
+ return new HttpHost(scheme, validatedAddress, uri.getHost(), port);
+ }
+ private SimpleHttpRequest buildRequest(URI uri, TaskPushNotificationConfig
config, byte[] body) {
+ SimpleRequestBuilder requestBuilder = SimpleRequestBuilder.post(uri)
+ .setBody(body, ContentType.parse(A2AConstants.CONTENT_TYPE))
+ .setRequestConfig(RequestConfig.custom()
+
.setResponseTimeout(Timeout.ofMilliseconds(REQUEST_TIMEOUT.toMillis()))
+ .build());
applyAuth(requestBuilder, config);
- HttpRequest request = requestBuilder.build();
-
- sendWithRetry(request, config);
+ return requestBuilder.build();
}
- private void applyAuth(HttpRequest.Builder builder,
TaskPushNotificationConfig config) {
+ private void applyAuth(SimpleRequestBuilder builder,
TaskPushNotificationConfig config) {
AuthenticationInfo auth = config.getAuthentication();
if (auth != null && auth.getScheme() != null && auth.getCredentials()
!= null) {
- builder.header("Authorization", auth.getScheme() + " " +
auth.getCredentials());
+ builder.setHeader("Authorization", auth.getScheme() + " " +
auth.getCredentials());
} else if (config.getToken() != null) {
- builder.header("Authorization", "Bearer " + config.getToken());
+ builder.setHeader("Authorization", "Bearer " + config.getToken());
}
}
- private void sendWithRetry(HttpRequest request, TaskPushNotificationConfig
config) {
- sendWithRetryAttempt(request, config, 0);
- }
-
- private void sendWithRetryAttempt(HttpRequest request,
TaskPushNotificationConfig config, int attempt) {
+ private void sendWithRetryAttempt(TaskPushNotificationConfig config,
byte[] body, int attempt) {
if (closed.get()) {
return;
}
+
+ // Re-validate and re-resolve on every attempt: a config that was safe
when first dispatched can be
+ // rebound between retries, which may be up to an hour apart.
+ URI uri;
+ InetAddress validatedAddress;
+ try {
+ validatedAddress =
WebhookUrlValidator.validateAndResolve(config.getUrl(), allowLocalWebhookUrls);
+ uri = URI.create(config.getUrl());
+ } catch (IllegalArgumentException e) {
+ LOG.warn("Skipping invalid push notification webhook for
taskId={}: {}", config.getTaskId(), e.getMessage());
+ return;
+ }
+
if (!tryAdmitWork(config, "HTTP request")) {
return;
}
- CompletableFuture<HttpResponse<InputStream>> responseFuture;
+
+ CompletableFuture<SimpleHttpResponse> responseFuture = new
CompletableFuture<>();
try {
- responseFuture = httpClient.sendAsync(request,
HttpResponse.BodyHandlers.ofInputStream());
+ httpClient.execute(
+ pinnedTarget(uri, validatedAddress),
+ SimpleRequestProducer.create(buildRequest(uri, config,
body)),
+ SimpleResponseConsumer.create(),
+ null, null,
+ new FutureCallback<>() {
+ @Override
+ public void completed(SimpleHttpResponse response) {
+ responseFuture.complete(response);
+ }
+
+ @Override
+ public void failed(Exception e) {
+ responseFuture.completeExceptionally(e);
+ }
+
+ @Override
+ public void cancelled() {
+ responseFuture.cancel(false);
+ }
+ });
} catch (RuntimeException e) {
releaseWork();
throw e;
@@ -186,14 +232,10 @@ public class PushNotificationDispatcher {
try {
track(responseFuture.whenCompleteAsync((response, failure) -> {
if (closed.get()) {
- if (response != null) {
- closeResponseBody(response.body(), config);
- }
return;
}
- boolean retry = false;
+ boolean retry;
if (failure == null) {
- closeResponseBody(response.body(), config);
retry = shouldRetry(response, config, attempt);
} else {
Throwable cause = failure instanceof CompletionException
&& failure.getCause() != null
@@ -204,7 +246,7 @@ public class PushNotificationDispatcher {
retry = true;
}
if (retry && attempt < maxRetries) {
- scheduleRetry(request, config, attempt);
+ scheduleRetry(config, body, attempt);
} else if (retry) {
LOG.error("Push notification to {} exhausted all {}
retries for taskId={}",
config.getUrl(), maxRetries + 1,
config.getTaskId());
@@ -216,7 +258,7 @@ public class PushNotificationDispatcher {
}
}
- private void scheduleRetry(HttpRequest request, TaskPushNotificationConfig
config, int attempt) {
+ private void scheduleRetry(TaskPushNotificationConfig config, byte[] body,
int attempt) {
if (closed.get()) {
return;
}
@@ -229,7 +271,7 @@ public class PushNotificationDispatcher {
ScheduledFuture<?> scheduled = executor.schedule(() -> {
scheduledRetries.remove(scheduledRef.get());
releaseWork();
- sendWithRetryAttempt(request, config, attempt + 1);
+ sendWithRetryAttempt(config, body, attempt + 1);
}, delay, TimeUnit.MILLISECONDS);
scheduledRef.set(scheduled);
scheduledRetries.add(scheduled);
@@ -287,8 +329,8 @@ public class PushNotificationDispatcher {
pendingWork.updateAndGet(current -> current > 0 ? current - 1 : 0);
}
- private boolean shouldRetry(HttpResponse<InputStream> response,
TaskPushNotificationConfig config, int attempt) {
- int status = response.statusCode();
+ private boolean shouldRetry(SimpleHttpResponse response,
TaskPushNotificationConfig config, int attempt) {
+ int status = response.getCode();
if (status >= 200 && status < 300) {
LOG.debug("Push notification delivered to {} for taskId={}",
@@ -329,13 +371,4 @@ public class PushNotificationDispatcher {
executor.shutdownNow();
}
}
-
- private static void closeResponseBody(InputStream body,
TaskPushNotificationConfig config) {
- try {
- body.close();
- } catch (IOException e) {
- LOG.debug("Failed to close push notification response body for
taskId={}: {}",
- config.getTaskId(), e.getMessage());
- }
- }
}
diff --git
a/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/util/WebhookUrlValidator.java
b/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/util/WebhookUrlValidator.java
index 36f8a1b116fe..cd8a41787584 100644
---
a/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/util/WebhookUrlValidator.java
+++
b/components/camel-ai/camel-a2a/src/main/java/org/apache/camel/component/a2a/util/WebhookUrlValidator.java
@@ -46,6 +46,22 @@ public final class WebhookUrlValidator {
* @throws IllegalArgumentException if the URL is invalid or unsafe
*/
public static void validate(String url, boolean allowLocal) {
+ validateAndResolve(url, allowLocal);
+ }
+
+ /**
+ * Validates a webhook URL for SSRF protection and returns the address the
host resolved to during validation.
+ * <p>
+ * Callers that go on to open a connection should connect to the returned
address rather than letting the HTTP
+ * client resolve the hostname again, otherwise the address that was
validated and the address actually connected to
+ * can differ for the same hostname (DNS rebinding), defeating the checks
performed here.
+ *
+ * @param url the URL to validate
+ * @param allowLocal whether to permit loopback/localhost
addresses (dev mode)
+ * @return the validated address the host
resolved to
+ * @throws IllegalArgumentException if the URL is invalid or unsafe
+ */
+ public static InetAddress validateAndResolve(String url, boolean
allowLocal) {
if (url == null || url.isBlank()) {
throw new IllegalArgumentException("Webhook URL must not be null
or empty");
}
@@ -92,7 +108,7 @@ public final class WebhookUrlValidator {
+ ". Set
allowLocalWebhookUrls=true for local development.");
}
// Loopback allowed — skip remaining network checks, permit HTTP
- return;
+ return address;
}
// Non-loopback: require HTTPS
@@ -112,6 +128,8 @@ public final class WebhookUrlValidator {
throw new IllegalArgumentException(
"Webhook URL must not point to a site-local/private
address (SSRF protection): " + host);
}
+
+ return address;
}
private static boolean isPrivateIpv6(String host) {
diff --git
a/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/push/PushNotificationDispatcherTest.java
b/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/push/PushNotificationDispatcherTest.java
index d6715f72c67c..5f629ed8d7d7 100644
---
a/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/push/PushNotificationDispatcherTest.java
+++
b/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/push/PushNotificationDispatcherTest.java
@@ -18,7 +18,8 @@ package org.apache.camel.component.a2a.push;
import java.lang.reflect.Field;
import java.net.InetSocketAddress;
-import java.net.http.HttpClient;
+import java.util.ArrayList;
+import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
@@ -34,6 +35,8 @@ import org.apache.camel.component.a2a.model.TaskState;
import org.apache.camel.component.a2a.model.TaskStatus;
import org.apache.camel.component.a2a.model.TaskStatusUpdateEvent;
import org.apache.camel.component.a2a.state.InMemoryTaskStore;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -49,6 +52,17 @@ class PushNotificationDispatcherTest {
private InMemoryTaskStore store;
private PushNotificationDispatcher dispatcher;
private ScheduledExecutorService executor;
+ private final List<CloseableHttpAsyncClient> httpClients = new
ArrayList<>();
+
+ /**
+ * Creates and starts an async HTTP client, tracked so it is closed after
the test.
+ */
+ private CloseableHttpAsyncClient startedClient() {
+ CloseableHttpAsyncClient client =
HttpAsyncClients.custom().disableRedirectHandling().build();
+ client.start();
+ httpClients.add(client);
+ return client;
+ }
@BeforeEach
void setUp() throws Exception {
@@ -70,6 +84,14 @@ class PushNotificationDispatcherTest {
if (mockServer != null) {
mockServer.stop(0);
}
+ for (CloseableHttpAsyncClient client : httpClients) {
+ try {
+ client.close();
+ } catch (Exception e) {
+ // ignore on teardown
+ }
+ }
+ httpClients.clear();
}
@Test
@@ -87,7 +109,7 @@ class PushNotificationDispatcherTest {
mockServer.start();
dispatcher = new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 0, 1000, executor, true);
+ startedClient(), store, 0, 1000, executor, true);
TaskPushNotificationConfig config = new TaskPushNotificationConfig();
config.setUrl("http://localhost:" + port + "/webhook");
@@ -122,7 +144,7 @@ class PushNotificationDispatcherTest {
mockServer.start();
dispatcher = new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 3, 50, executor, true);
+ startedClient(), store, 3, 50, executor, true);
TaskPushNotificationConfig config = new TaskPushNotificationConfig();
config.setUrl("http://localhost:" + port + "/webhook");
@@ -149,7 +171,7 @@ class PushNotificationDispatcherTest {
mockServer.start();
dispatcher = new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 0, 1000, executor, true);
+ startedClient(), store, 0, 1000, executor, true);
TaskPushNotificationConfig config = new TaskPushNotificationConfig();
config.setUrl("http://localhost:" + port + "/webhook");
@@ -179,7 +201,7 @@ class PushNotificationDispatcherTest {
mockServer.start();
dispatcher = new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 3, 50, executor, true);
+ startedClient(), store, 3, 50, executor, true);
TaskPushNotificationConfig config = new TaskPushNotificationConfig();
config.setUrl("http://localhost:" + port + "/webhook");
@@ -206,7 +228,7 @@ class PushNotificationDispatcherTest {
mockServer.start();
dispatcher = new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 1, 5000, executor, true);
+ startedClient(), store, 1, 5000, executor, true);
TaskPushNotificationConfig config = new TaskPushNotificationConfig();
config.setUrl("http://localhost:" + port + "/webhook");
@@ -243,7 +265,7 @@ class PushNotificationDispatcherTest {
mockServer.start();
dispatcher = new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 0, 1000, executor, true);
+ startedClient(), store, 0, 1000, executor, true);
for (int i = 1; i <= 3; i++) {
TaskPushNotificationConfig config = new
TaskPushNotificationConfig();
@@ -263,7 +285,7 @@ class PushNotificationDispatcherTest {
@Test
void dispatchSkipsWhenNoConfigs() {
dispatcher = new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 0, 1000, executor, true);
+ startedClient(), store, 0, 1000, executor, true);
TaskStatusUpdateEvent event = TaskStatusUpdateEvent.builder()
.taskId("task-1")
@@ -275,7 +297,7 @@ class PushNotificationDispatcherTest {
@Test
void rejectsTightRetryLoopConfiguration() {
assertThatThrownBy(() -> new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 1, 0, executor, true))
+ startedClient(), store, 1, 0, executor, true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("initialBackoffMs");
}
@@ -293,7 +315,7 @@ class PushNotificationDispatcherTest {
mockServer.start();
dispatcher = new PushNotificationDispatcher(
- HttpClient.newHttpClient(), store, 0, 1000, executor, true);
+ startedClient(), store, 0, 1000, executor, true);
TaskPushNotificationConfig config = new TaskPushNotificationConfig();
config.setUrl("http://localhost:" + port + "/webhook");
diff --git
a/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/push/PushNotificationTargetPinningTest.java
b/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/push/PushNotificationTargetPinningTest.java
new file mode 100644
index 000000000000..a9772112af71
--- /dev/null
+++
b/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/push/PushNotificationTargetPinningTest.java
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.camel.component.a2a.push;
+
+import java.net.InetAddress;
+import java.net.URI;
+
+import org.apache.hc.core5.http.HttpHost;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Verifies that a push notification request is targeted at the address the
webhook URL was validated against, while
+ * still carrying the original hostname so the Host header, TLS SNI and
certificate verification are unaffected.
+ */
+class PushNotificationTargetPinningTest {
+
+ @Test
+ void targetCarriesValidatedAddressAndOriginalHostname() throws Exception {
+ InetAddress validated = InetAddress.getByName("203.0.113.10");
+
+ HttpHost target = PushNotificationDispatcher.pinnedTarget(
+ URI.create("https://webhook.example.com/events"), validated);
+
+ // the connection is opened to the address that passed the SSRF
checks, not to a fresh DNS lookup
+ assertThat(target.getAddress()).isEqualTo(validated);
+ // the hostname is preserved for Host header / TLS SNI / certificate
hostname verification
+ assertThat(target.getHostName()).isEqualTo("webhook.example.com");
+ assertThat(target.getSchemeName()).isEqualTo("https");
+ assertThat(target.getPort()).isEqualTo(443);
+ }
+
+ @Test
+ void targetUsesDefaultPortPerScheme() throws Exception {
+ InetAddress validated = InetAddress.getByName("203.0.113.11");
+
+
assertThat(PushNotificationDispatcher.pinnedTarget(URI.create("https://host.example/x"),
validated).getPort())
+ .isEqualTo(443);
+
assertThat(PushNotificationDispatcher.pinnedTarget(URI.create("http://host.example/x"),
validated).getPort())
+ .isEqualTo(80);
+ }
+
+ @Test
+ void targetHonoursExplicitPort() throws Exception {
+ InetAddress validated = InetAddress.getByName("203.0.113.12");
+
+ HttpHost target = PushNotificationDispatcher.pinnedTarget(
+ URI.create("https://host.example:8443/x"), validated);
+
+ assertThat(target.getPort()).isEqualTo(8443);
+ assertThat(target.getAddress()).isEqualTo(validated);
+ }
+}
diff --git
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
index 1d83c20e43a7..6d822f0742dc 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_22.adoc
@@ -735,6 +735,26 @@ without being secrets — `adjustAuthorization`,
`jwtAuthorizationType`, `proxyA
have their values shown as `xxxxxx` in sanitized URIs. This is a cosmetic
change to sanitized output only and
does not affect the actual endpoint configuration.
+=== camel-a2a - push notification webhooks pinned to the validated address
+
+Push notifications are now delivered with Apache HttpClient 5 instead of
`java.net.http.HttpClient`, so the request
+can be pinned to the address the webhook URL was validated against.
+
+Previously `WebhookUrlValidator` resolved the webhook host and applied the
SSRF checks, and the HTTP client then
+resolved the same hostname again when it opened the connection. The two
lookups are independent, so the address that
+was validated and the address actually connected to could differ (DNS
rebinding). The connection is now opened to the
+address that passed validation, while the hostname is still used for the
`Host` header, TLS SNI and certificate
+hostname verification. Each retry attempt re-validates and re-resolves, so a
config that was safe when first
+dispatched cannot be rebound between retries (which may be up to an hour
apart).
+
+`camel-a2a` therefore has a new dependency on
`org.apache.httpcomponents.client5:httpclient5`.
+
+The `PushNotificationDispatcher` constructor now takes an
+`org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient` instead of a
`java.net.http.HttpClient`. This only
+affects code constructing the dispatcher directly; the client must be started
before use. Endpoint behaviour is
+unchanged — redirects were already never followed for push webhooks, and still
are not, since a redirect would be
+resolved by the client and escape the validation applied to the registered URL.
+
=== camel-core - Multicast UseOriginalAggregationStrategy fix
The Multicast EIP now correctly honors `UseOriginalAggregationStrategy`,
consistent with the Splitter