diff --git a/modules/clients/src/test/java/org/apache/ignite/internal/jdbc2/JdbcConnectionSelfTest.java b/modules/clients/src/test/java/org/apache/ignite/internal/jdbc2/JdbcConnectionSelfTest.java index b8056bfaca7a8..6ac3cca69ece6 100644 --- a/modules/clients/src/test/java/org/apache/ignite/internal/jdbc2/JdbcConnectionSelfTest.java +++ b/modules/clients/src/test/java/org/apache/ignite/internal/jdbc2/JdbcConnectionSelfTest.java @@ -20,12 +20,14 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; +import java.util.Arrays; import java.util.UUID; import java.util.concurrent.Callable; import org.apache.ignite.configuration.CacheConfiguration; import org.apache.ignite.configuration.IgniteConfiguration; import org.apache.ignite.internal.IgniteEx; import org.apache.ignite.testframework.GridTestUtils; +import org.apache.ignite.testframework.junits.WithSystemProperty; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.jetbrains.annotations.NotNull; import org.junit.Test; @@ -36,10 +38,14 @@ * Connection test. */ public class JdbcConnectionSelfTest extends GridCommonAbstractTest { - /** Custom cache name. */ + /** + * Custom cache name. + */ private static final String CUSTOM_CACHE_NAME = "custom-cache"; - /** Grid count. */ + /** + * Grid count. + */ private static final int GRID_CNT = 2; /** @@ -49,8 +55,11 @@ protected String configURL() { return "modules/clients/src/test/config/jdbc-config.xml"; } - /** {@inheritDoc} */ - @Override protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { + /** + * {@inheritDoc} + */ + @Override + protected IgniteConfiguration getConfiguration(String igniteInstanceName) throws Exception { IgniteConfiguration cfg = super.getConfiguration(igniteInstanceName); cfg.setCacheConfiguration(cacheConfiguration(DEFAULT_CACHE_NAME), cacheConfiguration(CUSTOM_CACHE_NAME)); @@ -71,8 +80,11 @@ private CacheConfiguration cacheConfiguration(@NotNull String name) throws Excep return cfg; } - /** {@inheritDoc} */ - @Override protected void beforeTestsStarted() throws Exception { + /** + * {@inheritDoc} + */ + @Override + protected void beforeTestsStarted() throws Exception { startGridsMultiThreaded(GRID_CNT); } @@ -85,12 +97,12 @@ public void testDefaults() throws Exception { try (Connection conn = DriverManager.getConnection(url)) { assertNotNull(conn); - assertTrue(((JdbcConnection)conn).ignite().configuration().isClientMode()); + assertTrue(((JdbcConnection) conn).ignite().configuration().isClientMode()); } try (Connection conn = DriverManager.getConnection(url + '/')) { assertNotNull(conn); - assertTrue(((JdbcConnection)conn).ignite().configuration().isClientMode()); + assertTrue(((JdbcConnection) conn).ignite().configuration().isClientMode()); } } @@ -124,7 +136,8 @@ public void testWrongNodeId() throws Exception { GridTestUtils.assertThrows( log, new Callable() { - @Override public Object call() throws Exception { + @Override + public Object call() throws Exception { try (Connection conn = DriverManager.getConnection(url)) { return conn; } @@ -149,7 +162,8 @@ public void testClientNodeId() throws Exception { GridTestUtils.assertThrows( log, new Callable() { - @Override public Object call() throws Exception { + @Override + public Object call() throws Exception { try (Connection conn = DriverManager.getConnection(url)) { return conn; } @@ -180,16 +194,17 @@ public void testWrongCache() throws Exception { final String url = CFG_URL_PREFIX + "cache=wrongCacheName@" + configURL(); GridTestUtils.assertThrows( - log, - new Callable() { - @Override public Object call() throws Exception { - try (Connection conn = DriverManager.getConnection(url)) { - return conn; + log, + new Callable() { + @Override + public Object call() throws Exception { + try (Connection conn = DriverManager.getConnection(url)) { + return conn; + } } - } - }, - SQLException.class, - "Client is invalid. Probably cache name is wrong." + }, + SQLException.class, + "Client is invalid. Probably cache name is wrong." ); } @@ -209,16 +224,17 @@ public void testClose() throws Exception { assertTrue(conn.isClosed()); GridTestUtils.assertThrows( - log, - new Callable() { - @Override public Object call() throws Exception { - conn.isValid(2); + log, + new Callable() { + @Override + public Object call() throws Exception { + conn.isValid(2); - return null; - } - }, - SQLException.class, - "Connection is closed." + return null; + } + }, + SQLException.class, + "Connection is closed." ); } } @@ -265,35 +281,82 @@ public void testTxAllowedRollback() throws Exception { @Test public void testSqlHints() throws Exception { try (final Connection conn = DriverManager.getConnection(CFG_URL_PREFIX + "enforceJoinOrder=true@" - + configURL())) { - assertTrue(((JdbcConnection)conn).isEnforceJoinOrder()); - assertFalse(((JdbcConnection)conn).isDistributedJoins()); - assertFalse(((JdbcConnection)conn).isCollocatedQuery()); - assertFalse(((JdbcConnection)conn).skipReducerOnUpdate()); + + configURL())) { + assertTrue(((JdbcConnection) conn).isEnforceJoinOrder()); + assertFalse(((JdbcConnection) conn).isDistributedJoins()); + assertFalse(((JdbcConnection) conn).isCollocatedQuery()); + assertFalse(((JdbcConnection) conn).skipReducerOnUpdate()); } try (final Connection conn = DriverManager.getConnection(CFG_URL_PREFIX + "distributedJoins=true@" - + configURL())) { - assertFalse(((JdbcConnection)conn).isEnforceJoinOrder()); - assertTrue(((JdbcConnection)conn).isDistributedJoins()); - assertFalse(((JdbcConnection)conn).isCollocatedQuery()); - assertFalse(((JdbcConnection)conn).skipReducerOnUpdate()); + + configURL())) { + assertFalse(((JdbcConnection) conn).isEnforceJoinOrder()); + assertTrue(((JdbcConnection) conn).isDistributedJoins()); + assertFalse(((JdbcConnection) conn).isCollocatedQuery()); + assertFalse(((JdbcConnection) conn).skipReducerOnUpdate()); } try (final Connection conn = DriverManager.getConnection(CFG_URL_PREFIX + "collocated=true@" - + configURL())) { - assertFalse(((JdbcConnection)conn).isEnforceJoinOrder()); - assertFalse(((JdbcConnection)conn).isDistributedJoins()); - assertTrue(((JdbcConnection)conn).isCollocatedQuery()); - assertFalse(((JdbcConnection)conn).skipReducerOnUpdate()); + + configURL())) { + assertFalse(((JdbcConnection) conn).isEnforceJoinOrder()); + assertFalse(((JdbcConnection) conn).isDistributedJoins()); + assertTrue(((JdbcConnection) conn).isCollocatedQuery()); + assertFalse(((JdbcConnection) conn).skipReducerOnUpdate()); } try (final Connection conn = DriverManager.getConnection(CFG_URL_PREFIX + "skipReducerOnUpdate=true@" - + configURL())) { - assertFalse(((JdbcConnection)conn).isEnforceJoinOrder()); - assertFalse(((JdbcConnection)conn).isDistributedJoins()); - assertFalse(((JdbcConnection)conn).isCollocatedQuery()); - assertTrue(((JdbcConnection)conn).skipReducerOnUpdate()); + + configURL())) { + assertFalse(((JdbcConnection) conn).isEnforceJoinOrder()); + assertFalse(((JdbcConnection) conn).isDistributedJoins()); + assertFalse(((JdbcConnection) conn).isCollocatedQuery()); + assertTrue(((JdbcConnection) conn).skipReducerOnUpdate()); + } + } + + /** + * Test that JDBC cfg:// URL with remote HTTP, HTTPS, and FTP location is blocked. + */ + @Test + public void testRemoteCfgUrlsAreBlocked() { + for (String scheme : Arrays.asList("http", "https", "ftp", "ftps")) { + final String url = CFG_URL_PREFIX + scheme + "://attacker.example.com/evil.xml"; + + GridTestUtils.assertThrows( + log, + new Callable() { + @Override + public Object call() throws Exception { + try (Connection conn = DriverManager.getConnection(url)) { + return conn; + } + } + }, + SQLException.class, + null + ); } } -} + + /** + * Test that JDBC cfg:// URL with remote HTTP location is allowed when system property is set. + */ + @Test + @WithSystemProperty(key = "ignite.spring.cfg.allowRemoteUrl", value = "true") + public void testRemoteHttpCfgUrlAllowedWhenFlagSet() { + final String url = CFG_URL_PREFIX + "http://127.0.0.1:1/nonexistent.xml"; + + GridTestUtils.assertThrows( + log, + new Callable() { + @Override + public Object call() throws Exception { + try (Connection conn = DriverManager.getConnection(url)) { + return conn; + } + } + }, + SQLException.class, + null + ); + } +} \ No newline at end of file diff --git a/modules/core/src/main/java/org/apache/ignite/IgniteSystemProperties.java b/modules/core/src/main/java/org/apache/ignite/IgniteSystemProperties.java index b1c36a9015827..8c52a124ede20 100644 --- a/modules/core/src/main/java/org/apache/ignite/IgniteSystemProperties.java +++ b/modules/core/src/main/java/org/apache/ignite/IgniteSystemProperties.java @@ -1967,6 +1967,15 @@ public final class IgniteSystemProperties extends IgniteCommonsSystemProperties @SystemProperty(value = "Packages list to expose in configuration view") public static final String IGNITE_CONFIGURATION_VIEW_PACKAGES = "IGNITE_CONFIGURATION_VIEW_PACKAGES"; + + /** + * System property to allow remote HTTP/HTTPS URLs when loading Spring XML configuration. + * Remote URLs are blocked by default to prevent RCE via attacker-controlled Spring XML. + * FTP is always blocked regardless of this property due to MITM risk. + */ + @SystemProperty(value = "Allow remote HTTP/HTTPS URLs when loading Spring XML configuration") + public static final String IGNITE_ALLOW_REMOTE_SPRING_CFG_URL = "ignite.spring.cfg.allowRemoteUrl"; + /** * Enforces singleton. */ diff --git a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java index 1513346138589..fd18f13a161e0 100755 --- a/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/util/IgniteUtils.java @@ -387,6 +387,14 @@ public abstract class IgniteUtils extends CommonUtils { /** Ignite Work Directory. */ public static final String IGNITE_WORK_DIR = System.getenv(IgniteSystemProperties.IGNITE_WORK_DIR); + /** URL schemes that load remote content and are blocked by default in Spring configuration. */ + private static final Set REMOTE_CFG_SCHEMES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("http", "https", "ftp", "ftps"))); + + /** URL schemes that are always blocked regardless of system property due to security risk. */ + private static final Set ALWAYS_BLOCKED_CFG_SCHEMES = Collections.unmodifiableSet( + new HashSet<>(Arrays.asList("ftp", "ftps"))); + /** Random is used to get random server node to authentication from client node. */ private static final Random RND = new Random(System.currentTimeMillis()); @@ -2600,6 +2608,31 @@ public static URL resolveSpringUrl(String springCfgPath) throws IgniteCheckedExc try { url = new URL(springCfgPath); + + String scheme = url.getProtocol().toLowerCase(); + + if (REMOTE_CFG_SCHEMES.contains(scheme)) { + // FTP is always blocked — unencrypted, susceptible to MITM + if (ALWAYS_BLOCKED_CFG_SCHEMES.contains(scheme)) + throw new IgniteCheckedException( + "Spring configuration URLs with scheme '" + scheme + "' are always blocked " + + "due to security risk (unencrypted transfer, MITM vulnerability). " + + "Use HTTPS or a local file/classpath reference instead. " + + "Provided host: " + url.getHost() + ); + + // HTTP/HTTPS blocked by default, allowed via system property + boolean allowRemote = Boolean.getBoolean(IgniteSystemProperties.IGNITE_ALLOW_REMOTE_SPRING_CFG_URL); + + if (!allowRemote) + throw new IgniteCheckedException( + "Remote Spring configuration URLs (http/https) are not allowed by default " + + "to prevent remote code execution via attacker-controlled Spring XML. " + + "Provided host: " + url.getHost() + ". " + + "To allow remote URLs set system property: -D" + + IgniteSystemProperties.IGNITE_ALLOW_REMOTE_SPRING_CFG_URL + "=true" + ); + } } catch (MalformedURLException e) { url = resolveIgniteUrl(springCfgPath); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/util/IgniteUtilsSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/util/IgniteUtilsSelfTest.java index 19f6ad17c38d8..e0149ef69bd41 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/util/IgniteUtilsSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/util/IgniteUtilsSelfTest.java @@ -1615,6 +1615,85 @@ public void testLongToBytes() { } } + /** + * Test that remote HTTPS URL in Spring cfg is blocked by default. + */ + @Test + public void testResolveSpringUrlBlocksHttpsByDefault() { + assertThrows(log, () -> { + IgniteUtils.resolveSpringUrl("https://attacker.example.com/evil.xml"); + return null; + }, IgniteCheckedException.class, "Remote Spring configuration URLs"); + } + + /** + * Test that remote FTP URL in Spring cfg is blocked by default. + */ + @Test + public void testResolveSpringUrlBlocksFtpByDefault() { + assertThrows(log, () -> { + IgniteUtils.resolveSpringUrl("ftp://attacker.example.com/evil.xml"); + return null; + }, IgniteCheckedException.class, "always blocked"); + } + + /** + * Test that remote HTTP URL in Spring cfg is blocked by default + * and error message contains guidance on how to enable remote URLs. + */ + @Test + public void testResolveSpringUrlBlocksHttpByDefault() { + try { + IgniteUtils.resolveSpringUrl("http://attacker.example.com/evil.xml"); + fail("Expected IgniteCheckedException"); + } + catch (IgniteCheckedException e) { + assertTrue( + "Error message should contain system property name", + e.getMessage().contains(IgniteSystemProperties.IGNITE_ALLOW_REMOTE_SPRING_CFG_URL) + ); + assertFalse( + "Error message should not contain full URL to avoid credential leak", + e.getMessage().contains("http://attacker.example.com/evil.xml") + ); + assertTrue( + "Error message should contain host", + e.getMessage().contains("attacker.example.com") + ); + } + } + + /** + * Test that remote HTTP URL is allowed when system property is set. + */ + @Test + @WithSystemProperty(key = "ignite.spring.cfg.allowRemoteUrl", value = "true") + public void testResolveSpringUrlAllowsHttpWhenPropertySet() { + // Should not throw — validation passes when flag is true. + // Will throw MalformedURLException or connection error, not our security check. + try { + IgniteUtils.resolveSpringUrl("http://127.0.0.1:1/nonexistent.xml"); + } + catch (IgniteCheckedException e) { + assertFalse( + "Should not throw security exception when flag is enabled", + e.getMessage().contains("Remote Spring configuration URLs") + ); + } + } + + /** + * Test that FTP is always blocked even when remote URL property is set. + */ + @Test + @WithSystemProperty(key = "ignite.spring.cfg.allowRemoteUrl", value = "true") + public void testResolveSpringUrlFtpAlwaysBlocked() { + assertThrows(log, () -> { + IgniteUtils.resolveSpringUrl("ftp://attacker.example.com/evil.xml"); + return null; + }, IgniteCheckedException.class, "always blocked"); + } + /** */ private byte[] asByteArray(String text) { String[] split = text.split("-");