This is an automated email from the ASF dual-hosted git repository.
oscerd pushed a commit to branch camel-4.22.x
in repository https://gitbox.apache.org/repos/asf/camel.git
The following commit(s) were added to refs/heads/camel-4.22.x by this push:
new c4a5c5fc9687 CAMEL-24371: camel-a2a - fix WebhookUrlValidator address
classification and host matching (backport camel-4.22.x) (#26583)
c4a5c5fc9687 is described below
commit c4a5c5fc968763474d0c6b0f8d840cee68c09142
Author: Andrea Cosentino <[email protected]>
AuthorDate: Fri Sep 18 13:25:57 2026 +0200
CAMEL-24371: camel-a2a - fix WebhookUrlValidator address classification and
host matching (backport camel-4.22.x) (#26583)
Backport of #25406 to camel-4.22.x.
Co-authored-by: Claude Opus 4.8 <[email protected]>
---
.../component/a2a/util/WebhookUrlValidator.java | 147 ++++++++++++++-----
.../a2a/util/WebhookUrlValidatorTest.java | 156 ++++++++++++++++++++-
2 files changed, 265 insertions(+), 38 deletions(-)
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 cd8a41787584..2f6a1b609a50 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
@@ -20,14 +20,22 @@ import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
+import java.util.Arrays;
/**
- * Validates webhook URLs for SSRF protection in A2A push notifications. All
hostnames (including {@code localhost}) are
- * resolved to their IP address and classified consistently. Loopback
addresses are blocked by default; set
- * {@code allowLocal=true} for local development.
+ * Validates webhook URLs for SSRF protection in A2A push notifications. Every
host, whether it is written as an IP
+ * literal or as a name that has to be resolved, is turned into an address and
classified by the same rules, so the two
+ * forms can never disagree. Loopback addresses are blocked by default; set
{@code allowLocal=true} for local
+ * development.
*/
public final class WebhookUrlValidator {
+ private static final int IPV4_LENGTH = 4;
+
+ /** 64:ff9b::/96, the well-known prefix for IPv4/IPv6 translation (RFC
6052). */
+ private static final byte[] NAT64_WELL_KNOWN_PREFIX
+ = { 0x00, 0x64, (byte) 0xff, (byte) 0x9b, 0, 0, 0, 0, 0, 0, 0, 0 };
+
private WebhookUrlValidator() {
}
@@ -62,6 +70,13 @@ public final class WebhookUrlValidator {
* @throws IllegalArgumentException if the URL is invalid or unsafe
*/
public static InetAddress validateAndResolve(String url, boolean
allowLocal) {
+ return validateAndResolve(url, allowLocal, InetAddress::getByName);
+ }
+
+ /**
+ * Validates against a supplied resolver, so the resolved-host path can be
exercised without depending on DNS.
+ */
+ static InetAddress validateAndResolve(String url, boolean allowLocal,
HostResolver resolver) {
if (url == null || url.isBlank()) {
throw new IllegalArgumentException("Webhook URL must not be null
or empty");
}
@@ -86,16 +101,11 @@ public final class WebhookUrlValidator {
throw new IllegalArgumentException("Webhook URL must have a host");
}
- // Block private IPv6 ranges before DNS resolution
- if (isPrivateIpv6(host)) {
- throw new IllegalArgumentException(
- "Webhook URL must not point to private/internal IPv6
ranges (SSRF protection): " + host);
- }
-
- // Resolve hostname to IP — treats localhost, 127.0.0.1, and any
hostname the same way
+ // Resolve the host to an IP — IP literals, localhost and any other
name are all treated the same way,
+ // and the address that comes back is what the remaining checks
classify
InetAddress address;
try {
- address = InetAddress.getByName(host);
+ address = resolver.resolve(host);
} catch (UnknownHostException e) {
throw new IllegalArgumentException(
"Webhook URL host cannot be resolved: " + host, e);
@@ -116,42 +126,111 @@ public final class WebhookUrlValidator {
throw new IllegalArgumentException("Webhook URL must use HTTPS for
non-localhost hosts");
}
- if (address.isAnyLocalAddress()) {
+ String reason = nonGlobalReason(address);
+ if (reason != null) {
throw new IllegalArgumentException(
- "Webhook URL must not point to a wildcard address (SSRF
protection): " + host);
+ "Webhook URL must not point to a " + reason + " address
(SSRF protection): " + host);
+ }
+
+ return address;
+ }
+
+ /**
+ * Describes the non-global range an address falls in, or returns {@code
null} when it is an ordinary globally
+ * routable address.
+ * <p>
+ * {@link InetAddress} carries predicates for most of these ranges but not
all of them:
+ * {@link InetAddress#isSiteLocalAddress()} reports only the deprecated
{@code fec0::/10} block and not the
+ * {@code fc00::/7} unique local addresses that replaced it, there is no
predicate for the shared address space, and
+ * none for the transition mechanisms that carry an IPv4 address inside an
IPv6 one. Those are classified here from
+ * the raw address bytes.
+ */
+ static String nonGlobalReason(InetAddress address) {
+ // Loopback is reported here for the sake of the addresses that carry
an IPv4 address inside an IPv6 one:
+ // a host reaching loopback directly is answered earlier, where
allowLocal can let it through
+ if (address.isLoopbackAddress()) {
+ return "loopback";
+ }
+ if (address.isAnyLocalAddress()) {
+ return "wildcard";
}
if (address.isLinkLocalAddress()) {
- throw new IllegalArgumentException(
- "Webhook URL must not point to a link-local address (SSRF
protection): " + host);
+ return "link-local";
}
if (address.isSiteLocalAddress()) {
- throw new IllegalArgumentException(
- "Webhook URL must not point to a site-local/private
address (SSRF protection): " + host);
+ return "site-local/private";
}
+ byte[] bytes = address.getAddress();
+ return bytes.length == IPV4_LENGTH ? ipv4Reason(bytes) :
ipv6Reason(bytes);
+ }
- return address;
+ private static String ipv4Reason(byte[] bytes) {
+ // 100.64.0.0/10, the shared address space used for carrier-grade NAT
(RFC 6598)
+ if ((bytes[0] & 0xff) == 100 && (bytes[1] & 0xc0) == 0x40) {
+ return "carrier-grade NAT";
+ }
+ return null;
}
- private static boolean isPrivateIpv6(String host) {
- String lower = host.toLowerCase();
- if (lower.startsWith("[") && lower.endsWith("]")) {
- lower = lower.substring(1, lower.length() - 1);
+ private static String ipv6Reason(byte[] bytes) {
+ // fc00::/7, the unique local addresses that replaced the deprecated
fec0::/10 site-local block
+ if ((bytes[0] & 0xfe) == 0xfc) {
+ return "unique local";
+ }
+ // ::a.b.c.d and ::ffff:a.b.c.d hold an IPv4 address in the low 32 bits
+ if (isZero(bytes, 0, 10) && (isZero(bytes, 10, 12) || isOnes(bytes,
10, 12))) {
+ return embeddedIpv4Reason(bytes, 12);
}
- if (lower.equals("::1")) {
- return true;
+ // 64:ff9b::/96 translates an IPv4 address held in the low 32 bits
+ if (hasPrefix(bytes, NAT64_WELL_KNOWN_PREFIX)) {
+ return embeddedIpv4Reason(bytes, 12);
}
- // Unique Local Address (fc00::/7)
- if (lower.startsWith("fc") || lower.startsWith("fd")) {
- return true;
+ // 2002::/16 carries the IPv4 address of the 6to4 endpoint in bytes 2
to 5
+ if ((bytes[0] & 0xff) == 0x20 && (bytes[1] & 0xff) == 0x02) {
+ return embeddedIpv4Reason(bytes, 2);
}
- // Link-local (fe80::/10)
- if (lower.startsWith("fe80:")) {
- return true;
+ return null;
+ }
+
+ private static String embeddedIpv4Reason(byte[] bytes, int offset) {
+ InetAddress embedded;
+ try {
+ embedded = InetAddress.getByAddress(Arrays.copyOfRange(bytes,
offset, offset + IPV4_LENGTH));
+ } catch (UnknownHostException e) {
+ // Not reachable: getByAddress only rejects arrays that are
neither 4 nor 16 bytes long
+ throw new IllegalStateException("Unexpected address length", e);
+ }
+ String reason = nonGlobalReason(embedded);
+ return reason == null ? null : reason + " (embedded IPv4)";
+ }
+
+ private static boolean isZero(byte[] bytes, int from, int to) {
+ for (int i = from; i < to; i++) {
+ if (bytes[i] != 0) {
+ return false;
+ }
}
- // IPv4-mapped IPv6 (::ffff:x.x.x.x)
- if (lower.startsWith("::ffff:")) {
- return true;
+ return true;
+ }
+
+ private static boolean isOnes(byte[] bytes, int from, int to) {
+ for (int i = from; i < to; i++) {
+ if (bytes[i] != (byte) 0xff) {
+ return false;
+ }
}
- return false;
+ return true;
+ }
+
+ private static boolean hasPrefix(byte[] bytes, byte[] prefix) {
+ return Arrays.equals(bytes, 0, prefix.length, prefix, 0,
prefix.length);
+ }
+
+ /**
+ * Resolves a host, which may be a name or an IP literal, to the address a
connection would be opened to.
+ */
+ @FunctionalInterface
+ interface HostResolver {
+ InetAddress resolve(String host) throws UnknownHostException;
}
}
diff --git
a/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/util/WebhookUrlValidatorTest.java
b/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/util/WebhookUrlValidatorTest.java
index 5f09cc53b164..a2c275fba6b6 100644
---
a/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/util/WebhookUrlValidatorTest.java
+++
b/components/camel-ai/camel-a2a/src/test/java/org/apache/camel/component/a2a/util/WebhookUrlValidatorTest.java
@@ -16,6 +16,9 @@
*/
package org.apache.camel.component.a2a.util;
+import java.net.InetAddress;
+
+import org.apache.camel.component.a2a.util.WebhookUrlValidator.HostResolver;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatNoException;
@@ -23,6 +26,14 @@ import static
org.assertj.core.api.Assertions.assertThatThrownBy;
class WebhookUrlValidatorTest {
+ /**
+ * A resolver that maps any host to a fixed address, so the resolved-host
path can be exercised without depending on
+ * DNS. The address carries the original host name, exactly as a real
lookup would return it.
+ */
+ private static HostResolver resolvingTo(String literal) {
+ return host -> InetAddress.getByAddress(host,
InetAddress.getByName(literal).getAddress());
+ }
+
@Test
void acceptsHttpsUrl() {
assertThatNoException()
@@ -49,7 +60,7 @@ class WebhookUrlValidatorTest {
void rejectsIpv6LoopbackByDefault() {
assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[::1]:8080/webhook"))
.isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("IPv6");
+ .hasMessageContaining("loopback");
}
// ---- Loopback allowed with flag ----
@@ -117,6 +128,28 @@ class WebhookUrlValidatorTest {
.hasMessageContaining("link-local");
}
+ @Test
+ void rejectsSharedAddressSpace() {
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://100.64.0.1/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("carrier-grade NAT");
+
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://100.127.255.254/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("carrier-grade NAT");
+ }
+
+ @Test
+ void acceptsAddressesAdjacentToSharedAddressSpace() {
+ assertThatNoException()
+ .isThrownBy(() -> WebhookUrlValidator.validateAndResolve(
+ "https://webhook.example/hook", false,
resolvingTo("100.63.255.255")));
+
+ assertThatNoException()
+ .isThrownBy(() -> WebhookUrlValidator.validateAndResolve(
+ "https://webhook.example/hook", false,
resolvingTo("100.128.0.1")));
+ }
+
@Test
void rejectsUnresolvableHost() {
assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://this-host-does-not-exist-xyzzy.invalid/webhook"))
@@ -132,25 +165,133 @@ class WebhookUrlValidatorTest {
.isInstanceOf(IllegalArgumentException.class);
}
+ // ---- IPv6 ranges ----
+
@Test
void rejectsIpv6UniqueLocalAddress() {
assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[fd00::1]/webhook"))
.isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("IPv6");
+ .hasMessageContaining("unique local");
+
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[fc00::1]/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unique local");
+
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[fdff:ffff::1]/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unique local");
+ }
+
+ /**
+ * A unique local address must be rejected when it is reached through a
host name, not only when it is written as a
+ * literal. {@code InetAddress#isSiteLocalAddress} does not report {@code
fc00::/7}, so this is the case that a
+ * predicate-only classification lets through.
+ */
+ @Test
+ void rejectsHostnameResolvingToUniqueLocalAddress() {
+ assertThatThrownBy(() -> WebhookUrlValidator.validateAndResolve(
+ "https://webhook.example/hook", false, resolvingTo("fd00::1")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unique local");
+
+ assertThatThrownBy(() -> WebhookUrlValidator.validateAndResolve(
+ "https://webhook.example/hook", false, resolvingTo("fc00::1")))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unique local");
+ }
+
+ @Test
+ void rejectsIpv6SiteLocalAddress() {
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[fec0::1]/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("site-local");
}
@Test
void rejectsIpv6LinkLocal() {
assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[fe80::1]/webhook"))
.isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("IPv6");
+ .hasMessageContaining("link-local");
+ }
+
+ @Test
+ void rejectsIpv6Wildcard() {
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[::]/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("wildcard");
}
@Test
void rejectsIpv4MappedIpv6() {
assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[::ffff:10.0.0.1]/webhook"))
.isInstanceOf(IllegalArgumentException.class)
- .hasMessageContaining("IPv6");
+ .hasMessageContaining("site-local");
+ }
+
+ @Test
+ void rejectsIpv4CompatibleIpv6() {
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[::10.0.0.1]/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("site-local/private (embedded IPv4)");
+ }
+
+ // ---- Transition mechanisms that carry an IPv4 address ----
+
+ @Test
+ void rejectsNat64EmbeddingPrivateIpv4() {
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[64:ff9b::a00:1]/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("site-local/private (embedded IPv4)");
+ }
+
+ @Test
+ void rejectsNat64EmbeddingLoopback() {
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[64:ff9b::7f00:1]/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("loopback (embedded IPv4)");
+ }
+
+ /**
+ * NAT64 is how an IPv6-only network reaches the IPv4 internet, so a
prefix carrying a public address stays allowed.
+ */
+ @Test
+ void acceptsNat64EmbeddingPublicIpv4() {
+ assertThatNoException()
+ .isThrownBy(() ->
WebhookUrlValidator.validate("https://[64:ff9b::808:808]/webhook"));
+ }
+
+ @Test
+ void rejects6to4EmbeddingPrivateIpv4() {
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[2002:c0a8:101::1]/webhook"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("site-local/private (embedded IPv4)");
+ }
+
+ @Test
+ void accepts6to4EmbeddingPublicIpv4() {
+ assertThatNoException()
+ .isThrownBy(() ->
WebhookUrlValidator.validate("https://[2002:808:808::1]/webhook"));
+ }
+
+ // ---- Host names are not classified by their spelling ----
+
+ /**
+ * Host names are classified by the address they resolve to, never by how
they are spelled. Names beginning with the
+ * hex digits of the IPv6 private prefixes, such as {@code fcm.} or {@code
fd-}, are ordinary public host names.
+ */
+ @Test
+ void acceptsHostnamesSpelledLikePrivateIpv6Prefixes() {
+ assertThatNoException()
+ .isThrownBy(() -> WebhookUrlValidator.validateAndResolve(
+ "https://fcm.example.test/webhook", false,
resolvingTo("93.184.216.34")));
+
+ assertThatNoException()
+ .isThrownBy(() -> WebhookUrlValidator.validateAndResolve(
+ "https://fd-edge.example.test/webhook", false,
resolvingTo("93.184.216.34")));
+
+ assertThatNoException()
+ .isThrownBy(() -> WebhookUrlValidator.validateAndResolve(
+ "https://fe80-cdn.example.test/webhook", false,
resolvingTo("93.184.216.34")));
}
// ---- Private ranges still blocked even with allowLocal ----
@@ -161,4 +302,11 @@ class WebhookUrlValidatorTest {
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("site-local");
}
+
+ @Test
+ void uniqueLocalStillBlockedWhenLocalAllowed() {
+ assertThatThrownBy(() ->
WebhookUrlValidator.validate("https://[fd00::1]/webhook", true))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("unique local");
+ }
}