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");
+    }
 }

Reply via email to