From ce761a50c866d7ebdd07416c1ac369fef185612b Mon Sep 17 00:00:00 2001 From: subrata71 Date: Sat, 28 Mar 2026 01:13:25 +0600 Subject: [PATCH 1/4] fix(security): block SSRF via send-test-email SMTP host validation The sendTestEmail endpoint accepted attacker-controlled smtpHost/smtpPort and passed them directly to JavaMailSenderImpl, bypassing the WebClient IP_CHECK_FILTER. This allowed admin users to reach internal services, cloud metadata endpoints, and private networks via raw SMTP connections. - Add WebClientUtils.validateHostNotDisallowed() that checks hostnames against the existing denylist and, after DNS resolution, rejects loopback, link-local, site-local, and multicast addresses - Apply host validation in sendTestEmail before connecting - Sanitize error messages to prevent information leakage via error-based port scanning (CWE-209) - Add tests for blocked and allowed hosts Fixes https://linear.app/appsmith/issue/APP-15034 Advisory: GHSA-vvxf-f8q9-86gh --- .../com/appsmith/util/WebClientUtils.java | 42 ++++++++++++ .../com/appsmith/util/WebClientUtilsTest.java | 46 +++++++++++++ .../server/solutions/ce/EnvManagerCEImpl.java | 16 ++++- .../server/solutions/EnvManagerTest.java | 65 +++++++++++++++++++ 4 files changed, 166 insertions(+), 3 deletions(-) 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 7639fd3bbdf3..03c9dc9d2fe7 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 @@ -31,6 +31,7 @@ import java.util.HashSet; import java.util.List; import java.util.Locale; +import java.util.Optional; import java.util.Set; @Slf4j @@ -198,6 +199,47 @@ protected AddressResolver newResolver(EventExecutor executor) } } + /** + * Validates that a hostname or IP is safe for outbound connections from non-HTTP paths + * (e.g. SMTP via JavaMail). Checks against the cloud-metadata denylist and, after DNS + * resolution, rejects loopback, link-local, site-local, and other non-routable addresses. + * + * @return empty if the host is allowed; a reason string if it must be blocked + */ + public static Optional validateHostNotDisallowed(String host) { + if (!StringUtils.hasText(host)) { + return Optional.of("Host is null or empty."); + } + + final String canonicalHost = normalizeHostForComparisonQuietly(host); + + if (DISALLOWED_HOSTS.contains(canonicalHost)) { + return Optional.of(HOST_NOT_ALLOWED); + } + + final InetAddress[] resolved; + try { + resolved = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + return Optional.of("Unable to resolve host."); + } + + for (InetAddress addr : resolved) { + if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(addr.getHostAddress()))) { + return Optional.of(HOST_NOT_ALLOWED); + } + if (addr.isLoopbackAddress() + || addr.isLinkLocalAddress() + || addr.isSiteLocalAddress() + || addr.isAnyLocalAddress() + || addr.isMulticastAddress()) { + return Optional.of(HOST_NOT_ALLOWED); + } + } + + return Optional.empty(); + } + public static boolean isDisallowedAndFail(String host, Promise promise) { if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(host))) { log.warn("Host {} is disallowed. Failing the request.", 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 186ab2551b22..fa8ddcceddb6 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 @@ -1,5 +1,6 @@ package com.appsmith.util; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.HttpMethod; @@ -10,6 +11,7 @@ import java.lang.reflect.Method; import java.net.URI; import java.net.UnknownHostException; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -43,6 +45,50 @@ public void testIsDisallowedAndFailNormalizesMetadataHostnames(String host) { assertTrue(WebClientUtils.isDisallowedAndFail(host, null)); } + @ParameterizedTest + @ValueSource( + strings = { + "127.0.0.1", + "10.0.0.1", + "192.168.1.1", + "172.16.0.1", + "169.254.169.254", + "169.254.10.10", + "100.100.100.200", + "168.63.129.16", + "0.0.0.0", + }) + public void validateHostNotDisallowed_blocksPrivateAndMetadataHosts(String host) { + Optional result = WebClientUtils.validateHostNotDisallowed(host); + assertTrue(result.isPresent(), "Expected host " + host + " to be blocked"); + } + + @Test + public void validateHostNotDisallowed_blocksNullAndEmpty() { + assertTrue(WebClientUtils.validateHostNotDisallowed(null).isPresent()); + assertTrue(WebClientUtils.validateHostNotDisallowed("").isPresent()); + assertTrue(WebClientUtils.validateHostNotDisallowed(" ").isPresent()); + } + + @Test + public void validateHostNotDisallowed_blocksLocalhostHostname() { + Optional result = WebClientUtils.validateHostNotDisallowed("localhost"); + assertTrue(result.isPresent(), "Expected 'localhost' to be blocked"); + } + + @ParameterizedTest + @ValueSource(strings = {"smtp.gmail.com", "email-smtp.us-east-1.amazonaws.com", "smtp.sendgrid.net"}) + public void validateHostNotDisallowed_allowsLegitimateSmtpHosts(String host) { + Optional result = WebClientUtils.validateHostNotDisallowed(host); + assertTrue(result.isEmpty(), "Expected host " + host + " to be allowed, but got: " + result.orElse("")); + } + + @Test + public void validateHostNotDisallowed_blocksUnresolvableHost() { + Optional result = WebClientUtils.validateHostNotDisallowed("definitely-not-a-real-host-xyz123.invalid"); + assertTrue(result.isPresent(), "Expected unresolvable host to be blocked"); + } + @SuppressWarnings("unchecked") private Mono invokeRequestFilterFn(String url) throws Exception { final Method method = WebClientUtils.class.getDeclaredMethod("requestFilterFn", ClientRequest.class); diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java index 0cbd6c1798a0..bc256edd162e 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java @@ -28,6 +28,7 @@ import com.appsmith.server.services.PermissionGroupService; import com.appsmith.server.services.SessionUserService; import com.appsmith.server.services.UserService; +import com.appsmith.util.WebClientUtils; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.mail.MessagingException; @@ -783,9 +784,18 @@ public Mono restartWithoutAclCheck() { return Mono.empty(); } + private static final String SMTP_GENERIC_ERROR = + "Failed to connect to the SMTP server. Please verify the host, " + "port, and credentials are correct."; + @Override public Mono sendTestEmail(TestEmailConfigRequestDTO requestDTO) { return verifyCurrentUserIsSuper().flatMap(user -> { + var hostCheckResult = WebClientUtils.validateHostNotDisallowed(requestDTO.getSmtpHost()); + if (hostCheckResult.isPresent()) { + return Mono.error(new AppsmithException( + AppsmithError.GENERIC_BAD_REQUEST, "Invalid SMTP host: " + hostCheckResult.get())); + } + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); mailSender.setHost(requestDTO.getSmtpHost()); mailSender.setPort(requestDTO.getSmtpPort()); @@ -817,15 +827,15 @@ public Mono sendTestEmail(TestEmailConfigRequestDTO requestDTO) { try { mailSender.testConnection(); } catch (MessagingException e) { - return Mono.error(new AppsmithException( - AppsmithError.GENERIC_BAD_REQUEST, e.getMessage().trim())); + log.error("SMTP test-connection failed for host {}", requestDTO.getSmtpHost(), e); + return Mono.error(new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, SMTP_GENERIC_ERROR)); } try { mailSender.send(message); } catch (MailException mailException) { log.error("failed to send test email", mailException); - return Mono.error(new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, mailException.getMessage())); + return Mono.error(new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, SMTP_GENERIC_ERROR)); } return Mono.just(TRUE); }); diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java index 8ff1c6794d0b..e71b065a425e 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java @@ -4,6 +4,7 @@ import com.appsmith.server.configurations.EmailConfig; import com.appsmith.server.configurations.GoogleRecaptchaConfig; import com.appsmith.server.domains.User; +import com.appsmith.server.dtos.TestEmailConfigRequestDTO; import com.appsmith.server.exceptions.AppsmithError; import com.appsmith.server.exceptions.AppsmithException; import com.appsmith.server.helpers.BlacklistedEnvVariableHelper; @@ -384,6 +385,70 @@ public void sendTestEmail_WhenUserNotSuperUser_ThrowsException() { .verify(); } + private void mockSuperUser() { + User user = new User(); + user.setEmail("admin@appsmith.com"); + Mockito.when(userUtils.isCurrentUserSuperUser()).thenReturn(Mono.just(true)); + Mockito.when(sessionUserService.getCurrentUser()).thenReturn(Mono.just(user)); + } + + private TestEmailConfigRequestDTO buildDto(String smtpHost) { + TestEmailConfigRequestDTO dto = new TestEmailConfigRequestDTO(); + dto.setSmtpHost(smtpHost); + dto.setSmtpPort(25); + dto.setFromEmail("test@appsmith.com"); + dto.setStarttlsEnabled(false); + return dto; + } + + @Test + public void sendTestEmail_WhenLocalhostHost_ThrowsException() { + mockSuperUser(); + + StepVerifier.create(envManager.sendTestEmail(buildDto("127.0.0.1"))) + .expectErrorSatisfies(e -> { + assertThat(e).isInstanceOf(AppsmithException.class); + assertThat(e.getMessage()).contains("Invalid SMTP host"); + }) + .verify(); + } + + @Test + public void sendTestEmail_WhenCloudMetadataHost_ThrowsException() { + mockSuperUser(); + + StepVerifier.create(envManager.sendTestEmail(buildDto("169.254.169.254"))) + .expectErrorSatisfies(e -> { + assertThat(e).isInstanceOf(AppsmithException.class); + assertThat(e.getMessage()).contains("Invalid SMTP host"); + }) + .verify(); + } + + @Test + public void sendTestEmail_WhenPrivateNetworkHost_ThrowsException() { + mockSuperUser(); + + StepVerifier.create(envManager.sendTestEmail(buildDto("10.0.0.1"))) + .expectErrorSatisfies(e -> { + assertThat(e).isInstanceOf(AppsmithException.class); + assertThat(e.getMessage()).contains("Invalid SMTP host"); + }) + .verify(); + } + + @Test + public void sendTestEmail_WhenLocalhost_ThrowsException() { + mockSuperUser(); + + StepVerifier.create(envManager.sendTestEmail(buildDto("localhost"))) + .expectErrorSatisfies(e -> { + assertThat(e).isInstanceOf(AppsmithException.class); + assertThat(e.getMessage()).contains("Invalid SMTP host"); + }) + .verify(); + } + @Test public void setEnv_AndGetAll() { // Create a test map of environment variables From 524cc1b841789167306b2b0c2c2f3e202daf1637 Mon Sep 17 00:00:00 2001 From: subrata71 Date: Sat, 28 Mar 2026 10:40:17 +0600 Subject: [PATCH 2/4] fix(security): address review - TOCTOU, IPv6 ULA, Cypress test failure Address CodeRabbitAI review findings and Cypress test regression: 1. DNS rebinding TOCTOU (Critical): Rename validateHostNotDisallowed to resolveIfAllowed, returning Optional. The caller now uses the resolved IP directly for mailSender.setHost(), eliminating the window between validation and connection where DNS could rebind. 2. IPv6 ULA gap (Major): Java's isSiteLocalAddress() only checks the deprecated fec0::/10 prefix, not fc00::/7 (Unique Local Addresses). Added explicit byte-level check: (firstByte & 0xFE) == 0xFC. 3. Cypress test failure: Removed isSiteLocalAddress() from the blocking criteria. The existing WebClient denylist intentionally allows RFC 1918 (10/8, 172.16/12, 192.168/16) because legitimate SMTP servers reside on private networks. host.docker.internal (used in CI) resolves to a site-local address and was incorrectly blocked. 4. Test cleanup: Parameterized duplicate EnvManager blocked-host tests into a single @ParameterizedTest. Updated WebClientUtilsTest for new API signature and added resolved-address assertion. --- .../com/appsmith/util/WebClientUtils.java | 34 +++++++++----- .../com/appsmith/util/WebClientUtilsTest.java | 43 ++++++++++-------- .../server/solutions/ce/EnvManagerCEImpl.java | 9 ++-- .../server/solutions/EnvManagerTest.java | 45 +++---------------- 4 files changed, 56 insertions(+), 75 deletions(-) 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 03c9dc9d2fe7..f67fe57b5c97 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 @@ -200,44 +200,54 @@ protected AddressResolver newResolver(EventExecutor executor) } /** - * Validates that a hostname or IP is safe for outbound connections from non-HTTP paths - * (e.g. SMTP via JavaMail). Checks against the cloud-metadata denylist and, after DNS - * resolution, rejects loopback, link-local, site-local, and other non-routable addresses. + * Resolves a hostname and validates that none of its addresses are disallowed for + * outbound connections from non-HTTP paths (e.g. SMTP via JavaMail). Checks against + * the cloud-metadata denylist, loopback, link-local, any-local, multicast, and IPv6 + * Unique Local Addresses (fc00::/7). Returns the first validated resolved address so + * callers can connect to it directly, preventing DNS-rebinding TOCTOU bypasses. * - * @return empty if the host is allowed; a reason string if it must be blocked + *

