Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@ public abstract class BasePluginErrorMessages {
"Unable to parse content. Expected an array or object of multipart data";
public static final String ERROR_INVALID_BASE64_FORMAT =
"Invalid BASE64 format. Expected format: data:mimetype;base64,content";
public static final String DS_MISSING_HOSTNAME_ERROR_MSG = "Missing hostname.";
public static final String DS_INVALID_HOSTNAME_CHARS_ERROR_MSG =
"Host value cannot contain `%s` characters. Found `%s`.";
public static final String DS_SSRF_HOST_BLOCKED_ERROR_MSG =
"The hostname '%s' is not allowed. Connections to metadata endpoints, loopback, and link-local addresses are blocked.";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.appsmith.external.helpers;

import com.appsmith.external.exceptions.pluginExceptions.BasePluginErrorMessages;
import com.appsmith.util.WebClientUtils;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.Optional;
import java.util.regex.Pattern;

/**
* Shared hostname validation utility for JDBC database plugins.
* Blocks URI-special characters that can alter JDBC URL semantics (e.g., '#' fragment injection)
* and provides SSRF protection via {@link WebClientUtils#resolveIfAllowed(String)}.
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class JdbcHostValidator {

private static final Pattern DISALLOWED_HOST_CHARS = Pattern.compile("[/:@#?\\\\]");

/**
* Validates that a hostname does not contain URI-special characters that could
* alter JDBC URL parsing (e.g., '#' for fragment injection, '/' for path injection).
*
* @return {@link Optional#empty()} if valid, or an error message string if invalid.
*/
public static Optional<String> validateHostname(String host) {
if (host == null || host.isBlank()) {
return Optional.of(BasePluginErrorMessages.DS_MISSING_HOSTNAME_ERROR_MSG);
}

var matcher = DISALLOWED_HOST_CHARS.matcher(host);
if (matcher.find()) {
return Optional.of(
String.format(BasePluginErrorMessages.DS_INVALID_HOSTNAME_CHARS_ERROR_MSG, matcher.group(), host));
}

return Optional.empty();
}

/**
* Checks whether the hostname is allowed by SSRF protection rules.
* Blocks cloud metadata endpoints, loopback, and link-local addresses.
* Allows RFC 1918 private ranges for self-hosted deployments.
*
* @return {@link Optional#empty()} if allowed, or an error message string if blocked.
*/
public static Optional<String> checkSsrfProtection(String host) {
if (host == null || host.isBlank()) {
return Optional.of(BasePluginErrorMessages.DS_MISSING_HOSTNAME_ERROR_MSG);
}

var resolved = WebClientUtils.resolveIfAllowed(host);
if (resolved.isEmpty()) {
return Optional.of(String.format(BasePluginErrorMessages.DS_SSRF_HOST_BLOCKED_ERROR_MSG, host));
}

return Optional.empty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.appsmith.external.helpers;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

import java.util.Optional;

import static org.junit.jupiter.api.Assertions.assertTrue;

public class JdbcHostValidatorTest {

@ParameterizedTest
@ValueSource(strings = {"myhost", "my.db.host.com", "my-db-host", "10.0.1.5", "192.168.1.100"})
public void validateHostname_allowsValidHostnames(String host) {
Optional<String> result = JdbcHostValidator.validateHostname(host);
assertTrue(result.isEmpty(), "Expected host '" + host + "' to be allowed, but got: " + result.orElse(""));
}

@Test
public void validateHostname_blocksNullAndEmpty() {
assertTrue(JdbcHostValidator.validateHostname(null).isPresent());
assertTrue(JdbcHostValidator.validateHostname("").isPresent());
assertTrue(JdbcHostValidator.validateHostname(" ").isPresent());
}

@ParameterizedTest
@ValueSource(strings = {"evil.com#", "evil.com/path", "evil.com:1234", "user@evil.com", "evil.com?", "evil.com\\"})
public void validateHostname_blocksDisallowedCharacters(String host) {
Optional<String> result = JdbcHostValidator.validateHostname(host);
assertTrue(result.isPresent(), "Expected host '" + host + "' to be blocked");
}

@ParameterizedTest
@ValueSource(strings = {"169.254.169.254", "100.100.100.200", "168.63.129.16"})
public void checkSsrfProtection_blocksMetadataIps(String host) {
Optional<String> result = JdbcHostValidator.checkSsrfProtection(host);
assertTrue(result.isPresent(), "Expected metadata IP '" + host + "' to be blocked by SSRF check");
}

@ParameterizedTest
@ValueSource(strings = {"10.0.1.5", "192.168.1.1", "172.16.0.1"})
public void checkSsrfProtection_allowsPrivateIps(String host) {
Optional<String> result = JdbcHostValidator.checkSsrfProtection(host);
assertTrue(result.isEmpty(), "Expected private IP '" + host + "' to be allowed for self-hosted deployments");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.exceptions.pluginExceptions.StaleConnectionException;
import com.appsmith.external.helpers.DataTypeServiceUtils;
import com.appsmith.external.helpers.JdbcHostValidator;
import com.appsmith.external.helpers.MustacheHelper;
import com.appsmith.external.helpers.Stopwatch;
import com.appsmith.external.models.ActionConfiguration;
Expand Down Expand Up @@ -396,6 +397,20 @@ public Set<String> validateDatasource(@NonNull DatasourceConfiguration datasourc

if (isEmpty(datasourceConfiguration.getEndpoints())) {
invalids.add(MssqlErrorMessages.DS_MISSING_ENDPOINT_ERROR_MSG);
} else {
for (final Endpoint endpoint : datasourceConfiguration.getEndpoints()) {
if (StringUtils.isEmpty(endpoint.getHost())) {
invalids.add(MssqlErrorMessages.DS_MISSING_HOSTNAME_ERROR_MSG);
} else {
Optional<String> hostnameError = JdbcHostValidator.validateHostname(endpoint.getHost());
if (hostnameError.isPresent()) {
invalids.add(hostnameError.get());
} else {
JdbcHostValidator.checkSsrfProtection(endpoint.getHost())
.ifPresent(invalids::add);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

if (datasourceConfiguration.getConnection() != null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,6 @@ public class MssqlErrorMessages extends BasePluginErrorMessages {
public static final String DS_MISSING_USERNAME_ERROR_MSG = "Missing username for authentication.";

public static final String DS_MISSING_PASSWORD_ERROR_MSG = "Missing password for authentication.";

public static final String DS_MISSING_HOSTNAME_ERROR_MSG = "Missing hostname.";
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import com.appsmith.external.models.PsParameterDTO;
import com.appsmith.external.models.RequestParamDTO;
import com.appsmith.external.models.SSLDetails;
import com.external.plugins.exceptions.MssqlErrorMessages;
import com.external.plugins.exceptions.MssqlPluginError;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
Expand All @@ -24,6 +25,8 @@
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.testcontainers.containers.MSSQLServerContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
Expand Down Expand Up @@ -773,4 +776,71 @@ public void testGetEndpointIdentifierForRateLimit_HostPresentPortAbsent_ReturnsC
})
.verifyComplete();
}

@ParameterizedTest
@ValueSource(strings = {"evil.com#fragment", "evil.com/"})
public void testValidateDatasource_withInvalidHostname_returnsInvalid(String hostname) {
DatasourceConfiguration dsConfig = new DatasourceConfiguration();
Endpoint endpoint = new Endpoint();
endpoint.setHost(hostname);
endpoint.setPort(1433L);
dsConfig.setEndpoints(List.of(endpoint));
DBAuth auth = new DBAuth();
auth.setUsername("test");
auth.setPassword("test");
dsConfig.setAuthentication(auth);
Set<String> output = mssqlPluginExecutor.validateDatasource(dsConfig);
assertTrue(
output.stream().anyMatch(msg -> msg.contains("Host value cannot contain")),
"Expected hostname validation error for '" + hostname + "', got: " + output);
}

@Test
public void testValidateDatasource_withEmptyHostname_returnsInvalid() {
DatasourceConfiguration dsConfig = new DatasourceConfiguration();
Endpoint endpoint = new Endpoint();
endpoint.setHost("");
endpoint.setPort(1433L);
dsConfig.setEndpoints(List.of(endpoint));
DBAuth auth = new DBAuth();
auth.setUsername("test");
auth.setPassword("test");
dsConfig.setAuthentication(auth);
Set<String> output = mssqlPluginExecutor.validateDatasource(dsConfig);
assertTrue(output.contains(MssqlErrorMessages.DS_MISSING_HOSTNAME_ERROR_MSG));
}

@Test
public void testValidateDatasource_withMetadataIp_returnsInvalid() {
DatasourceConfiguration dsConfig = new DatasourceConfiguration();
Endpoint endpoint = new Endpoint();
endpoint.setHost("169.254.169.254");
endpoint.setPort(1433L);
dsConfig.setEndpoints(List.of(endpoint));
DBAuth auth = new DBAuth();
auth.setUsername("test");
auth.setPassword("test");
dsConfig.setAuthentication(auth);
Set<String> output = mssqlPluginExecutor.validateDatasource(dsConfig);
assertTrue(
output.stream().anyMatch(msg -> msg.contains("not allowed")),
"Expected SSRF blocked error for metadata IP, got: " + output);
}

@Test
public void testValidateDatasource_withValidHostname_noHostErrors() {
DatasourceConfiguration dsConfig = new DatasourceConfiguration();
Endpoint endpoint = new Endpoint();
endpoint.setHost("mydb.internal.com");
endpoint.setPort(1433L);
dsConfig.setEndpoints(List.of(endpoint));
DBAuth auth = new DBAuth();
auth.setUsername("test");
auth.setPassword("test");
dsConfig.setAuthentication(auth);
Set<String> output = mssqlPluginExecutor.validateDatasource(dsConfig);
assertTrue(
output.stream().noneMatch(msg -> msg.contains("Host value") || msg.contains("hostname")),
"Expected no hostname errors for valid host, got: " + output);
}
Comment on lines +780 to +845
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add MSSQL plugin-level SSRF regression cases.

These tests cover invalid characters, but they don’t verify blocking of metadata/loopback/link-local hosts. Please add cases like 169.254.169.254 and 127.0.0.1 and assert the “not allowed” error path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/server/appsmith-plugins/mssqlPlugin/src/test/java/com/external/plugins/MssqlPluginTest.java`
around lines 780 - 828, Add SSRF regression tests in MssqlPluginTest that call
mssqlPluginExecutor.validateDatasource with endpoints using loopback and
metadata hosts (e.g., "127.0.0.1" and "169.254.169.254") and assert the
validation output contains the plugin’s "not allowed" / forbidden-host error
path; specifically, add new `@ParameterizedTest` or `@Test` methods mirroring the
existing hostname tests (reuse Endpoint/DatasourceConfiguration/DBAuth setup)
and assert the returned Set<String> from validateDatasource(...) contains the
expected "not allowed" message (or the canonical error constant used by the
plugin) for those addresses.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.helpers.JdbcHostValidator;
import com.appsmith.external.models.ConnectionContext;
import com.appsmith.external.models.DBAuth;
import com.appsmith.external.models.DatasourceConfiguration;
Expand All @@ -21,6 +22,7 @@
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;

import static com.appsmith.external.constants.PluginConstants.HostName.LOCALHOST;
Expand Down Expand Up @@ -178,9 +180,14 @@ public static Set<String> validateDatasource(DatasourceConfiguration datasourceC
for (final Endpoint endpoint : datasourceConfiguration.getEndpoints()) {
if (endpoint.getHost() == null || endpoint.getHost().isBlank()) {
invalids.add(MySQLErrorMessages.DS_MISSING_HOSTNAME_ERROR_MSG);
} else if (endpoint.getHost().contains("/")
|| endpoint.getHost().contains(":")) {
invalids.add(String.format(MySQLErrorMessages.DS_INVALID_HOSTNAME_ERROR_MSG, endpoint.getHost()));
} else {
Optional<String> hostnameError = JdbcHostValidator.validateHostname(endpoint.getHost());
if (hostnameError.isPresent()) {
invalids.add(hostnameError.get());
} else {
JdbcHostValidator.checkSsrfProtection(endpoint.getHost())
.ifPresent(invalids::add);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ private DatasourceConfiguration getDatasourceConfigurationWithStandardConnection

/* Set MySQL endpoints */
ArrayList<Endpoint> mysqlEndpoints = new ArrayList<>();
mysqlEndpoints.add(new Endpoint("mysqlHost", 3306L));
mysqlEndpoints.add(new Endpoint("10.0.0.1", 3306L));
datasourceConfiguration.setEndpoints(mysqlEndpoints);

/* Set DB username and password */
Expand Down Expand Up @@ -180,7 +180,24 @@ public void testValidateDatasourceInvalidEndpoint() {
String hostname = "r2dbc:mysql://localhost";
dsConfig.getEndpoints().get(0).setHost(hostname);
Set<String> output = pluginExecutor.validateDatasource(dsConfig);
assertTrue(output.contains("Host value cannot contain `/` or `:` characters. Found `" + hostname + "`."));
assertTrue(
output.stream().anyMatch(msg -> msg.contains("Host value cannot contain") && msg.contains(hostname)));
}

@Test
public void testValidateDatasource_withHashInHostname_returnsInvalid() {
DatasourceConfiguration dsConfig = getDatasourceConfigurationWithStandardConnectionMethod();
dsConfig.getEndpoints().get(0).setHost("evil.com#fragment");
Set<String> output = pluginExecutor.validateDatasource(dsConfig);
assertTrue(output.stream().anyMatch(msg -> msg.contains("Host value cannot contain")));
}

@Test
public void testValidateDatasource_withMetadataIp_returnsInvalid() {
DatasourceConfiguration dsConfig = getDatasourceConfigurationWithStandardConnectionMethod();
dsConfig.getEndpoints().get(0).setHost("169.254.169.254");
Set<String> output = pluginExecutor.validateDatasource(dsConfig);
assertTrue(output.stream().anyMatch(msg -> msg.contains("not allowed")));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -360,9 +360,9 @@ public void testDatasourceWithNullPassword() {
assertEquals("mysql", auth.getUsername());
assertEquals("", auth.getPassword());

// Validate datastore
// Validate datastore — no password error expected (SSRF error for localhost is OK)
Set<String> output = pluginExecutor.validateDatasource(dsConfig);
assertTrue(output.isEmpty());
assertFalse(output.contains(MySQLErrorMessages.DS_MISSING_PASSWORD_ERROR_MSG));
// test connect
Mono<ConnectionContext<ConnectionPool>> connectionContextMono = pluginExecutor
.datasourceCreate(dsConfig)
Expand Down Expand Up @@ -391,9 +391,9 @@ public void testDatasourceWithRootUserAndNullPassword() {
assertEquals("root", mySQLContainerWithInvalidTimezone.getUsername());
assertEquals("", mySQLContainerWithInvalidTimezone.getPassword());

// Validate datastore
// Validate datastore — no password error expected (SSRF error for localhost is OK)
Set<String> output = pluginExecutor.validateDatasource(dsConfig);
assertTrue(output.isEmpty());
assertFalse(output.contains(MySQLErrorMessages.DS_MISSING_PASSWORD_ERROR_MSG));
// test connect
Mono<ConnectionContext<ConnectionPool>> connectionContextMono = pluginExecutor
.datasourceCreate(dsConfig)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginError;
import com.appsmith.external.exceptions.pluginExceptions.AppsmithPluginException;
import com.appsmith.external.exceptions.pluginExceptions.StaleConnectionException;
import com.appsmith.external.helpers.JdbcHostValidator;
import com.appsmith.external.models.DBAuth;
import com.appsmith.external.models.DatasourceConfiguration;
import com.appsmith.external.models.DatasourceStructure;
Expand All @@ -29,6 +30,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -113,9 +115,14 @@ public static Set<String> validateDatasource(DatasourceConfiguration datasourceC
for (final Endpoint endpoint : datasourceConfiguration.getEndpoints()) {
if (isBlank(endpoint.getHost())) {
invalids.add(OracleErrorMessages.DS_MISSING_HOSTNAME_ERROR_MSG);
} else if (endpoint.getHost().contains("/")
|| endpoint.getHost().contains(":")) {
invalids.add(String.format(OracleErrorMessages.DS_INVALID_HOSTNAME_ERROR_MSG, endpoint.getHost()));
} else {
Optional<String> hostnameError = JdbcHostValidator.validateHostname(endpoint.getHost());
if (hostnameError.isPresent()) {
invalids.add(hostnameError.get());
} else {
JdbcHostValidator.checkSsrfProtection(endpoint.getHost())
.ifPresent(invalids::add);
}
}
}
}
Expand Down
Loading
Loading