diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 206bbaa..3d79161 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -27,3 +27,6 @@ jobs: - name: Build with Maven run: mvn -B -f SimpleAPI/pom.xml package + + - name: Verify legacy Connector/J fallback TLS + run: mvn -B -f SimpleAPI/pom.xml -Plegacy-mysql-tls-test -Dtest=MariaDbFallbackTlsTest test diff --git a/SimpleAPI/pom.xml b/SimpleAPI/pom.xml index 7bd56c1..153fdb1 100644 --- a/SimpleAPI/pom.xml +++ b/SimpleAPI/pom.xml @@ -13,6 +13,9 @@ ${maven.build.timestamp} yyyy-MM-dd HH:mm NOTSET + com.mysql + mysql-connector-j + 9.5.0 21 21 21 @@ -298,6 +301,12 @@ 6.1.2 test + + ${mysql.connector.test.group} + ${mysql.connector.test.artifact} + ${mysql.connector.test.version} + test + org.mockito mockito-core @@ -354,6 +363,14 @@ + + legacy-mysql-tls-test + + mysql + mysql-connector-java + 8.0.12 + + normal diff --git a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/ConnectionManager.java b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/ConnectionManager.java index 679477a..e2cbe94 100644 --- a/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/ConnectionManager.java +++ b/SimpleAPI/src/main/java/com/bencodez/simpleapi/sql/mysql/ConnectionManager.java @@ -297,16 +297,72 @@ String buildJdbcUrl(String driverClassName) { if (postgreSqlTlsMode != PostgreSqlTlsMode.LEGACY) { throw new IllegalArgumentException("PostgreSqlTlsMode applies only to PostgreSQL connections"); } - boolean maria = (dbType == DbType.MARIADB) || "org.mariadb.jdbc.Driver".equals(driverClassName); + boolean maria = !"com.mysql.cj.jdbc.Driver".equals(driverClassName) + && (dbType == DbType.MARIADB || "org.mariadb.jdbc.Driver".equals(driverClassName)); String base = maria ? String.format("jdbc:mariadb://%s:%s/%s", host, port, database) : String.format("jdbc:mysql://%s:%s/%s", host, port, database); + String fallbackTls = !maria && dbType == DbType.MARIADB && useSSL + ? requiredMysqlFallbackTls(extra) : ""; return base + "?useSSL=" + useSSL + "&allowMultiQueries=true" + "&rewriteBatchedStatements=true" + "&useDynamicCharsetInfo=false" + "&allowPublicKeyRetrieval=" + publicKeyRetrieval + "&tcpKeepAlive=true" + "&connectTimeout=10000" + "&socketTimeout=30000" + "&serverTimezone=UTC" - + extra; + + extra + fallbackTls; } + /** Require TLS on both modern and pre-8.0.13 Connector/J when MariaDB SSL is enabled. */ + private String requiredMysqlFallbackTls(String extra) { + String mode = null; + boolean verifyCertificate = false; + String options = extra.startsWith("?") || extra.startsWith("&") ? extra.substring(1) : extra; + for (String parameter : options.split("&", -1)) { + String[] pair = parameter.split("=", 2); + String key; + String value; + try { + key = URLDecoder.decode(pair[0], StandardCharsets.UTF_8).trim().toLowerCase(Locale.ROOT); + value = pair.length == 2 ? URLDecoder.decode(pair[1], StandardCharsets.UTF_8).trim() : ""; + } catch (IllegalArgumentException invalid) { + throw new IllegalArgumentException("Invalid encoded parameter in MariaDB fallback Line"); + } + if (key.equals("sslmode")) { + String selected = value.toUpperCase(Locale.ROOT); + if (mode != null || !(selected.equals("REQUIRED") || selected.equals("VERIFY_CA") + || selected.equals("VERIFY_IDENTITY"))) { + throw new IllegalArgumentException("MariaDB UseSSL requires a single mandatory Connector/J TLS mode"); + } + mode = selected; + } else if (key.equals("usessl") || key.equals("requiressl")) { + if (!value.equalsIgnoreCase("true")) { + throw new IllegalArgumentException("MariaDB UseSSL forbids disabling Connector/J TLS"); + } + } else if (key.equals("verifyservercertificate") && value.equalsIgnoreCase("true")) { + verifyCertificate = true; + } + } + String requiredMode = mode != null ? mode : verifyCertificate ? "VERIFY_CA" : "REQUIRED"; + if (verifyCertificate && requiredMode.equals("REQUIRED")) requiredMode = "VERIFY_CA"; + if (requiredMode.equals("VERIFY_IDENTITY") && !mysqlDriverSupportsSslMode()) { + throw new IllegalArgumentException("MariaDB VERIFY_IDENTITY requires Connector/J hostname verification support"); + } + return "&sslMode=" + requiredMode + "&requireSSL=true" + + (requiredMode.startsWith("VERIFY_") ? "&verifyServerCertificate=true" : ""); + } + + /** Query the driver's supported JDBC properties without opening a connection. */ + private boolean mysqlDriverSupportsSslMode() { + try { + java.sql.Driver driver = (java.sql.Driver) Class.forName("com.mysql.cj.jdbc.Driver") + .getDeclaredConstructor().newInstance(); + for (java.sql.DriverPropertyInfo property : driver.getPropertyInfo("jdbc:mysql://localhost/", null)) { + if ("sslMode".equals(property.name)) return true; + } + return false; + } catch (ReflectiveOperationException | SQLException | LinkageError failure) { + throw new IllegalArgumentException("Unable to determine Connector/J TLS verification support", failure); + } + } + private void rejectConflictingPostgreSqlTlsOptions(String extra) { String options = extra.startsWith("?") || extra.startsWith("&") ? extra.substring(1) : extra; for (String parameter : options.split("&", -1)) { diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MariaDbDriverFallbackTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MariaDbDriverFallbackTest.java new file mode 100644 index 0000000..9cc3741 --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MariaDbDriverFallbackTest.java @@ -0,0 +1,94 @@ +package com.bencodez.simpleapi.sql.mysql; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.tools.ToolProvider; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.simpleapi.tests.shared.SharedRuntimeClasspath; + +class MariaDbDriverFallbackTest { + @TempDir Path directory; + + @Test void mysqlFallbackUsesMysqlSchemeAndKeepsConfiguredOptions() { + ConnectionManager manager = manager(DbType.MARIADB); + manager.setUseSSL(true); + manager.setPublicKeyRetrieval(true); + manager.setStr("&customOption=retained"); + String url = manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver"); + assertTrue(url.startsWith("jdbc:mysql://localhost:3306/votes?")); + assertTrue(url.contains("useSSL=true")); + assertTrue(url.contains("allowPublicKeyRetrieval=true")); + assertTrue(url.contains("&customOption=retained")); + assertTrue(url.endsWith("&sslMode=REQUIRED&requireSSL=true")); + assertEquals(DbType.MARIADB, manager.getDbType()); + } + + @Test void nativeMariaDbDriverRetainsMariaDbScheme() { + assertTrue(manager(DbType.MARIADB).buildJdbcUrl("org.mariadb.jdbc.Driver").startsWith("jdbc:mariadb:")); + } + + @Test void explicitMariaDbDriverOverridesMysqlTypeAsBefore() { + assertTrue(manager(DbType.MYSQL).buildJdbcUrl("org.mariadb.jdbc.Driver").startsWith("jdbc:mariadb:")); + } + + @Test void customMariaDbDriverRetainsConfiguredScheme() { + assertTrue(manager(DbType.MARIADB).buildJdbcUrl("example.CustomDriver").startsWith("jdbc:mariadb:")); + } + + @Test void mysqlAndPostgresqlSelectionsRemainUnchanged() { + assertTrue(manager(DbType.MYSQL).buildJdbcUrl("com.mysql.cj.jdbc.Driver").startsWith("jdbc:mysql:")); + assertTrue(manager(DbType.POSTGRESQL).buildJdbcUrl("org.postgresql.Driver").startsWith("jdbc:postgresql:")); + } + + @Test void absentMariaDbDriverResolvesToDriverThatAcceptsGeneratedUrl() throws Exception { + try (var loader = isolatedMysqlDriver()) { + assertThrows(ClassNotFoundException.class, () -> loader.loadClass("org.mariadb.jdbc.Driver")); + Class type = loader.loadClass(ConnectionManager.class.getName()); + Object manager = type.getConstructor(String.class, String.class, String.class, String.class, String.class) + .newInstance("localhost", "3306", "test", "", "votes"); + Class dbType = loader.loadClass(DbType.class.getName()); + type.getMethod("setDbType", dbType).invoke(manager, dbType.getField("MARIADB").get(null)); + var resolve = type.getDeclaredMethod("resolveDriver"); resolve.setAccessible(true); + assertEquals("com.mysql.cj.jdbc.Driver", resolve.invoke(manager)); + var build = type.getDeclaredMethod("buildJdbcUrl", String.class); build.setAccessible(true); + String url = (String) build.invoke(manager, resolve.invoke(manager)); + java.sql.Driver driver = (java.sql.Driver) loader.loadClass("com.mysql.cj.jdbc.Driver") + .getConstructor().newInstance(); + assertTrue(driver.acceptsURL(url)); + type.getMethod("setMariadbFallbackToMysqlDriver", boolean.class).invoke(manager, false); + InvocationTargetException rejected = assertThrows(InvocationTargetException.class, () -> resolve.invoke(manager)); + assertInstanceOf(ClassNotFoundException.class, rejected.getCause()); + } + } + + private java.net.URLClassLoader isolatedMysqlDriver() throws Exception { + Path source = directory.resolve("com/mysql/cj/jdbc/Driver.java"); + Files.createDirectories(source.getParent()); + Files.writeString(source, """ + package com.mysql.cj.jdbc; + public class Driver implements java.sql.Driver { + public boolean acceptsURL(String url) { return url.startsWith("jdbc:mysql:"); } + public java.sql.Connection connect(String url, java.util.Properties p) { return null; } + public java.sql.DriverPropertyInfo[] getPropertyInfo(String u, java.util.Properties p) { return new java.sql.DriverPropertyInfo[0]; } + public int getMajorVersion() { return 1; } + public int getMinorVersion() { return 0; } + public boolean jdbcCompliant() { return false; } + public java.util.logging.Logger getParentLogger() { return java.util.logging.Logger.getGlobal(); } + } + """); + assertEquals(0, ToolProvider.getSystemJavaCompiler().run(null, null, null, + "-proc:none", "-d", directory.toString(), source.toString())); + return SharedRuntimeClasspath.open(ConnectionManager.class.getProtectionDomain().getCodeSource().getLocation(), + directory.toUri().toURL()); + } + + private ConnectionManager manager(DbType type) { + ConnectionManager result = new ConnectionManager("localhost", "3306", "test", "", "votes"); + result.setDbType(type); + return result; + } +} diff --git a/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MariaDbFallbackTlsTest.java b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MariaDbFallbackTlsTest.java new file mode 100644 index 0000000..3e37925 --- /dev/null +++ b/SimpleAPI/src/test/java/com/bencodez/simpleapi/sql/mysql/MariaDbFallbackTlsTest.java @@ -0,0 +1,213 @@ +package com.bencodez.simpleapi.sql.mysql; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayOutputStream; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.sql.SQLException; +import java.util.Properties; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Path; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLException; +import com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity; + +class MariaDbFallbackTlsTest { + @TempDir Path directory; + @Test void sslEnabledFallbackRequiresTls() { + assertTrue(fallback().buildJdbcUrl("com.mysql.cj.jdbc.Driver").endsWith("&sslMode=REQUIRED&requireSSL=true")); + } + + @Test void explicitStrongerModesRemainIntact() { + for (String mode : new String[]{"REQUIRED", "VERIFY_CA", "VERIFY_IDENTITY"}) { + ConnectionManager manager = fallback(); + manager.setStr("&sslMode=" + mode); + if (mode.equals("VERIFY_IDENTITY") && legacyDriver()) { + assertThrows(IllegalArgumentException.class, () -> manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver")); + } else { + String certificateFlag = mode.startsWith("VERIFY_") ? "&verifyServerCertificate=true" : ""; + assertTrue(manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver").endsWith("&sslMode=" + mode + "&requireSSL=true" + certificateFlag)); + } + } + } + + @Test void weakMalformedAndDuplicateModesFailClosed() { + for (String options : new String[]{"&sslMode=DISABLED", "&sslMode=PREFERRED", "&sslMode=", + "&sslMode=unknown", "&ssl%4dode=PREFERRED", "&sslMode=%50REFERRED", + "&sslMode=REQUIRED&sslMode=DISABLED", "&sslMode=VERIFY_CA&sslMode=REQUIRED", + "&sslMode=%GG", "&useSSL=false", "&requireSSL=false", "&useSSL=", + "&requireSSL=unknown", "&require%53SL=false"}) { + ConnectionManager manager = fallback(); + manager.setStr(options); + assertThrows(IllegalArgumentException.class, () -> manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver")); + } + } + + @Test void legacyCertificateVerificationRemainsEnabled() { + ConnectionManager manager = fallback(); + manager.setStr("&verifyServerCertificate=true&customOption=retained"); + assertTrue(manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver").endsWith("&sslMode=VERIFY_CA&requireSSL=true&verifyServerCertificate=true")); + } + + @Test void explicitEncryptionRetainsLegacyCertificateVerification() { + ConnectionManager manager = fallback(); + manager.setStr("&sslMode=REQUIRED&verifyServerCertificate=true"); + assertTrue(manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver").endsWith( + "&sslMode=VERIFY_CA&requireSSL=true&verifyServerCertificate=true")); + } + + @Test void ordinaryMysqlNativeMariaAndSslDisabledRemainUnchanged() { + ConnectionManager manager = fallback(); + assertFalse(manager.buildJdbcUrl("org.mariadb.jdbc.Driver").contains("sslMode=")); + manager.setDbType(DbType.MYSQL); + assertFalse(manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver").contains("sslMode=")); + manager.setDbType(DbType.MARIADB); + manager.setUseSSL(false); + manager.setStr("&sslMode=DISABLED"); + assertTrue(manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver").endsWith("&sslMode=DISABLED")); + } + + @Test void actualConnectorRejectsNonTlsGreetingBeforeSendingCredentials() throws Exception { + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + server.setSoTimeout(5000); + var worker = Executors.newSingleThreadExecutor(); + try { + var peer = worker.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5000); + socket.getOutputStream().write(nonTlsGreeting()); + socket.getOutputStream().flush(); + return socket.getInputStream().read(); + } + }); + ConnectionManager manager = fallback(); + manager.setHost(server.getInetAddress().getHostAddress()); + manager.setPort(Integer.toString(server.getLocalPort())); + manager.setStr("&connectTimeout=2000&socketTimeout=2000"); + Properties properties = new Properties(); + properties.setProperty("user", "test-user"); + properties.setProperty("password", "test-only-not-a-secret"); + SQLException failure = assertThrows(SQLException.class, () -> + new com.mysql.cj.jdbc.Driver().connect(manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver"), properties)); + assertTrue(failure.getMessage().contains("SSL"), failure::getMessage); + assertEquals(-1, peer.get(5, TimeUnit.SECONDS), "No authentication packet may be sent to a non-TLS peer"); + } finally { + worker.shutdownNow(); + } + } + } + + @Test void identityVerificationFailsClosedOnLegacyDriver() { + ConnectionManager manager = fallback(); + manager.setStr("&sslMode=VERIFY_IDENTITY"); + if (legacyDriver()) { + assertThrows(IllegalArgumentException.class, () -> manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver")); + } else { + assertTrue(manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver").contains("sslMode=VERIFY_IDENTITY")); + } + } + + @Test void verifiedFallbackRejectsUntrustedTlsPeerBeforeAuthentication() throws Exception { + var context = HttpTlsIdentity.loadOrCreate(directory.resolve("untrusted-server"), "localhost").serverContext(); + try (ServerSocket server = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + server.setSoTimeout(5000); + var worker = Executors.newSingleThreadExecutor(); + try { + var peer = worker.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5000); + socket.getOutputStream().write(greeting(true)); + socket.getOutputStream().flush(); + byte[] header = socket.getInputStream().readNBytes(4); + assertEquals(4, header.length); + int length = (header[0] & 255) | ((header[1] & 255) << 8) | ((header[2] & 255) << 16); + assertEquals(32, length, "Client must send a bounded SSL request before TLS"); + byte[] request = socket.getInputStream().readNBytes(length); + assertEquals(32, request.length); + assertTrue((request[1] & 8) != 0, "CLIENT_SSL must be set"); + try (SSLSocket tls = (SSLSocket) context.getSocketFactory().createSocket( + socket, "localhost", socket.getPort(), true)) { + tls.setUseClientMode(false); + try { + tls.startHandshake(); + return tls.getInputStream().read(); + } catch (SSLException rejected) { + return -1; + } + } + } + }); + ConnectionManager manager = fallback(); + manager.setHost(server.getInetAddress().getHostAddress()); + manager.setPort(Integer.toString(server.getLocalPort())); + manager.setStr("&sslMode=VERIFY_CA&verifyServerCertificate=false&enabledTLSProtocols=TLSv1.2&connectTimeout=2000&socketTimeout=2000"); + Properties properties = new Properties(); + properties.setProperty("user", "test-user"); + properties.setProperty("password", "test-only-not-a-secret"); + SQLException failure = assertThrows(SQLException.class, () -> + new com.mysql.cj.jdbc.Driver().connect(manager.buildJdbcUrl("com.mysql.cj.jdbc.Driver"), properties)); + assertTrue(certificateFailure(failure), "Expected certificate validation failure, got " + failure); + assertEquals(-1, peer.get(5, TimeUnit.SECONDS), "No authentication packet may reach an untrusted TLS peer"); + } finally { + worker.shutdownNow(); + } + } + } + + private static boolean certificateFailure(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof java.security.cert.CertificateException + || current instanceof java.security.cert.CertPathBuilderException + || current instanceof java.security.cert.CertPathValidatorException) return true; + } + return false; + } + + private static boolean legacyDriver() { + return "8.0.12".equals(com.mysql.cj.jdbc.Driver.class.getPackage().getImplementationVersion()); + } + + private static ConnectionManager fallback() { + ConnectionManager manager = new ConnectionManager("localhost", "3306", "test", "", "votes"); + manager.setDbType(DbType.MARIADB); + manager.setUseSSL(true); + return manager; + } + + // Protocol-10 greeting with protocol-41, secure connection and plugin authentication, + // deliberately omitting CLIENT_SSL. No actual database or external network is needed. + private static byte[] nonTlsGreeting() { return greeting(false); } + + private static byte[] greeting(boolean ssl) { + ByteArrayOutputStream payload = new ByteArrayOutputStream(); + payload.write(10); + payload.writeBytes("8.0.0-test\0".getBytes(StandardCharsets.US_ASCII)); + payload.writeBytes(new byte[]{1, 0, 0, 0}); + payload.writeBytes("abcdefgh".getBytes(StandardCharsets.US_ASCII)); + payload.write(0); + int capabilities = 1 | 512 | 8192 | 32768 | 0x80000 | (ssl ? 2048 : 0); + payload.write(capabilities & 255); + payload.write((capabilities >>> 8) & 255); + payload.write(45); + payload.write(2); + payload.write(0); + payload.write((capabilities >>> 16) & 255); + payload.write((capabilities >>> 24) & 255); + payload.write(21); + payload.writeBytes(new byte[10]); + payload.writeBytes("ijklmnopqrst\0".getBytes(StandardCharsets.US_ASCII)); + payload.writeBytes("mysql_native_password\0".getBytes(StandardCharsets.US_ASCII)); + ByteArrayOutputStream packet = new ByteArrayOutputStream(); + packet.write(payload.size() & 255); + packet.write((payload.size() >>> 8) & 255); + packet.write((payload.size() >>> 16) & 255); + packet.write(0); + packet.writeBytes(payload.toByteArray()); + return packet.toByteArray(); + } +} diff --git a/docs/shared-libraries.md b/docs/shared-libraries.md index a6b4e88..914df51 100644 --- a/docs/shared-libraries.md +++ b/docs/shared-libraries.md @@ -220,3 +220,22 @@ is different: it reads a replacement before publishing it and throws last valid active configuration. The failed reload does not rewrite the file. Callers must abort runtime reconfiguration when reload fails. An intentionally empty, valid YAML document remains accepted. + +### MariaDB JDBC fallback + +When the configured MariaDB driver is absent and the existing MySQL-driver +fallback is enabled, the resolved MySQL Connector/J driver uses a `jdbc:mysql:` +URL. The configured database type, TLS/options, host, and database remain intact. +Native MariaDB and custom MariaDB driver overrides retain the MariaDB scheme; +the explicit fallback opt-out remains supported. + +For MariaDB configured with `UseSSL=true`, Connector/J fallback requires TLS +(`sslMode=REQUIRED` plus `requireSSL=true` for pre-8.0.13 drivers) instead of permitting a plaintext downgrade. +`VERIFY_CA` also enables the legacy certificate-verification property. +`VERIFY_IDENTITY` is accepted only when the actual driver advertises modern +`sslMode` support, and otherwise fails before connecting. Weak or ambiguous +`sslMode` overrides and options disabling `useSSL`/`requireSSL` are rejected. +Legacy certificate verification requests remain verified even with an explicit +`REQUIRED` mode. This requires encryption but does not claim server identity verification +without the appropriate verified mode. Native MariaDB, ordinary MySQL settings, +and explicitly disabled TLS remain unchanged.