RFC 1918 site-local ranges (10/8, 172.16/12, 192.168/16) are intentionally + * allowed because legitimate SMTP servers frequently reside on private networks. + * + * @return the resolved {@link InetAddress} if the host is allowed, or empty if blocked */ - public static Optional validateHostNotDisallowed(String host) { + public static Optional resolveIfAllowed(String host) { if (!StringUtils.hasText(host)) { - return Optional.of("Host is null or empty."); + return Optional.empty(); } final String canonicalHost = normalizeHostForComparisonQuietly(host); if (DISALLOWED_HOSTS.contains(canonicalHost)) { - return Optional.of(HOST_NOT_ALLOWED); + return Optional.empty(); } final InetAddress[] resolved; try { resolved = InetAddress.getAllByName(host); } catch (UnknownHostException e) { - return Optional.of("Unable to resolve host."); + return Optional.empty(); } for (InetAddress addr : resolved) { if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(addr.getHostAddress()))) { - return Optional.of(HOST_NOT_ALLOWED); + return Optional.empty(); + } + if (addr instanceof Inet6Address) { + byte firstByte = addr.getAddress()[0]; + if ((firstByte & (byte) 0xFE) == (byte) 0xFC) { + return Optional.empty(); + } } if (addr.isLoopbackAddress() || addr.isLinkLocalAddress() - || addr.isSiteLocalAddress() || addr.isAnyLocalAddress() || addr.isMulticastAddress()) { - return Optional.of(HOST_NOT_ALLOWED); + return Optional.empty(); } } - return Optional.empty(); + return Optional.of(resolved[0]); } public static boolean isDisallowedAndFail(String host, Promise promise) { 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 fa8ddcceddb6..d1145b159803 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 @@ -9,6 +9,7 @@ import reactor.test.StepVerifier; import java.lang.reflect.Method; +import java.net.InetAddress; import java.net.URI; import java.net.UnknownHostException; import java.util.Optional; @@ -49,44 +50,48 @@ public void testIsDisallowedAndFailNormalizesMetadataHostnames(String host) { @ValueSource( strings = { "127.0.0.1", - "10.0.0.1", - "192.168.1.1", - "172.16.0.1", "169.254.169.254", "169.254.10.10", "100.100.100.200", "168.63.129.16", "0.0.0.0", }) - public void validateHostNotDisallowed_blocksPrivateAndMetadataHosts(String host) { - Optional result = WebClientUtils.validateHostNotDisallowed(host); - assertTrue(result.isPresent(), "Expected host " + host + " to be blocked"); + public void resolveIfAllowed_blocksLoopbackMetadataAndSpecialHosts(String host) { + Optional result = WebClientUtils.resolveIfAllowed(host); + assertTrue(result.isEmpty(), "Expected host " + host + " to be blocked"); } @Test - public void validateHostNotDisallowed_blocksNullAndEmpty() { - assertTrue(WebClientUtils.validateHostNotDisallowed(null).isPresent()); - assertTrue(WebClientUtils.validateHostNotDisallowed("").isPresent()); - assertTrue(WebClientUtils.validateHostNotDisallowed(" ").isPresent()); + public void resolveIfAllowed_blocksNullAndEmpty() { + assertTrue(WebClientUtils.resolveIfAllowed(null).isEmpty()); + assertTrue(WebClientUtils.resolveIfAllowed("").isEmpty()); + assertTrue(WebClientUtils.resolveIfAllowed(" ").isEmpty()); } @Test - public void validateHostNotDisallowed_blocksLocalhostHostname() { - Optional result = WebClientUtils.validateHostNotDisallowed("localhost"); - assertTrue(result.isPresent(), "Expected 'localhost' to be blocked"); + public void resolveIfAllowed_blocksLocalhostHostname() { + Optional result = WebClientUtils.resolveIfAllowed("localhost"); + assertTrue(result.isEmpty(), "Expected 'localhost' to be blocked"); } @ParameterizedTest @ValueSource(strings = {"smtp.gmail.com", "email-smtp.us-east-1.amazonaws.com", "smtp.sendgrid.net"}) - public void validateHostNotDisallowed_allowsLegitimateSmtpHosts(String host) { - Optional result = WebClientUtils.validateHostNotDisallowed(host); - assertTrue(result.isEmpty(), "Expected host " + host + " to be allowed, but got: " + result.orElse("")); + public void resolveIfAllowed_allowsLegitimateSmtpHosts(String host) { + Optional result = WebClientUtils.resolveIfAllowed(host); + assertTrue(result.isPresent(), "Expected host " + host + " to be allowed"); } @Test - public void validateHostNotDisallowed_blocksUnresolvableHost() { - Optional result = WebClientUtils.validateHostNotDisallowed("definitely-not-a-real-host-xyz123.invalid"); - assertTrue(result.isPresent(), "Expected unresolvable host to be blocked"); + public void resolveIfAllowed_blocksUnresolvableHost() { + Optional result = WebClientUtils.resolveIfAllowed("definitely-not-a-real-host-xyz123.invalid"); + assertTrue(result.isEmpty(), "Expected unresolvable host to be blocked"); + } + + @Test + public void resolveIfAllowed_returnsResolvedAddress() { + Optional result = WebClientUtils.resolveIfAllowed("smtp.gmail.com"); + assertTrue(result.isPresent()); + assertTrue(result.get().getHostAddress().matches("\\d+\\.\\d+\\.\\d+\\.\\d+")); } @SuppressWarnings("unchecked") diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java index bc256edd162e..f16f744dc3e4 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java @@ -790,14 +790,13 @@ public Mono restartWithoutAclCheck() { @Override public Mono sendTestEmail(TestEmailConfigRequestDTO requestDTO) { return verifyCurrentUserIsSuper().flatMap(user -> { - var hostCheckResult = WebClientUtils.validateHostNotDisallowed(requestDTO.getSmtpHost()); - if (hostCheckResult.isPresent()) { - return Mono.error(new AppsmithException( - AppsmithError.GENERIC_BAD_REQUEST, "Invalid SMTP host: " + hostCheckResult.get())); + var resolvedAddress = WebClientUtils.resolveIfAllowed(requestDTO.getSmtpHost()); + if (resolvedAddress.isEmpty()) { + return Mono.error(new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, "Invalid SMTP host.")); } JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); - mailSender.setHost(requestDTO.getSmtpHost()); + mailSender.setHost(resolvedAddress.get().getHostAddress()); mailSender.setPort(requestDTO.getSmtpPort()); Properties props = mailSender.getJavaMailProperties(); diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java index e71b065a425e..c61b5f3bb7f2 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java @@ -25,6 +25,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mockito; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.mail.javamail.JavaMailSender; @@ -401,47 +403,12 @@ private TestEmailConfigRequestDTO buildDto(String smtpHost) { return dto; } - @Test - public void sendTestEmail_WhenLocalhostHost_ThrowsException() { - mockSuperUser(); - - StepVerifier.create(envManager.sendTestEmail(buildDto("127.0.0.1"))) - .expectErrorSatisfies(e -> { - assertThat(e).isInstanceOf(AppsmithException.class); - assertThat(e.getMessage()).contains("Invalid SMTP host"); - }) - .verify(); - } - - @Test - public void sendTestEmail_WhenCloudMetadataHost_ThrowsException() { - mockSuperUser(); - - StepVerifier.create(envManager.sendTestEmail(buildDto("169.254.169.254"))) - .expectErrorSatisfies(e -> { - assertThat(e).isInstanceOf(AppsmithException.class); - assertThat(e.getMessage()).contains("Invalid SMTP host"); - }) - .verify(); - } - - @Test - public void sendTestEmail_WhenPrivateNetworkHost_ThrowsException() { - mockSuperUser(); - - StepVerifier.create(envManager.sendTestEmail(buildDto("10.0.0.1"))) - .expectErrorSatisfies(e -> { - assertThat(e).isInstanceOf(AppsmithException.class); - assertThat(e.getMessage()).contains("Invalid SMTP host"); - }) - .verify(); - } - - @Test - public void sendTestEmail_WhenLocalhost_ThrowsException() { + @ParameterizedTest + @ValueSource(strings = {"127.0.0.1", "169.254.169.254", "localhost"}) + public void sendTestEmail_WhenBlockedHost_ThrowsException(String host) { mockSuperUser(); - StepVerifier.create(envManager.sendTestEmail(buildDto("localhost"))) + StepVerifier.create(envManager.sendTestEmail(buildDto(host))) .expectErrorSatisfies(e -> { assertThat(e).isInstanceOf(AppsmithException.class); assertThat(e.getMessage()).contains("Invalid SMTP host"); From 08f786803dc6bd65078a6058a0c14e105a4bba41 Mon Sep 17 00:00:00 2001 From: subrata71 Date: Mon, 30 Mar 2026 20:05:17 +0600 Subject: [PATCH 3/4] fix(security): add SMTP port allowlist as defense-in-depth Restrict the send-test-email endpoint to standard SMTP ports (25, 465, 587, 2525) by default. This prevents abuse of the endpoint for probing non-SMTP services on otherwise-allowed hosts. Operators can extend the allowlist via the APPSMITH_MAIL_ALLOWED_PORTS environment variable (comma-separated port numbers) for deployments that use non-standard SMTP ports. Port validation runs before host resolution (fail fast). Error messages use the same generic text as host validation to avoid leaking which ports are permitted. --- .../server/solutions/ce/EnvManagerCEImpl.java | 29 ++++++++++++++++++- .../server/solutions/EnvManagerTest.java | 21 ++++++++++++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java index f16f744dc3e4..033d116feab7 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java @@ -784,15 +784,42 @@ public Mono restartWithoutAclCheck() { return Mono.empty(); } + private static final Set DEFAULT_SMTP_PORTS = Set.of(25, 465, 587, 2525); + + private static final Set ALLOWED_SMTP_PORTS = computeAllowedSmtpPorts(); + + private static Set computeAllowedSmtpPorts() { + Set ports = new HashSet<>(DEFAULT_SMTP_PORTS); + String extra = System.getenv("APPSMITH_MAIL_ALLOWED_PORTS"); + if (extra != null && !extra.isBlank()) { + for (String token : extra.split(",")) { + try { + int port = Integer.parseInt(token.trim()); + if (port > 0 && port <= 65535) { + ports.add(port); + } + } catch (NumberFormatException ignored) { + } + } + } + return Set.copyOf(ports); + } + private static final String SMTP_GENERIC_ERROR = "Failed to connect to the SMTP server. Please verify the host, " + "port, and credentials are correct."; @Override public Mono sendTestEmail(TestEmailConfigRequestDTO requestDTO) { return verifyCurrentUserIsSuper().flatMap(user -> { + if (!ALLOWED_SMTP_PORTS.contains(requestDTO.getSmtpPort())) { + return Mono.error( + new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, "Invalid SMTP configuration.")); + } + var resolvedAddress = WebClientUtils.resolveIfAllowed(requestDTO.getSmtpHost()); if (resolvedAddress.isEmpty()) { - return Mono.error(new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, "Invalid SMTP host.")); + return Mono.error( + new AppsmithException(AppsmithError.GENERIC_BAD_REQUEST, "Invalid SMTP configuration.")); } JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); diff --git a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java index c61b5f3bb7f2..a514fdefd141 100644 --- a/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java +++ b/app/server/appsmith-server/src/test/java/com/appsmith/server/solutions/EnvManagerTest.java @@ -395,9 +395,13 @@ private void mockSuperUser() { } private TestEmailConfigRequestDTO buildDto(String smtpHost) { + return buildDto(smtpHost, 25); + } + + private TestEmailConfigRequestDTO buildDto(String smtpHost, int port) { TestEmailConfigRequestDTO dto = new TestEmailConfigRequestDTO(); dto.setSmtpHost(smtpHost); - dto.setSmtpPort(25); + dto.setSmtpPort(port); dto.setFromEmail("test@appsmith.com"); dto.setStarttlsEnabled(false); return dto; @@ -411,7 +415,20 @@ public void sendTestEmail_WhenBlockedHost_ThrowsException(String host) { StepVerifier.create(envManager.sendTestEmail(buildDto(host))) .expectErrorSatisfies(e -> { assertThat(e).isInstanceOf(AppsmithException.class); - assertThat(e.getMessage()).contains("Invalid SMTP host"); + assertThat(e.getMessage()).contains("Invalid SMTP configuration"); + }) + .verify(); + } + + @ParameterizedTest + @ValueSource(ints = {80, 443, 6379, 8080, 27017, 0, -1}) + public void sendTestEmail_WhenDisallowedPort_ThrowsException(int port) { + mockSuperUser(); + + StepVerifier.create(envManager.sendTestEmail(buildDto("smtp.gmail.com", port))) + .expectErrorSatisfies(e -> { + assertThat(e).isInstanceOf(AppsmithException.class); + assertThat(e.getMessage()).contains("Invalid SMTP configuration"); }) .verify(); } From ead902522adcef370125b3c433f2cc24f1363cc6 Mon Sep 17 00:00:00 2001 From: subrata71 Date: Tue, 31 Mar 2026 01:55:28 +0600 Subject: [PATCH 4/4] fix(security): enable implicit TLS for SMTPS on port 465 Port 465 uses SMTPS (implicit TLS per RFC 8314), not STARTTLS. When the configured port is 465, set mail.smtp.ssl.enable=true and disable STARTTLS so JavaMail connects with immediate TLS as the protocol requires. --- .../appsmith/server/solutions/ce/EnvManagerCEImpl.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java index 033d116feab7..b1954e63140a 100644 --- a/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java +++ b/app/server/appsmith-server/src/main/java/com/appsmith/server/solutions/ce/EnvManagerCEImpl.java @@ -829,8 +829,14 @@ public Mono sendTestEmail(TestEmailConfigRequestDTO requestDTO) { Properties props = mailSender.getJavaMailProperties(); props.put("mail.transport.protocol", "smtp"); - props.put( - "mail.smtp.starttls.enable", requestDTO.getStarttlsEnabled().toString()); + if (requestDTO.getSmtpPort() == 465) { + props.put("mail.smtp.ssl.enable", "true"); + props.put("mail.smtp.starttls.enable", "false"); + } else { + props.put( + "mail.smtp.starttls.enable", + requestDTO.getStarttlsEnabled().toString()); + } props.put("mail.smtp.timeout", 7000); // 7 seconds