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..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 @@ -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,57 @@ protected AddressResolver newResolver(EventExecutor executor) } } + /** + * 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. + * + *

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 resolveIfAllowed(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(); + } + + for (InetAddress addr : resolved) { + if (DISALLOWED_HOSTS.contains(normalizeHostForComparisonQuietly(addr.getHostAddress()))) { + 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.isAnyLocalAddress() + || addr.isMulticastAddress()) { + return Optional.empty(); + } + } + + return Optional.of(resolved[0]); + } + 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..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 @@ -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; @@ -8,8 +9,10 @@ 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; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -43,6 +46,54 @@ public void testIsDisallowedAndFailNormalizesMetadataHostnames(String host) { assertTrue(WebClientUtils.isDisallowedAndFail(host, null)); } + @ParameterizedTest + @ValueSource( + strings = { + "127.0.0.1", + "169.254.169.254", + "169.254.10.10", + "100.100.100.200", + "168.63.129.16", + "0.0.0.0", + }) + public void resolveIfAllowed_blocksLoopbackMetadataAndSpecialHosts(String host) { + Optional result = WebClientUtils.resolveIfAllowed(host); + assertTrue(result.isEmpty(), "Expected host " + host + " to be blocked"); + } + + @Test + public void resolveIfAllowed_blocksNullAndEmpty() { + assertTrue(WebClientUtils.resolveIfAllowed(null).isEmpty()); + assertTrue(WebClientUtils.resolveIfAllowed("").isEmpty()); + assertTrue(WebClientUtils.resolveIfAllowed(" ").isEmpty()); + } + + @Test + 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 resolveIfAllowed_allowsLegitimateSmtpHosts(String host) { + Optional result = WebClientUtils.resolveIfAllowed(host); + assertTrue(result.isPresent(), "Expected host " + host + " to be allowed"); + } + + @Test + 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") 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..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 @@ -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,18 +784,59 @@ 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 configuration.")); + } + JavaMailSenderImpl mailSender = new JavaMailSenderImpl(); - mailSender.setHost(requestDTO.getSmtpHost()); + mailSender.setHost(resolvedAddress.get().getHostAddress()); mailSender.setPort(requestDTO.getSmtpPort()); 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 @@ -817,15 +859,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..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 @@ -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; @@ -24,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; @@ -384,6 +387,52 @@ 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) { + return buildDto(smtpHost, 25); + } + + private TestEmailConfigRequestDTO buildDto(String smtpHost, int port) { + TestEmailConfigRequestDTO dto = new TestEmailConfigRequestDTO(); + dto.setSmtpHost(smtpHost); + dto.setSmtpPort(port); + dto.setFromEmail("test@appsmith.com"); + dto.setStarttlsEnabled(false); + return dto; + } + + @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(host))) + .expectErrorSatisfies(e -> { + assertThat(e).isInstanceOf(AppsmithException.class); + 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(); + } + @Test public void setEnv_AndGetAll() { // Create a test map of environment variables