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 54041cce5453 CAMEL-24371: camel-a2a - fix WebhookUrlValidator address 
classification and host matching (#25406)
54041cce5453 is described below

commit 54041cce545322f7ff1bb362ecc02999b3d96ba0
Author: Andrea Cosentino <[email protected]>
AuthorDate: Sun Aug 9 18:57:39 2026 +0200

    CAMEL-24371: camel-a2a - fix WebhookUrlValidator address classification and 
host matching (#25406)
    
    WebhookUrlValidator classified webhook hosts in two places that did not 
agree.
    A host written as an IP literal was checked against a string prefix list, 
while a
    host reached through a name was classified with the InetAddress predicates. 
Those
    predicates do not cover the same ground: isSiteLocalAddress reports the 
deprecated
    fec0::/10 block and not the fc00::/7 unique local addresses that replaced 
it, so
    the same address was accepted or rejected depending on how it was written.
    
    The literal pre-check also prefix-matched the raw host string without 
establishing
    that the host was an IP literal, so any name beginning with fc or fd, such 
as
    fcm.googleapis.com, was rejected outright.
    
    Both paths now resolve the host and classify the resulting address with one 
shared
    raw-byte classifier. InetAddress.getByName already parses bracketed IPv6 
literals
    without touching DNS, so the separate literal path is no longer needed. The
    classifier additionally recognises fc00::/7, IPv4-compatible IPv6, the NAT64
    well-known prefix 64:ff9b::/96, 6to4 under 2002::/16 and the shared address 
space
    100.64.0.0/10. NAT64 and 6to4 addresses are classified by the IPv4 address 
they
    embed, so a translation prefix carrying a globally routable address stays 
allowed.
    
    A package-private resolver seam lets the resolved-host path be tested 
without DNS.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    Signed-off-by: Andrea Cosentino <[email protected]>
---
 .../component/a2a/util/WebhookUrlValidator.java    | 147 ++++++++++++++-----
 .../a2a/util/WebhookUrlValidatorTest.java          | 156 ++++++++++++++++++++-
 .../ROOT/pages/camel-4x-upgrade-guide-4_23.adoc    |  41 ++++++
 .../modules/ROOT/pages/camel-4x-upgrade-guide.adoc |   1 +
 4 files changed, 307 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");
+    }
 }
diff --git 
a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
new file mode 100644
index 000000000000..37cdd6818fb9
--- /dev/null
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc
@@ -0,0 +1,41 @@
+= Apache Camel 4.x Upgrade Guide
+
+This document is for helping you upgrade your Apache Camel application
+from Camel 4.x to 4.y. For example, if you are upgrading Camel 4.0 to 4.2, 
then you should follow the guides
+from both 4.0 to 4.1 and 4.1 to 4.2.
+
+[NOTE]
+====
+https://github.com/apache/camel-upgrade-recipes/[The Camel Upgrade Recipes 
project] provides automated assistance for some common migration tasks.
+Note that manual migration is still required.
+See the xref:camel-upgrade-recipes-tool.adoc[documentation] page for details.
+====
+
+== Upgrading Camel 4.22 to 4.23
+
+=== camel-a2a - webhook URL address classification
+
+Push notification webhook URLs are now classified by the address the host 
resolves to, using the
+same rules whether the host is written as an IP literal or as a name. 
Previously a few ranges were
+recognised only in literal form, and host names were partly classified by how 
they were spelled.
+
+Webhook URLs are now rejected when the host resolves into any of the 
following, in addition to the
+loopback, wildcard, link-local and site-local ranges that were already 
rejected:
+
+* IPv6 unique local addresses, `fc00::/7`
+* IPv4-compatible IPv6 addresses, `::a.b.c.d`, when the embedded IPv4 address 
is itself non-global
+* NAT64 addresses under the well-known prefix `64:ff9b::/96`, when the 
embedded IPv4 address is
+  itself non-global
+* 6to4 addresses under `2002::/16`, when the embedded IPv4 address is itself 
non-global
+* The shared address space used for carrier-grade NAT, `100.64.0.0/10`
+
+NAT64 and 6to4 addresses carrying a globally routable IPv4 address remain 
allowed, so an IPv6-only
+deployment can still reach public webhook endpoints through a translation 
prefix.
+
+In the other direction, host names are no longer rejected on the basis of 
their spelling. Names
+beginning with `fc` or `fd`, such as `fcm.googleapis.com`, were previously 
refused because those are
+the leading hex digits of the IPv6 unique local prefixes; they are now 
resolved and classified like
+any other name.
+
+Set `allowLocalWebhookUrls=true` to permit loopback targets during local 
development. That option is
+unchanged and still does not permit any of the ranges above.
diff --git a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide.adoc 
b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide.adoc
index f9ed76dce6c0..100d23213d90 100644
--- a/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide.adoc
+++ b/docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide.adoc
@@ -31,6 +31,7 @@ You can find the upgrade guide for each release in the 
following pages:
 - xref:camel-4x-upgrade-guide-4_20.adoc[Upgrade guide 4.19 -> 4.20]
 - xref:camel-4x-upgrade-guide-4_21.adoc[Upgrade guide 4.20 -> 4.21]
 - xref:camel-4x-upgrade-guide-4_22.adoc[Upgrade guide 4.21 -> 4.22]
+- xref:camel-4x-upgrade-guide-4_23.adoc[Upgrade guide 4.22 -> 4.23]
 
 [NOTE]
 ====

Reply via email to