From 8eebbc5a98b9e07001d8c6125331dec2214ff6a4 Mon Sep 17 00:00:00 2001 From: Andrea Cosentino Date: Fri, 7 Aug 2026 18:25:00 +0200 Subject: [PATCH] CAMEL-24371: camel-a2a - fix WebhookUrlValidator address classification and host matching 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) Signed-off-by: Andrea Cosentino --- .../a2a/util/WebhookUrlValidator.java | 147 +++++++++++++---- .../a2a/util/WebhookUrlValidatorTest.java | 156 +++++++++++++++++- .../pages/camel-4x-upgrade-guide-4_23.adoc | 41 +++++ .../ROOT/pages/camel-4x-upgrade-guide.adoc | 1 + 4 files changed, 307 insertions(+), 38 deletions(-) create mode 100644 docs/user-manual/modules/ROOT/pages/camel-4x-upgrade-guide-4_23.adoc 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 cd8a41787584a..2f6a1b609a500 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.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 static void validate(String url, boolean allowLocal) { * @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 static InetAddress validateAndResolve(String url, boolean allowLocal) { 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 static InetAddress validateAndResolve(String url, boolean allowLocal) { 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. + *

+ * {@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 5f09cc53b1642..a2c275fba6b65 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 @@ 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 @@ void rejectsLoopbackIpByDefault() { void rejectsIpv6LoopbackByDefault() { assertThatThrownBy(() -> WebhookUrlValidator.validate("https://[::1]:8080/webhook")) .isInstanceOf(IllegalArgumentException.class) - .hasMessageContaining("IPv6"); + .hasMessageContaining("loopback"); } // ---- Loopback allowed with flag ---- @@ -117,6 +128,28 @@ void rejectsLinkLocalRange() { .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 @@ void rejectsNullOrEmpty() { .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 @@ void privateIpStillBlockedWhenLocalAllowed() { .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 0000000000000..37cdd6818fb9d --- /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 f9ed76dce6c0e..100d23213d90c 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] ====