From 06407c58cdfeb3a60663fc6736d38530bc401f09 Mon Sep 17 00:00:00 2001 From: iacsha <122627209+iacsha@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:47:06 +0000 Subject: [PATCH 1/2] Scope table-existence checks to the connection catalog (fixes #489) DatabaseUtil.tableExists and JdbcDao.checkAndCreateChannelTables/tableExists called DatabaseMetaData.getTables with a null catalog, so on MySQL a login with access to other databases matched their tables and a second OIE database was never migrated and missing message tables were not recreated. Pass connection.getCatalog() so each engine only sees its own database; null-catalog behaviour is unchanged on databases where getCatalog() returns null. Signed-off-by: iacsha <122627209+iacsha@users.noreply.github.com> --- .../donkey/server/data/jdbc/JdbcDao.java | 10 ++++- .../connect/server/util/DatabaseUtil.java | 8 +++- .../connect/server/util/DatabaseUtilTest.java | 43 +++++++++++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) create mode 100644 server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java diff --git a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java index 1ff1034560..73bed766b6 100644 --- a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java +++ b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java @@ -1924,7 +1924,10 @@ public void checkAndCreateChannelTables() { try { DatabaseMetaData dbMetaData = connection.getMetaData(); - try (ResultSet rs = dbMetaData.getTables(null, null, "%", null)) { + // Scope to the connection's own catalog so tables in other databases visible to the + // same login are not mistaken for this database's tables. See + // OpenIntegrationEngine/engine#489. + try (ResultSet rs = dbMetaData.getTables(connection.getCatalog(), null, "%", null)) { while (rs.next()) { String tableName = rs.getString("TABLE_NAME").toLowerCase(); channelTablesMap.remove(tableName); @@ -2238,10 +2241,13 @@ public boolean initTableStructure() { private boolean tableExists(String tableName) { try { DatabaseMetaData metaData = connection.getMetaData(); + // Scope to the connection's own catalog so tables in other databases visible to the + // same login are not matched. See OpenIntegrationEngine/engine#489. + String catalog = connection.getCatalog(); // Databases disagree on identifier case folding, so try both spellings for (String name : new String[] { tableName, tableName.toUpperCase() }) { - try (ResultSet resultSet = metaData.getTables(null, null, name, null)) { + try (ResultSet resultSet = metaData.getTables(catalog, null, name, null)) { if (resultSet.next()) { return true; } diff --git a/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java b/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java index b4f815d9e3..ffe9abc7c8 100644 --- a/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java +++ b/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java @@ -211,14 +211,18 @@ public static boolean tableExists(Connection connection, String tableName) { try { DatabaseMetaData metaData = connection.getMetaData(); - resultSet = metaData.getTables(null, null, tableName.toUpperCase(), new String[] { + // Scope the lookup to the connection's own catalog so a login with access to other + // databases on the same server does not match their tables. See + // OpenIntegrationEngine/engine#489. + String catalog = connection.getCatalog(); + resultSet = metaData.getTables(catalog, null, tableName.toUpperCase(), new String[] { "TABLE" }); if (resultSet.next()) { return true; } - resultSet = metaData.getTables(null, null, tableName.toLowerCase(), new String[] { + resultSet = metaData.getTables(catalog, null, tableName.toLowerCase(), new String[] { "TABLE" }); return resultSet.next(); } catch (SQLException e) { diff --git a/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java b/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java new file mode 100644 index 0000000000..91886eecf1 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Open Integration Engine + +package com.mirth.connect.server.util; + +import static org.junit.Assert.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; + +import org.junit.Test; + +public class DatabaseUtilTest { + + /** + * tableExists must scope its metadata lookup to the connection's own catalog. Passing a null + * catalog lets MySQL Connector/J match tables in any database the login can see, so a second + * OIE database on a shared login is never migrated. See OpenIntegrationEngine/engine#489. + */ + @Test + public void tableExists_scopesLookupToConnectionCatalog() throws Exception { + Connection connection = mock(Connection.class); + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + ResultSet resultSet = mock(ResultSet.class); + + when(connection.getMetaData()).thenReturn(metaData); + when(connection.getCatalog()).thenReturn("oie_b"); + when(metaData.getTables(any(), any(), any(), any())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(false); + + assertFalse(DatabaseUtil.tableExists(connection, "CONFIGURATION")); + + verify(metaData).getTables(eq("oie_b"), isNull(), eq("CONFIGURATION"), any()); + verify(metaData).getTables(eq("oie_b"), isNull(), eq("configuration"), any()); + } +} From 07a31269b0702680bb2798206e7fa1e961a5499e Mon Sep 17 00:00:00 2001 From: iacsha <122627209+iacsha@users.noreply.github.com> Date: Fri, 6 Nov 2026 19:52:54 +0000 Subject: [PATCH 2/2] Scope table lookups to the catalog only on MySQL (review feedback) Move the catalog scoping behind a JdbcDao flag that MysqlDaoFactory enables, and limit DatabaseUtil.tableExists scoping to MySQL/MariaDB, so other databases keep the existing null-catalog lookup. Signed-off-by: iacsha <122627209+iacsha@users.noreply.github.com> --- .../donkey/server/data/jdbc/JdbcDao.java | 24 ++++++++---- .../server/data/jdbc/MysqlDaoFactory.java | 1 + .../donkey/server/data/jdbc/JdbcDaoTest.java | 37 ++++++++++++++++++ .../connect/server/util/DatabaseUtil.java | 11 ++++-- .../connect/server/util/DatabaseUtilTest.java | 38 ++++++++++++++----- 5 files changed, 92 insertions(+), 19 deletions(-) diff --git a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java index 73bed766b6..b69e387174 100644 --- a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java +++ b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDao.java @@ -83,6 +83,7 @@ public class JdbcDao implements DonkeyDao { private String statsServerId; private boolean transactionAlteredChannels = false; private char quoteChar = '"'; + private boolean scopeTablesToCatalog = false; private Logger logger = LogManager.getLogger(this.getClass()); protected JdbcDao(Donkey donkey, Connection connection, QuerySource querySource, SerializerProvider serializerProvider, boolean encryptMessageContent, boolean encryptAttachments, boolean encryptCustomMetaData, boolean decryptData, StatisticsUpdater statisticsUpdater, Statistics currentStats, Statistics totalStats, String statsServerId) { @@ -130,6 +131,20 @@ public void setQuoteChar(char quoteChar) { this.quoteChar = quoteChar; } + /** + * Limits table lookups to the connection's own catalog. MySQL Connector/J treats a null catalog + * as every database the login can see, so without this a second database on a shared login + * matches the first one's tables. Off by default; enabled by {@link MysqlDaoFactory}. See + * OpenIntegrationEngine/engine#489. + */ + public void setScopeTablesToCatalog(boolean scopeTablesToCatalog) { + this.scopeTablesToCatalog = scopeTablesToCatalog; + } + + private String getTableCatalog() throws SQLException { + return scopeTablesToCatalog ? connection.getCatalog() : null; + } + @Override public void insertMessage(Message message) { logger.debug(message.getChannelId() + "/" + message.getMessageId() + ": inserting message"); @@ -1924,10 +1939,7 @@ public void checkAndCreateChannelTables() { try { DatabaseMetaData dbMetaData = connection.getMetaData(); - // Scope to the connection's own catalog so tables in other databases visible to the - // same login are not mistaken for this database's tables. See - // OpenIntegrationEngine/engine#489. - try (ResultSet rs = dbMetaData.getTables(connection.getCatalog(), null, "%", null)) { + try (ResultSet rs = dbMetaData.getTables(getTableCatalog(), null, "%", null)) { while (rs.next()) { String tableName = rs.getString("TABLE_NAME").toLowerCase(); channelTablesMap.remove(tableName); @@ -2241,9 +2253,7 @@ public boolean initTableStructure() { private boolean tableExists(String tableName) { try { DatabaseMetaData metaData = connection.getMetaData(); - // Scope to the connection's own catalog so tables in other databases visible to the - // same login are not matched. See OpenIntegrationEngine/engine#489. - String catalog = connection.getCatalog(); + String catalog = getTableCatalog(); // Databases disagree on identifier case folding, so try both spellings for (String name : new String[] { tableName, tableName.toUpperCase() }) { diff --git a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/MysqlDaoFactory.java b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/MysqlDaoFactory.java index ab08ebf458..7a888c8320 100644 --- a/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/MysqlDaoFactory.java +++ b/donkey/src/main/java/com/mirth/connect/donkey/server/data/jdbc/MysqlDaoFactory.java @@ -16,6 +16,7 @@ public class MysqlDaoFactory extends JdbcDaoFactory { public JdbcDao getDao(SerializerProvider serializerProvider) { JdbcDao dao = super.getDao(serializerProvider); dao.setQuoteChar('`'); + dao.setScopeTablesToCatalog(true); return dao; } } diff --git a/donkey/src/test/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDaoTest.java b/donkey/src/test/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDaoTest.java index 4cf983e55f..5ac513d43a 100644 --- a/donkey/src/test/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDaoTest.java +++ b/donkey/src/test/java/com/mirth/connect/donkey/server/data/jdbc/JdbcDaoTest.java @@ -10,10 +10,12 @@ package com.mirth.connect.donkey.server.data.jdbc; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.fail; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; @@ -476,4 +478,39 @@ private void setLocalChannelId(String channelId, long localChannelId) { channels.put(channelId, channel); when(donkey.getDeployedChannels()).thenReturn(channels); } + + /** + * Table lookups stay unscoped (null catalog) unless the factory enables catalog scoping, so + * only MySQL changes behaviour. See OpenIntegrationEngine/engine#489. + */ + @Test + public void testTableLookupUnscopedByDefault() throws SQLException { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + ResultSet resultSet = mock(ResultSet.class); + when(connection.getMetaData()).thenReturn(metaData); + when(connection.getCatalog()).thenReturn("oie_b"); + when(metaData.getTables(any(), any(), any(), any())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); + + assertFalse(dao.initTableStructure()); + + verify(metaData).getTables(isNull(), isNull(), eq("d_channels"), isNull()); + verify(connection, never()).getCatalog(); + } + + /** With catalog scoping enabled (MySQL), lookups are limited to the connection's database. */ + @Test + public void testTableLookupScopedToCatalogWhenEnabled() throws SQLException { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + ResultSet resultSet = mock(ResultSet.class); + when(connection.getMetaData()).thenReturn(metaData); + when(connection.getCatalog()).thenReturn("oie_b"); + when(metaData.getTables(any(), any(), any(), any())).thenReturn(resultSet); + when(resultSet.next()).thenReturn(true); + + dao.setScopeTablesToCatalog(true); + assertFalse(dao.initTableStructure()); + + verify(metaData).getTables(eq("oie_b"), isNull(), eq("d_channels"), isNull()); + } } diff --git a/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java b/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java index ffe9abc7c8..b0b5855f10 100644 --- a/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java +++ b/server/src/main/java/com/mirth/connect/server/util/DatabaseUtil.java @@ -211,10 +211,10 @@ public static boolean tableExists(Connection connection, String tableName) { try { DatabaseMetaData metaData = connection.getMetaData(); - // Scope the lookup to the connection's own catalog so a login with access to other - // databases on the same server does not match their tables. See + // MySQL treats a null catalog as every database the login can see, so scope the lookup + // to this connection's database there. Other databases keep the unscoped lookup. See // OpenIntegrationEngine/engine#489. - String catalog = connection.getCatalog(); + String catalog = isMySql(metaData) ? connection.getCatalog() : null; resultSet = metaData.getTables(catalog, null, tableName.toUpperCase(), new String[] { "TABLE" }); @@ -232,6 +232,11 @@ public static boolean tableExists(Connection connection, String tableName) { } } + private static boolean isMySql(DatabaseMetaData metaData) throws SQLException { + String productName = metaData.getDatabaseProductName(); + return "MySQL".equalsIgnoreCase(productName) || "MariaDB".equalsIgnoreCase(productName); + } + /** * Tell whether or not the given index exists in the database */ diff --git a/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java b/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java index 91886eecf1..3029cdd271 100644 --- a/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java +++ b/server/src/test/java/com/mirth/connect/server/util/DatabaseUtilTest.java @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: Open Integration Engine +// SPDX-FileCopyrightText: Open Integration Engine Contributors package com.mirth.connect.server.util; @@ -8,6 +8,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -19,25 +20,44 @@ public class DatabaseUtilTest { - /** - * tableExists must scope its metadata lookup to the connection's own catalog. Passing a null - * catalog lets MySQL Connector/J match tables in any database the login can see, so a second - * OIE database on a shared login is never migrated. See OpenIntegrationEngine/engine#489. - */ - @Test - public void tableExists_scopesLookupToConnectionCatalog() throws Exception { + private static Connection connectionFor(String productName, DatabaseMetaData metaData) throws Exception { Connection connection = mock(Connection.class); - DatabaseMetaData metaData = mock(DatabaseMetaData.class); ResultSet resultSet = mock(ResultSet.class); when(connection.getMetaData()).thenReturn(metaData); when(connection.getCatalog()).thenReturn("oie_b"); + when(metaData.getDatabaseProductName()).thenReturn(productName); when(metaData.getTables(any(), any(), any(), any())).thenReturn(resultSet); when(resultSet.next()).thenReturn(false); + return connection; + } + + /** + * On MySQL, tableExists must scope its metadata lookup to the connection's own catalog. A null + * catalog lets Connector/J match tables in any database the login can see, so a second OIE + * database on a shared login is never migrated. See OpenIntegrationEngine/engine#489. + */ + @Test + public void tableExists_onMySql_scopesLookupToConnectionCatalog() throws Exception { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + Connection connection = connectionFor("MySQL", metaData); assertFalse(DatabaseUtil.tableExists(connection, "CONFIGURATION")); verify(metaData).getTables(eq("oie_b"), isNull(), eq("CONFIGURATION"), any()); verify(metaData).getTables(eq("oie_b"), isNull(), eq("configuration"), any()); } + + /** Other databases keep the existing unscoped (null catalog) lookup. */ + @Test + public void tableExists_onOtherDatabases_keepsNullCatalog() throws Exception { + DatabaseMetaData metaData = mock(DatabaseMetaData.class); + Connection connection = connectionFor("PostgreSQL", metaData); + + assertFalse(DatabaseUtil.tableExists(connection, "CONFIGURATION")); + + verify(metaData).getTables(isNull(), isNull(), eq("CONFIGURATION"), any()); + verify(metaData).getTables(isNull(), isNull(), eq("configuration"), any()); + verify(connection, never()).getCatalog(); + } }