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]
====