diff --git a/app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java b/app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java index 196f5dac5b5b..1295b8e6ac3f 100644 --- a/app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java +++ b/app/server/appsmith-interfaces/src/main/java/com/appsmith/util/WebClientUtils.java @@ -41,6 +41,13 @@ public class WebClientUtils { private static final Set DISALLOWED_HOSTS = computeDisallowedHosts(); + // Opt-in strict egress mode (GHSA-4fjr-w826-cpwm). When enabled, outbound datasource/HTTP + // requests are additionally blocked from reaching loopback and RFC 1918 private ranges on ALL + // deployment types. Default is off so existing self-hosted deployments that legitimately connect + // datasources to internal/private hosts are not broken on upgrade. + private static final boolean BLOCK_PRIVATE_ADDRESSES = + "true".equalsIgnoreCase(System.getenv("APPSMITH_SSRF_BLOCK_PRIVATE_ADDRESS")); + public static final String HOST_NOT_ALLOWED = "Host not allowed."; private static final int MAX_IN_MEMORY_SIZE_IN_BYTES = 16 * 1024 * 1024; @@ -239,9 +246,50 @@ public static Optional resolveIfAllowed(String host) { return Optional.of(resolved[0]); } + /** + * Datasource-facing SSRF resolver for plugins that connect outside the WebClient pipeline (e.g. + * the SMTP plugin via JavaMail — GHSA-72m2-f9xp-wg9h). Mirrors the WebClient HTTP egress policy: + * the cloud-metadata denylist and the always-blocked address classes (link-local/IMDS, + * any-local, multicast, IPv6 ULA) are rejected on every deployment, while loopback is rejected + * only inside Docker or under strict egress, and RFC 1918 site-local only under strict egress. + * + *

Unlike {@link #resolveIfAllowed}, loopback and RFC 1918 are allowed by default so that + * self-hosted mail servers on private networks continue to work. Returns the resolved + * {@link InetAddress} so callers can pin the connection to it and defeat DNS-rebinding. + * + * @return the resolved {@link InetAddress} if the host is allowed, or empty if blocked + */ + public static Optional resolveForDatasource(String host) { + if (!StringUtils.hasText(host)) { + return Optional.empty(); + } + + final String canonicalHost = normalizeHostForComparisonQuietly(host); + if (DISALLOWED_HOSTS.contains(canonicalHost)) { + return Optional.empty(); + } + + final InetAddress[] resolved; + try { + resolved = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + return Optional.empty(); + } + + final boolean inDocker = "1".equals(System.getenv("IN_DOCKER")); + for (InetAddress addr : resolved) { + if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(addr.getHostAddress())) + || isBlockedResolvedAddress(addr, inDocker, BLOCK_PRIVATE_ADDRESSES)) { + return Optional.empty(); + } + } + + return Optional.of(resolved[0]); + } + public static boolean isDisallowedAndFail(String host, Promise promise) { final String canonicalHost = normalizeHostForComparisonQuietly(host); - if (DISALLOWED_HOSTS.contains(canonicalHost) || isBlockedAddressClassInDocker(canonicalHost)) { + if (DISALLOWED_HOSTS.contains(canonicalHost) || isBlockedForEgress(canonicalHost)) { log.warn("Host {} is disallowed. Failing the request.", host); if (promise != null) { promise.setFailure(new UnknownHostException(HOST_NOT_ALLOWED)); @@ -269,7 +317,7 @@ private static Mono requestFilterFn(ClientRequest request) { AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, "IP Address resolution is invalid")); } - return (DISALLOWED_HOSTS.contains(canonicalHost) || isBlockedAddressClassInDocker(canonicalHost)) + return (DISALLOWED_HOSTS.contains(canonicalHost) || isBlockedForEgress(canonicalHost)) ? Mono.error(new UnknownHostException(HOST_NOT_ALLOWED)) : Mono.just(request); } @@ -285,11 +333,30 @@ static boolean isBlockedIpAddressClass(String canonicalHost) { } } - private static boolean matchesBlockedAddressClass(InetAddress address) { - if (address.isLoopbackAddress() - || address.isAnyLocalAddress() - || address.isLinkLocalAddress() - || address.isMulticastAddress()) { + /** + * Address classes that have no legitimate outbound-datasource use and are therefore blocked on + * EVERY deployment, independent of {@code IN_DOCKER} (GHSA-7wfp-6c63-99gq, GHSA-66gg-xpjf-p92v): + * link-local (covers the whole 169.254.0.0/16 IMDS range and IPv6 fe80::/10), any-local, + * multicast, and IPv6 Unique Local Addresses (fc00::/7). + * + *

Note this deliberately EXCLUDES loopback and RFC 1918 site-local ranges — those are gated + * separately (loopback under {@code IN_DOCKER} or strict mode; RFC 1918 under strict mode only) + * so that self-hosted deployments and local development that legitimately reach private/loopback + * services are not broken by default. + */ + static boolean isAlwaysBlockedAddressClass(String canonicalHost) { + if (!isValidIpAddress(canonicalHost)) { + return false; + } + try { + return matchesAlwaysBlockedAddressClass(InetAddress.getByName(canonicalHost)); + } catch (UnknownHostException e) { + return false; + } + } + + private static boolean matchesAlwaysBlockedAddressClass(InetAddress address) { + if (address.isAnyLocalAddress() || address.isLinkLocalAddress() || address.isMulticastAddress()) { return true; } if (address instanceof Inet6Address) { @@ -300,8 +367,47 @@ private static boolean matchesBlockedAddressClass(InetAddress address) { return false; } - private static boolean isBlockedAddressClassInDocker(String canonicalHost) { - return "1".equals(System.getenv("IN_DOCKER")) && isBlockedIpAddressClass(canonicalHost); + private static boolean matchesBlockedAddressClass(InetAddress address) { + return address.isLoopbackAddress() || matchesAlwaysBlockedAddressClass(address); + } + + /** + * Whether an IP-literal host must be blocked for outbound egress under the current deployment + * configuration. Used by both the WebClient HTTP path ({@link #requestFilterFn} / + * {@link #isDisallowedAndFail}) and the datasource resolver ({@link #resolveForDatasource}). + */ + private static boolean isBlockedForEgress(String canonicalHost) { + if (!isValidIpAddress(canonicalHost)) { + return false; + } + final InetAddress address; + try { + address = InetAddress.getByName(canonicalHost); + } catch (UnknownHostException e) { + return false; + } + return isBlockedResolvedAddress(address, "1".equals(System.getenv("IN_DOCKER")), BLOCK_PRIVATE_ADDRESSES); + } + + /** + * Pure address-class decision, extracted so it can be unit-tested deterministically without + * mutating process env. + * + * @param inDocker whether the process runs inside the Appsmith Docker image ({@code IN_DOCKER=1}) + * @param blockPrivate whether strict egress mode ({@code APPSMITH_SSRF_BLOCK_PRIVATE_ADDRESS=true}) is on + */ + static boolean isBlockedResolvedAddress(InetAddress address, boolean inDocker, boolean blockPrivate) { + // Always blocked, every deployment: link-local (incl. IMDS), any-local, multicast, IPv6 ULA. + if (matchesAlwaysBlockedAddressClass(address)) { + return true; + } + // Loopback: blocked inside Docker (existing behaviour) or when strict egress is enabled. + if ((inDocker || blockPrivate) && address.isLoopbackAddress()) { + return true; + } + // RFC 1918 site-local: blocked only under strict egress (never gated on IN_DOCKER, because + // Docker deployments routinely reach private-range services such as sibling containers). + return blockPrivate && address.isSiteLocalAddress(); } private static boolean isValidIpAddress(String host) { diff --git a/app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java b/app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java index d80a84351e70..c78b4bf8c260 100644 --- a/app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java +++ b/app/server/appsmith-interfaces/src/test/java/com/appsmith/util/WebClientUtilsTest.java @@ -146,6 +146,149 @@ public void isBlockedIpAddressClass_doesNotMatchOtherHosts(String host) { "Did not expect " + host + " to be recognized as a blocked address class"); } + // GHSA-7wfp-6c63-99gq / GHSA-66gg-xpjf-p92v / GHSA-4fjr-w826-cpwm: + // Link-local (incl. non-listed IMDS ranges), any-local, multicast, and IPv6 ULA addresses have + // no legitimate datasource use, so they must be blocked on every deployment — not only under + // IN_DOCKER as before. isAlwaysBlockedAddressClass is deployment-independent. + @ParameterizedTest + @ValueSource( + strings = { + // Link-local IPv4 (169.254.0.0/16) beyond the explicitly listed metadata IPs + "169.254.1.1", + "169.254.0.1", + // Link-local IPv6 + "fe80::1", + // Any-local + "0.0.0.0", + "::", + // Multicast + "224.0.0.1", + "239.255.255.250", + "ff02::1", + // IPv6 ULA (fc00::/7) + "fc00::1", + "fd00::1", + }) + public void isAlwaysBlockedAddressClass_recognizesNonRoutableClasses(String host) { + assertTrue( + WebClientUtils.isAlwaysBlockedAddressClass(host), + "Expected " + host + " to be an always-blocked address class"); + } + + // Loopback and RFC1918 are NOT in the always-blocked set: loopback stays reachable on non-Docker + // deployments (and localhost-backed datasources / MockWebServer tests keep working), and RFC1918 + // stays reachable so self-hosted internal datasources are unaffected. No regression by default. + @ParameterizedTest + @ValueSource(strings = {"127.0.0.1", "::1", "10.0.0.1", "192.168.1.1", "172.16.0.1", "1.1.1.1", "8.8.8.8"}) + public void isAlwaysBlockedAddressClass_doesNotMatchLoopbackPrivateOrPublic(String host) { + assertFalse( + WebClientUtils.isAlwaysBlockedAddressClass(host), + "Did not expect " + host + " to be an always-blocked address class"); + } + + // The HTTP egress enforcement points (isDisallowedAndFail / requestFilterFn) must reject the + // always-blocked classes even when IN_DOCKER is unset (the historical gap this batch closes). + @ParameterizedTest + @ValueSource(strings = {"169.254.1.1", "fe80::1", "0.0.0.0", "224.0.0.1", "fc00::1"}) + public void isDisallowedAndFail_blocksAlwaysBlockedClassesRegardlessOfDocker(String host) { + assertTrue( + WebClientUtils.isDisallowedAndFail(host, null), + "Expected " + host + " to be blocked on all deployments"); + } + + @ParameterizedTest + @ValueSource( + strings = { + "http://169.254.1.1/latest/meta-data", + "http://[fe80::1]/", + "http://0.0.0.0/", + "http://224.0.0.1/", + }) + public void requestFilterFn_blocksAlwaysBlockedClassesRegardlessOfDocker(String url) throws Exception { + StepVerifier.create(invokeRequestFilterFn(url)) + .expectErrorSatisfies(throwable -> { + assertTrue(throwable instanceof UnknownHostException); + assertEquals(WebClientUtils.HOST_NOT_ALLOWED, throwable.getMessage()); + }) + .verify(); + } + + // Default posture (no IN_DOCKER, strict-egress flag off): loopback and RFC1918 remain reachable + // so existing self-hosted internal datasources and local dev keep working (no regression). + @ParameterizedTest + @ValueSource(strings = {"127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1"}) + public void isDisallowedAndFail_allowsLoopbackAndPrivateByDefault(String host) { + assertFalse( + WebClientUtils.isDisallowedAndFail(host, null), + "Expected " + host + " to be allowed by default (no IN_DOCKER, strict egress off)"); + } + + // resolveForDatasource is the datasource-facing resolver used by non-HTTP plugins that connect + // outside the WebClient pipeline (e.g. SMTP via JavaMail). It blocks the cloud-metadata denylist + // and the always-blocked address classes on every deployment, but allows loopback and RFC1918 by + // default so self-hosted mail servers on private networks (and Testcontainers hosts that resolve + // to loopback on some Docker setups) keep working. It returns the resolved address so callers can + // pin the connection and defeat DNS-rebinding. + @ParameterizedTest + @ValueSource(strings = {"169.254.169.254", "169.254.1.1", "0.0.0.0", "224.0.0.1", "metadata.google.internal"}) + public void resolveForDatasource_blocksMetadataAndAlwaysBlockedClasses(String host) { + assertTrue( + WebClientUtils.resolveForDatasource(host).isEmpty(), + "Expected datasource host " + host + " to be blocked"); + } + + @ParameterizedTest + @ValueSource(strings = {"127.0.0.1", "10.0.0.1", "192.168.1.1", "172.16.0.1"}) + public void resolveForDatasource_allowsLoopbackAndPrivateByDefault(String host) { + assertTrue( + WebClientUtils.resolveForDatasource(host).isPresent(), + "Expected datasource host " + host + " to be allowed by default"); + } + + @Test + public void resolveForDatasource_blocksNullAndEmpty() { + assertTrue(WebClientUtils.resolveForDatasource(null).isEmpty()); + assertTrue(WebClientUtils.resolveForDatasource(" ").isEmpty()); + } + + @Test + public void resolveForDatasource_returnsResolvedAddressForPublicHost() { + Optional result = WebClientUtils.resolveForDatasource("smtp.gmail.com"); + assertTrue(result.isPresent()); + } + + // Strict egress mode (APPSMITH_SSRF_BLOCK_PRIVATE_ADDRESS=true) additionally blocks loopback and + // RFC1918 on all deployments. Exercised through the pure overload so no process env is mutated. + @Test + public void isBlockedResolvedAddress_strictMode_blocksLoopbackAndPrivate() throws Exception { + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("127.0.0.1"), false, true)); + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("10.0.0.1"), false, true)); + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("192.168.1.1"), false, true)); + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("172.16.0.1"), false, true)); + } + + @Test + public void isBlockedResolvedAddress_defaultMode_allowsLoopbackAndPrivate() throws Exception { + assertFalse(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("127.0.0.1"), false, false)); + assertFalse(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("10.0.0.1"), false, false)); + assertFalse(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("192.168.1.1"), false, false)); + } + + @Test + public void isBlockedResolvedAddress_dockerMode_blocksLoopbackButNotPrivate() throws Exception { + // In Docker, loopback is blocked but RFC1918 stays reachable (sibling containers/services). + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("127.0.0.1"), true, false)); + assertFalse(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("10.0.0.1"), true, false)); + } + + @Test + public void isBlockedResolvedAddress_alwaysBlocksNonRoutableClassesInEveryMode() throws Exception { + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("169.254.1.1"), false, false)); + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("fe80::1"), false, false)); + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("224.0.0.1"), false, false)); + assertTrue(WebClientUtils.isBlockedResolvedAddress(InetAddress.getByName("fc00::1"), false, false)); + } + @SuppressWarnings("unchecked") private Mono invokeRequestFilterFn(String url) throws Exception { final Method method = WebClientUtils.class.getDeclaredMethod("requestFilterFn", ClientRequest.class); diff --git a/app/server/appsmith-plugins/anthropicPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java b/app/server/appsmith-plugins/anthropicPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java index 772673790ebd..5a36e4e75351 100644 --- a/app/server/appsmith-plugins/anthropicPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java +++ b/app/server/appsmith-plugins/anthropicPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java @@ -4,13 +4,13 @@ import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; import com.appsmith.external.models.ApiKeyAuth; import com.appsmith.external.models.DatasourceConfiguration; +import com.appsmith.util.WebClientUtils; import com.external.plugins.constants.AnthropicConstants; import com.external.plugins.constants.AnthropicErrorMessages; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.reactive.ClientHttpRequest; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.client.WebClient; @@ -69,10 +69,11 @@ public static Mono> makeRequest( private static WebClient createWebClient() { // Initializing webClient to be used for http call - WebClient.Builder webClientBuilder = WebClient.builder(); + // Route outbound calls through WebClientUtils so the centralized SSRF address filter is + // applied (GHSA-h4wh-59p8-vqff). + WebClient.Builder webClientBuilder = WebClientUtils.builder(HttpClient.create(connectionProvider())); return webClientBuilder .exchangeStrategies(AnthropicConstants.EXCHANGE_STRATEGIES) - .clientConnector(new ReactorClientHttpConnector(HttpClient.create(connectionProvider()))) .build(); } diff --git a/app/server/appsmith-plugins/appsmithAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java b/app/server/appsmith-plugins/appsmithAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java index d5d49e3338b9..2328883e5205 100644 --- a/app/server/appsmith-plugins/appsmithAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java +++ b/app/server/appsmith-plugins/appsmithAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java @@ -2,6 +2,7 @@ import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError; import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; +import com.appsmith.util.WebClientUtils; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import jakarta.validation.constraints.NotNull; @@ -10,7 +11,6 @@ import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.reactive.ClientHttpRequest; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.codec.multipart.FilePart; import org.springframework.util.LinkedMultiValueMap; import org.springframework.web.reactive.function.BodyInserter; @@ -132,11 +132,10 @@ public static Mono> makeRequest( private static WebClient createWebClient() { // Initializing webClient to be used for http call - WebClient.Builder webClientBuilder = WebClient.builder(); - return webClientBuilder - .exchangeStrategies(EXCHANGE_STRATEGIES) - .clientConnector(new ReactorClientHttpConnector(HttpClient.create(connectionProvider()))) - .build(); + // Route outbound calls through WebClientUtils so the centralized SSRF address filter is + // applied (GHSA-h4wh-59p8-vqff). + WebClient.Builder webClientBuilder = WebClientUtils.builder(HttpClient.create(connectionProvider())); + return webClientBuilder.exchangeStrategies(EXCHANGE_STRATEGIES).build(); } private static ConnectionProvider connectionProvider() { diff --git a/app/server/appsmith-plugins/googleAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java b/app/server/appsmith-plugins/googleAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java index 048ca6c185d6..b3b98dd1f18f 100644 --- a/app/server/appsmith-plugins/googleAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java +++ b/app/server/appsmith-plugins/googleAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java @@ -4,13 +4,13 @@ import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException; import com.appsmith.external.models.ApiKeyAuth; import com.appsmith.external.models.DatasourceConfiguration; +import com.appsmith.util.WebClientUtils; import com.external.plugins.constants.GoogleAIConstants; import com.external.plugins.constants.GoogleAIErrorMessages; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.reactive.ClientHttpRequest; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.client.WebClient; @@ -77,10 +77,11 @@ private static URI appendKeyInUri(String apiKey, URI uri) { private static WebClient createWebClient() { // Initializing webClient to be used for http call - WebClient.Builder webClientBuilder = WebClient.builder(); + // Route outbound calls through WebClientUtils so the centralized SSRF address filter is + // applied (GHSA-h4wh-59p8-vqff). + WebClient.Builder webClientBuilder = WebClientUtils.builder(HttpClient.create(connectionProvider())); return webClientBuilder .exchangeStrategies(GoogleAIConstants.EXCHANGE_STRATEGIES) - .clientConnector(new ReactorClientHttpConnector(HttpClient.create(connectionProvider()))) .build(); } diff --git a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/plugins/GoogleSheetsPlugin.java b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/plugins/GoogleSheetsPlugin.java index 7f6b0c9255b4..8acee925353d 100644 --- a/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/plugins/GoogleSheetsPlugin.java +++ b/app/server/appsmith-plugins/googleSheetsPlugin/src/main/java/com/external/plugins/GoogleSheetsPlugin.java @@ -350,8 +350,9 @@ public Mono triggerWithFlags( final TriggerMethod triggerMethod = GoogleSheetsMethodStrategy.getTriggerMethod(request, objectMapper); MethodConfig methodConfig = new MethodConfig(request); - // Initializing webClient to be used for http call - WebClient.Builder webClientBuilder = WebClient.builder(); + // Route through WebClientUtils so the centralized SSRF address filter is applied on the + // trigger path too, matching the execution path above (GHSA-h4wh-59p8-vqff). + WebClient.Builder webClientBuilder = WebClientUtils.builder(); triggerMethod.validateTriggerMethodRequest(methodConfig); diff --git a/app/server/appsmith-plugins/openAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java b/app/server/appsmith-plugins/openAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java index 39fa48e91b71..f4fc598c037a 100644 --- a/app/server/appsmith-plugins/openAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java +++ b/app/server/appsmith-plugins/openAiPlugin/src/main/java/com/external/plugins/utils/RequestUtils.java @@ -5,12 +5,12 @@ import com.appsmith.external.models.ActionConfiguration; import com.appsmith.external.models.BearerTokenAuth; import com.appsmith.external.models.DatasourceConfiguration; +import com.appsmith.util.WebClientUtils; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.http.client.reactive.ClientHttpRequest; -import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyInserter; @@ -94,12 +94,11 @@ public static Mono> makeRequest( } private static WebClient createWebClient() { - // Initializing webClient to be used for http call - WebClient.Builder webClientBuilder = WebClient.builder(); - return webClientBuilder - .exchangeStrategies(EXCHANGE_STRATEGIES) - .clientConnector(new ReactorClientHttpConnector(HttpClient.create(connectionProvider()))) - .build(); + // Route outbound calls through WebClientUtils so the centralized SSRF address filter is + // applied (GHSA-h4wh-59p8-vqff). builder(HttpClient) installs the guarded resolver and the + // IP_CHECK_FILTER; we only override the exchange strategies for this plugin's codecs. + WebClient.Builder webClientBuilder = WebClientUtils.builder(HttpClient.create(connectionProvider())); + return webClientBuilder.exchangeStrategies(EXCHANGE_STRATEGIES).build(); } private static ConnectionProvider connectionProvider() { diff --git a/app/server/appsmith-plugins/smtpPlugin/src/main/java/com/external/plugins/SmtpPlugin.java b/app/server/appsmith-plugins/smtpPlugin/src/main/java/com/external/plugins/SmtpPlugin.java index 5fcaf493a676..0f8a96e1f822 100644 --- a/app/server/appsmith-plugins/smtpPlugin/src/main/java/com/external/plugins/SmtpPlugin.java +++ b/app/server/appsmith-plugins/smtpPlugin/src/main/java/com/external/plugins/SmtpPlugin.java @@ -12,6 +12,7 @@ import com.appsmith.external.models.Endpoint; import com.appsmith.external.plugins.BasePlugin; import com.appsmith.external.plugins.PluginExecutor; +import com.appsmith.util.WebClientUtils; import com.external.plugins.exceptions.SMTPErrorMessages; import com.external.plugins.exceptions.SMTPPluginError; import jakarta.activation.DataHandler; @@ -203,6 +204,18 @@ public Mono datasourceCreate(DatasourceConfiguration datasourceConfigur Endpoint endpoint = datasourceConfiguration.getEndpoints().get(0); DBAuth authentication = (DBAuth) datasourceConfiguration.getAuthentication(); + // GHSA-72m2-f9xp-wg9h: the SMTP plugin connects via JavaMail, outside the WebClient SSRF + // pipeline, so validate the destination host through the centralized guard before building + // the session. resolveForDatasource rejects cloud-metadata endpoints and the always-blocked + // address classes (link-local/IMDS, any-local, multicast, IPv6 ULA), and — on Docker or under + // strict egress mode — loopback and private ranges, while allowing legitimate internal mail + // servers on private networks by default. + if (WebClientUtils.resolveForDatasource(endpoint.getHost()).isEmpty()) { + return Mono.error(new AppsmithPluginException( + AppsmithPluginError.PLUGIN_DATASOURCE_ARGUMENT_ERROR, + SMTPErrorMessages.DS_INVALID_HOST_ERROR_MSG)); + } + Properties prop = new Properties(); prop.put("mail.transport.protocol", "smtp"); prop.put("mail.smtp.host", endpoint.getHost()); diff --git a/app/server/appsmith-plugins/smtpPlugin/src/main/java/com/external/plugins/exceptions/SMTPErrorMessages.java b/app/server/appsmith-plugins/smtpPlugin/src/main/java/com/external/plugins/exceptions/SMTPErrorMessages.java index 282ed1b76ecc..f0d6af4c0f93 100644 --- a/app/server/appsmith-plugins/smtpPlugin/src/main/java/com/external/plugins/exceptions/SMTPErrorMessages.java +++ b/app/server/appsmith-plugins/smtpPlugin/src/main/java/com/external/plugins/exceptions/SMTPErrorMessages.java @@ -28,6 +28,9 @@ public class SMTPErrorMessages { public static final String DS_MISSING_HOST_ADDRESS_ERROR_MSG = "Could not find host address. Please edit the 'Hostname' field to provide the desired endpoint."; + public static final String DS_INVALID_HOST_ERROR_MSG = + "The SMTP host is not allowed. Please provide a routable external mail server hostname or address."; + public static final String DS_NO_SUCH_PROVIDER_ERROR_MSG = "Unable to create underlying SMTP protocol. Please contact support"; diff --git a/app/server/appsmith-plugins/smtpPlugin/src/test/java/com/external/plugins/SmtpPluginTest.java b/app/server/appsmith-plugins/smtpPlugin/src/test/java/com/external/plugins/SmtpPluginTest.java index 8b4d60892dd2..66f078dc8aa0 100644 --- a/app/server/appsmith-plugins/smtpPlugin/src/test/java/com/external/plugins/SmtpPluginTest.java +++ b/app/server/appsmith-plugins/smtpPlugin/src/test/java/com/external/plugins/SmtpPluginTest.java @@ -132,6 +132,40 @@ public void testInvalidHostname() { pluginExecutor.validateDatasource(invalidDatasourceConfiguration)); } + // GHSA-72m2-f9xp-wg9h: datasourceCreate must reject SSRF targets before building the session. + @Test + public void datasourceCreate_blocksCloudMetadataHost() { + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + dsConfig.setEndpoints(List.of(new Endpoint("169.254.169.254", 25L))); + + StepVerifier.create(pluginExecutor.datasourceCreate(dsConfig)) + .expectErrorMatches(e -> e instanceof AppsmithPluginException + && e.getMessage().equals(SMTPErrorMessages.DS_INVALID_HOST_ERROR_MSG)) + .verify(); + } + + @Test + public void datasourceCreate_blocksLinkLocalHost() { + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + dsConfig.setEndpoints(List.of(new Endpoint("169.254.10.20", 25L))); + + StepVerifier.create(pluginExecutor.datasourceCreate(dsConfig)) + .expectErrorMatches(e -> e instanceof AppsmithPluginException + && e.getMessage().equals(SMTPErrorMessages.DS_INVALID_HOST_ERROR_MSG)) + .verify(); + } + + @Test + public void datasourceCreate_allowsPrivateHostByDefault() { + // RFC1918 hosts are allowed by default so self-hosted internal mail servers keep working. + DatasourceConfiguration dsConfig = createDatasourceConfiguration(); + dsConfig.setEndpoints(List.of(new Endpoint("10.1.2.3", 25L))); + + StepVerifier.create(pluginExecutor.datasourceCreate(dsConfig)) + .assertNext(session -> assertNotNull(session)) + .verifyComplete(); + } + @Test public void testInvalidPort() { DatasourceConfiguration invalidDatasourceConfiguration = createDatasourceConfiguration();