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