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..aecfffbb3e19c 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,15 @@ 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.IgniteSystemProperties; 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; @@ -122,16 +125,16 @@ public void testWrongNodeId() throws Exception { final String url = CFG_URL_PREFIX + "nodeId=" + wrongId + '@' + 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, - "Failed to establish connection with node (is it a server node?): " + wrongId + } + }, + SQLException.class, + "Failed to establish connection with node (is it a server node?): " + wrongId ); } @@ -147,16 +150,16 @@ public void testClientNodeId() throws Exception { final String url = CFG_URL_PREFIX + "nodeId=" + clientId + '@' + 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, - "Failed to establish connection with node (is it a server node?): " + clientId + } + }, + SQLException.class, + "Failed to establish connection with node (is it a server node?): " + clientId ); } @@ -296,4 +299,57 @@ public void testSqlHints() throws Exception { 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"; + final String expMsg = scheme.startsWith("ftp") ? "always blocked" : "Remote Spring configuration URLs"; + + GridTestUtils.assertThrows( + log, + new Callable() { + @Override public Object call() throws Exception { + try (Connection conn = DriverManager.getConnection(url)) { + return conn; + } + } + }, + SQLException.class, + expMsg + ); + } + } + + /** + * Test that JDBC cfg:// URL with remote HTTP location is allowed when system property is set. + */ + @Test + @WithSystemProperty(key = IgniteSystemProperties.IGNITE_ALLOW_REMOTE_SPRING_CFG_URL, value = "true") + public void testRemoteHttpCfgUrlAllowedWhenFlagSet() { + final String url = CFG_URL_PREFIX + "http://127.0.0.1:1/nonexistent.xml"; + + Throwable err = GridTestUtils.assertThrows( + log, + new Callable() { + @Override public Object call() throws Exception { + try (Connection conn = DriverManager.getConnection(url)) { + return conn; + } + } + }, + SQLException.class, + null + ); + + String msg = err.getMessage(); + + assertFalse( + "Security exception should not be thrown when flag is enabled", + msg != null && msg.contains("Remote Spring configuration URLs") + ); + } } 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 430f5bfbf734f..432548c8bc35d 100644 --- a/modules/core/src/main/java/org/apache/ignite/IgniteSystemProperties.java +++ b/modules/core/src/main/java/org/apache/ignite/IgniteSystemProperties.java @@ -1908,6 +1908,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|FTPS are always blocked regardless of this property due to security 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 d3770f81e541e..60eca21048755 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 @@ -375,6 +375,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()); @@ -2558,12 +2566,72 @@ public static boolean mkdirs(File dir) { public static URL resolveSpringUrl(String springCfgPath) throws IgniteCheckedException { A.notNull(springCfgPath, "springCfgPath"); + String prop = IgniteSystemProperties.IGNITE_ALLOW_REMOTE_SPRING_CFG_URL; + URL url; try { url = new URL(springCfgPath); + + URL cfgUrl = url; + + String scheme = cfgUrl.getProtocol().toLowerCase(Locale.ROOT); + + // Unwrap jar:!/path (potentially nested) to avoid bypassing remote-scheme checks. + while ("jar".equals(scheme)) { + String file = cfgUrl.getFile(); + + int sep = file.indexOf("!/"); + + if (sep <= 0) + break; + + try { + cfgUrl = new URL(file.substring(0, sep)); + scheme = cfgUrl.getProtocol().toLowerCase(Locale.ROOT); + } + catch (MalformedURLException ignored) { + break; + } + } + + if (REMOTE_CFG_SCHEMES.contains(scheme)) { + if (ALWAYS_BLOCKED_CFG_SCHEMES.contains(scheme)) + throw new IgniteCheckedException( + "Spring configuration URL`s with scheme '" + scheme + "' are always blocked " + + "due to security risk. Use a local file or classpath reference instead. " + + "For remote HTTP|HTTPS set system property: -D" + + prop + "=true. " + + "Provided host: " + cfgUrl.getHost() + ); + + boolean allowRemote = IgniteSystemProperties.getBoolean(prop); + + if (!allowRemote) + throw new IgniteCheckedException( + "Remote Spring configuration URL`s (http|https) are not allowed by default " + + "to prevent remote code execution via attacker-controlled Spring XML. " + + "Provided host: " + cfgUrl.getHost() + ". " + + "To allow remote URL`s set system property: -D" + + prop + "=true" + ); + } } catch (MalformedURLException e) { + // "ftps" is not a recognized scheme for java.net.URL, so it lands here rather + // than being caught by the scheme check above. Block it explicitly with the + // same security message for a consistent user-facing error. + String lowerPath = springCfgPath.toLowerCase(Locale.ROOT); + + for (String blockedScheme : ALWAYS_BLOCKED_CFG_SCHEMES) { + if (lowerPath.startsWith(blockedScheme + "://")) + throw new IgniteCheckedException( + "Spring configuration URL`s with scheme '" + blockedScheme + "' are always blocked " + + "due to security risk. Use a local file or classpath reference instead. " + + "For remote HTTP|HTTPS set system property: -D" + + prop + "=true.", e + ); + } url = resolveIgniteUrl(springCfgPath); if (url == null) 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..bee3d75b04f3c 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 @@ -38,6 +38,7 @@ import java.math.BigInteger; import java.net.InetAddress; import java.net.InetSocketAddress; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -1615,6 +1616,78 @@ 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 = IgniteSystemProperties.IGNITE_ALLOW_REMOTE_SPRING_CFG_URL, value = "true") + public void testResolveSpringUrlAllowsHttpWhenPropertySet() throws IgniteCheckedException { + URL url = IgniteUtils.resolveSpringUrl("http://127.0.0.1:1/nonexistent.xml"); + + assertNotNull(url); + assertEquals("http", url.getProtocol()); + } + + /** + * Test that FTP is always blocked even when remote URL property is set. + */ + @Test + @WithSystemProperty(key = IgniteSystemProperties.IGNITE_ALLOW_REMOTE_SPRING_CFG_URL, 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("-");