From 5b8b1bd5953dd7dc5321ca49dd205e191e4924c6 Mon Sep 17 00:00:00 2001 From: Jon Bartels Date: Mon, 14 Sep 2026 11:25:48 -0400 Subject: [PATCH 1/2] Fix SQLi + XXE CVEs (CVE-2026-82583, -78224, -82578) with regression tests Three vulnerabilities reported against NextGen/Mirth Connect are present in this 4.6.0 tree (below every upstream fix version). Each is fixed using the project's own established idioms, and each is covered by an integration test in the ci/smoketest harness that is RED on the vulnerable code and GREEN once the fix is applied. CVE-2026-82583 (SQL injection): DatabaseConnectorServlet.getTables executed the caller-supplied selectLimit query parameter verbatim via Statement.executeQuery. The Database connector metadata dialog only ever sends a selectLimit taken from the configured driver list, so selectLimit is now validated against that list (getDatabaseDrivers plus the built-in DriverInfo defaults, which are always included so a cleared list cannot disable the check) before any SQL runs. A non-allowlisted value is rejected with a generic exception that does not reflect the input. A blank value still routes to the safe DatabaseMetaData.getColumns path. Not fixed here (noted as follow-ups): the endpoint's missing @MirthOperation permission and unconstrained driver/url, a gap shared by every connector test servlet. CVE-2026-78224 (XSLT step XXE): XsltStep builds a TransformerFactory inside generated JavaScript, so it was never reached by the Java-side XML hardening. The generated script now enables FEATURE_SECURE_PROCESSING and sets ACCESS_EXTERNAL_DTD/ACCESS_EXTERNAL_STYLESHEET to "" (setAttribute guarded for implementations that reject it), on both the normal and iterator paths, blocking external entity resolution in the stylesheet and the source XML. CVE-2026-82578 (XML batch XXE): XMLBatchAdaptor evaluated XPath directly over an InputSource, letting XPath build its own DOCTYPE-resolving parser. It now parses with DocumentSerializer.getSecureDocumentBuilderFactory() (disallow-doctype-decl, external entities/DTD off, no entity expansion) and evaluates against the parsed Document. The output path in this file was already hardened; only the reader side was missed. Tests: - XsltStepSecurityTest (unit): asserts the hardening is emitted into the script. - DatabaseConnectorSqlInjectionTest, XsltStepXxeTest, XmlBatchXxeTest (smoketest): drive the live server; SQLi runs on DB-backed configurations (DB coordinates passed as oie.db.* via run-configuration.sh + harness.compose.yml) and skips embedded-Derby, the XXE tests build channels from a base fixture via new OieServer/SecurityChannels helpers. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Jon Bartels --- ci/harness.compose.yml | 4 + ci/run-configuration.sh | 27 ++- .../jdbc/DatabaseConnectorServlet.java | 50 +++++ .../datatypes/xml/XMLBatchAdaptor.java | 17 +- .../connect/plugins/xsltstep/XsltStep.java | 8 + .../xsltstep/XsltStepSecurityTest.java | 49 +++++ smoketest/build.gradle | 5 + .../DatabaseConnectorSqlInjectionTest.java | 130 +++++++++++++ .../smoketest/OieServer.java | 29 +++ .../smoketest/SecurityChannels.java | 86 ++++++++ .../smoketest/XmlBatchXxeTest.java | 88 +++++++++ .../smoketest/XsltStepXxeTest.java | 72 +++++++ .../fixtures/security/base-vm-noop.xml | 184 ++++++++++++++++++ 13 files changed, 745 insertions(+), 4 deletions(-) create mode 100644 server/src/test/java/com/mirth/connect/plugins/xsltstep/XsltStepSecurityTest.java create mode 100644 smoketest/src/test/java/org/openintegrationengine/smoketest/DatabaseConnectorSqlInjectionTest.java create mode 100644 smoketest/src/test/java/org/openintegrationengine/smoketest/SecurityChannels.java create mode 100644 smoketest/src/test/java/org/openintegrationengine/smoketest/XmlBatchXxeTest.java create mode 100644 smoketest/src/test/java/org/openintegrationengine/smoketest/XsltStepXxeTest.java create mode 100644 smoketest/src/test/resources/fixtures/security/base-vm-noop.xml diff --git a/ci/harness.compose.yml b/ci/harness.compose.yml index dc93d4cec6..3cae62d64a 100644 --- a/ci/harness.compose.yml +++ b/ci/harness.compose.yml @@ -19,5 +19,9 @@ services: OIE_BASE_URL: https://oie:8443 OIE_CONFIGURATION: ${OIE_CONFIGURATION} OIE_PASSWORD: ${OIE_ADMIN_PASSWORD} + # Extra -D flags forwarded to the JUnit JVM. run-configuration.sh sets these to the DB + # coordinates for DB-backed configurations so the SQL-injection test can reach the database; + # empty for embedded-Derby configurations, where that test skips. + OIE_HARNESS_OPTS: ${OIE_HARNESS_OPTS:-} volumes: - ${WORKSPACE}/ci/test-results:/results diff --git a/ci/run-configuration.sh b/ci/run-configuration.sh index 7881f57bbd..e62c6f795e 100755 --- a/ci/run-configuration.sh +++ b/ci/run-configuration.sh @@ -14,15 +14,36 @@ if [[ ! -f "$1" ]]; then exit 2 fi export OIE_IMAGE="$2" -export OIE_CONFIGURATION="$(basename "$1" .compose.yml)" +OIE_CONFIGURATION="$(basename "$1" .compose.yml)" +export OIE_CONFIGURATION # The engine generates a random admin password on first boot, so the stack and the # harness have to agree on one up front. See ci/harness.compose.yml. export OIE_ADMIN_PASSWORD="${OIE_ADMIN_PASSWORD:-ci-smoke-admin}" export WORKSPACE="$PWD" -export HOST_UID="$(id -u)" -export HOST_GID="$(id -g)" +HOST_UID="$(id -u)" +export HOST_UID +HOST_GID="$(id -g)" +export HOST_GID mkdir -p "$WORKSPACE/ci/test-results" +# Database coordinates for the DB-backed configurations, forwarded to the harness (see +# ci/harness.compose.yml) so the SQL-injection smoke test can drive the JDBC metadata endpoint +# against the same database the stack uses. The endpoint runs inside the oie container, so the URL +# host is the compose "db" service. Embedded-Derby configurations expose no separate database +# service and are left unset, so that test skips. Values are space-separated -D flags and must not +# themselves contain spaces (run-harness.sh word-splits them onto the java command line). +case "$OIE_CONFIGURATION" in + *-postgres) + export OIE_HARNESS_OPTS="-Doie.db.driver=org.postgresql.Driver -Doie.db.url=jdbc:postgresql://db:5432/mirthdb -Doie.db.user=mirthdb -Doie.db.password=mirthdb" + ;; + *-mysql) + export OIE_HARNESS_OPTS="-Doie.db.driver=com.mysql.cj.jdbc.Driver -Doie.db.url=jdbc:mysql://db:3306/mirthdb -Doie.db.user=mirthdb -Doie.db.password=mirthdb" + ;; + *) + export OIE_HARNESS_OPTS="" + ;; +esac + compose=(docker compose -f "$1" -f ci/harness.compose.yml -p "oie-ci-${OIE_CONFIGURATION//[^a-z0-9-]/-}-$$") cleanup() { diff --git a/server/src/main/java/com/mirth/connect/connectors/jdbc/DatabaseConnectorServlet.java b/server/src/main/java/com/mirth/connect/connectors/jdbc/DatabaseConnectorServlet.java index 2493be8d7b..b061540fae 100644 --- a/server/src/main/java/com/mirth/connect/connectors/jdbc/DatabaseConnectorServlet.java +++ b/server/src/main/java/com/mirth/connect/connectors/jdbc/DatabaseConnectorServlet.java @@ -33,7 +33,9 @@ import org.apache.logging.log4j.Logger; import com.mirth.connect.client.core.api.MirthApiException; +import com.mirth.connect.model.DriverInfo; import com.mirth.connect.server.api.MirthServlet; +import com.mirth.connect.server.controllers.ConfigurationController; import com.mirth.connect.server.controllers.ContextFactoryController; import com.mirth.connect.server.controllers.ControllerFactory; import com.mirth.connect.server.util.TemplateValueReplacer; @@ -45,6 +47,7 @@ public class DatabaseConnectorServlet extends MirthServlet implements DatabaseCo private static final Logger logger = LogManager.getLogger(DatabaseConnectorServlet.class); private static final TemplateValueReplacer replacer = new TemplateValueReplacer(); private static final ContextFactoryController contextFactoryController = ControllerFactory.getFactory().createContextFactoryController(); + private static final ConfigurationController configurationController = ControllerFactory.getFactory().createConfigurationController(); public DatabaseConnectorServlet(@Context HttpServletRequest request, @Context SecurityContext sc) { super(request, sc, PLUGIN_POINT); @@ -52,6 +55,10 @@ public DatabaseConnectorServlet(@Context HttpServletRequest request, @Context Se @Override public SortedSet getTables(String channelId, String channelName, String driver, String url, String username, String password, Set tableNamePatterns, String selectLimit, Set resourceIds) { + // Reject any selectLimit that is not one the server itself configured, before it can be + // executed as SQL (CVE-2026-82583). Done outside the try below so it is not re-wrapped. + validateSelectLimit(selectLimit); + CustomDriver customDriver = null; Connection connection = null; try { @@ -229,6 +236,49 @@ public SortedSet
getTables(String channelId, String channelName, String d } } + /** + * Validates the caller-supplied {@code selectLimit} against the server's configured driver list + * before it is ever executed as SQL. The Database connector metadata dialog only ever sends a + * {@code selectLimit} taken from the configured drivers (dbdrivers.xml / {@link DriverInfo}), so + * any other value is a SQL-injection attempt (CVE-2026-82583) and is rejected. A blank value is + * allowed: it routes to the safe {@link DatabaseMetaData#getColumns} path. The exception message + * is deliberately generic so the rejected value is not reflected back to the caller, and the + * built-in default drivers are always included so an empty/cleared configured list cannot + * disable the check (fail closed). + */ + private void validateSelectLimit(String selectLimit) { + if (StringUtils.isBlank(selectLimit)) { + return; + } + + Set allowedSelectLimits = new HashSet(); + addSelectLimits(allowedSelectLimits, DriverInfo.getDefaultDrivers()); + + try { + addSelectLimits(allowedSelectLimits, configurationController.getDatabaseDrivers()); + } catch (Exception e) { + // Fall back to the built-in driver list rather than failing open if the configured + // list cannot be read. + logger.warn("Could not load configured database drivers for selectLimit validation; using built-in defaults.", e); + } + + if (!allowedSelectLimits.contains(selectLimit.trim())) { + logger.warn("Rejected database metadata request with a selectLimit that is not in the configured driver list."); + throw new MirthApiException("The provided selectLimit is not permitted."); + } + } + + private void addSelectLimits(Set allowedSelectLimits, List drivers) { + if (drivers == null) { + return; + } + for (DriverInfo driver : drivers) { + if (driver != null && driver.getSelectLimit() != null) { + allowedSelectLimits.add(driver.getSelectLimit().trim()); + } + } + } + /** * Translate the given pattern expression so that it can be used properly for searching tables * in the database. Multiple table name patterns are delimited by comma (,) diff --git a/server/src/main/java/com/mirth/connect/plugins/datatypes/xml/XMLBatchAdaptor.java b/server/src/main/java/com/mirth/connect/plugins/datatypes/xml/XMLBatchAdaptor.java index bb6994e339..5eb013efa3 100644 --- a/server/src/main/java/com/mirth/connect/plugins/datatypes/xml/XMLBatchAdaptor.java +++ b/server/src/main/java/com/mirth/connect/plugins/datatypes/xml/XMLBatchAdaptor.java @@ -18,6 +18,7 @@ import java.util.Map; import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; import javax.xml.transform.OutputKeys; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerFactory; @@ -33,6 +34,7 @@ import org.mozilla.javascript.Context; import org.mozilla.javascript.Script; import org.mozilla.javascript.Scriptable; +import org.w3c.dom.Document; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import org.xml.sax.InputSource; @@ -42,6 +44,7 @@ import com.mirth.connect.donkey.server.message.batch.BatchMessageException; import com.mirth.connect.donkey.server.message.batch.BatchMessageReader; import com.mirth.connect.donkey.server.message.batch.BatchMessageReceiver; +import com.mirth.connect.model.converters.DocumentSerializer; import com.mirth.connect.plugins.datatypes.xml.XMLBatchProperties.SplitType; import com.mirth.connect.server.controllers.ContextFactoryController; import com.mirth.connect.server.controllers.ControllerFactory; @@ -127,7 +130,19 @@ private String getMessageFromReader() throws Exception { XPath xpath = xPathFactory.newXPath(); - nodeList = (NodeList) xpath.evaluate(query.toString(), new InputSource(bufferedReader), XPathConstants.NODESET); + // Parse the untrusted batch with a hardened parser before evaluating XPath, rather than + // letting XPath.evaluate(InputSource) build its own DOCTYPE-resolving parser (XXE, + // CVE-2026-82578). getSecureDocumentBuilderFactory() already sets disallow-doctype-decl; + // the extra features below block external entities/DTDs and entity expansion outright. + DocumentBuilderFactory dbf = DocumentSerializer.getSecureDocumentBuilderFactory(); + dbf.setFeature("http://xml.org/sax/features/external-general-entities", false); + dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + dbf.setXIncludeAware(false); + dbf.setExpandEntityReferences(false); + Document document = dbf.newDocumentBuilder().parse(new InputSource(bufferedReader)); + + nodeList = (NodeList) xpath.evaluate(query.toString(), document, XPathConstants.NODESET); } if (currentNode < nodeList.getLength()) { diff --git a/server/src/main/java/com/mirth/connect/plugins/xsltstep/XsltStep.java b/server/src/main/java/com/mirth/connect/plugins/xsltstep/XsltStep.java index 1facca8b46..e0436a6edc 100644 --- a/server/src/main/java/com/mirth/connect/plugins/xsltstep/XsltStep.java +++ b/server/src/main/java/com/mirth/connect/plugins/xsltstep/XsltStep.java @@ -66,6 +66,14 @@ private String getTransformationScript() { script.append("tFactory = Packages.javax.xml.transform.TransformerFactory.newInstance();\n"); } + // Harden the factory against XXE (CVE-2026-78224): enable secure processing and forbid + // external DTD/stylesheet access so external entities in the stylesheet or the source XML + // are not resolved. setAttribute is guarded because some implementations (e.g. Saxon) reject + // these attributes; secure processing alone still applies. Mirrors XmlProcessor.configureSecureTF. + script.append("tFactory.setFeature(Packages.javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true);\n"); + script.append("try { tFactory.setAttribute(Packages.javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, ''); } catch (e) {}\n"); + script.append("try { tFactory.setAttribute(Packages.javax.xml.XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ''); } catch (e) {}\n"); + script.append("xsltTemplate = new Packages.java.io.StringReader(" + template + ");\n"); script.append("transformer = tFactory.newTransformer(new Packages.javax.xml.transform.stream.StreamSource(xsltTemplate));\n"); script.append("sourceVar = new Packages.java.io.StringReader(" + sourceXml + ");\n"); diff --git a/server/src/test/java/com/mirth/connect/plugins/xsltstep/XsltStepSecurityTest.java b/server/src/test/java/com/mirth/connect/plugins/xsltstep/XsltStepSecurityTest.java new file mode 100644 index 0000000000..98370b59b2 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/plugins/xsltstep/XsltStepSecurityTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) Open Integration Engine. All rights reserved. + * + * The software in this package is published under the terms of the MPL license a copy of which has + * been included with this distribution in the LICENSE.txt file. + */ + +package com.mirth.connect.plugins.xsltstep; + +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** + * Guards the XXE hardening of the XSLT transformer step (CVE-2026-78224). The step emits JavaScript + * that builds a TransformerFactory at runtime, so the fix lives in the generated script text. + */ +public class XsltStepSecurityTest { + + @Test + public void generatedScriptHardensTransformerFactoryAgainstXxe() { + XsltStep step = new XsltStep(); + step.setSourceXml("connectorMessage.getRawData()"); + step.setResultVariable("xsltResult"); + step.setTemplate("''"); + + String script = step.getScript(false); + + assertTrue("secure processing should be enabled on the transformer factory", + script.contains("FEATURE_SECURE_PROCESSING")); + assertTrue("external DTD access should be disabled", script.contains("ACCESS_EXTERNAL_DTD")); + assertTrue("external stylesheet access should be disabled", script.contains("ACCESS_EXTERNAL_STYLESHEET")); + } + + @Test + public void hardeningAlsoAppliedOnTheIteratorPath() throws Exception { + XsltStep step = new XsltStep(); + step.setSourceXml("connectorMessage.getRawData()"); + step.setResultVariable("xsltResult"); + step.setTemplate("''"); + + String script = step.getIterationScript(false, new java.util.LinkedList<>()); + + assertTrue("secure processing should be enabled on the iterator path", + script.contains("FEATURE_SECURE_PROCESSING")); + assertTrue("external DTD access should be disabled on the iterator path", + script.contains("ACCESS_EXTERNAL_DTD")); + } +} diff --git a/smoketest/build.gradle b/smoketest/build.gradle index 4d121012c8..b6a2809c75 100644 --- a/smoketest/build.gradle +++ b/smoketest/build.gradle @@ -15,6 +15,11 @@ dependencies { testCompileOnly files(clientCoreJar) // RawMessage, Message, ConnectorMessage, MessageContent, Status, DeployedState testCompileOnly files(donkeyModelJar) + // Connector/plugin classes the security tests build channels with and call directly + // (DatabaseConnectorServletInterface, Table, XsltStep, XMLDataTypeProperties, + // VmReceiverProperties, ...). These live in the server module's main output and are present at + // runtime via /opt/engine/extensions; needed only to compile the tests, hence compileOnly. + testCompileOnly project(':server').sourceSets.main.output // Provided at runtime by /opt/engine/server-lib (server-main uses it too). testCompileOnly libs.snakeyaml diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/DatabaseConnectorSqlInjectionTest.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/DatabaseConnectorSqlInjectionTest.java new file mode 100644 index 0000000000..81ded046de --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/DatabaseConnectorSqlInjectionTest.java @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Open Integration Engine + +package org.openintegrationengine.smoketest; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.util.Set; +import java.util.SortedSet; + +import org.junit.jupiter.api.Test; + +import com.mirth.connect.connectors.jdbc.Column; +import com.mirth.connect.connectors.jdbc.Table; + +/** + * Validates CVE-2026-82583: the Database connector {@code _getTables} endpoint executed the + * caller-supplied {@code selectLimit} as raw SQL. + * + *

+ * The test drives the same {@code getTables} API the Database connector metadata dialog uses, + * against the database the CI stack already runs (coordinates passed as {@code oie.db.*} system + * properties). It skips on embedded-Derby configurations that expose no separate database service. + * + *

+ * Pre-fix this test is RED (the injected {@code SELECT 1 AS OIE_SQLI_MARKER} runs and its alias + * surfaces as a column); post-fix it is GREEN (the non-allowlisted {@code selectLimit} is rejected + * before any SQL executes). + */ +class DatabaseConnectorSqlInjectionTest { + + private static final String MARKER = "OIE_SQLI_MARKER"; + + @Test + void selectLimitDoesNotExecuteArbitrarySql() throws Exception { + Db db = Db.fromSystemProperties(); + assumeTrue(db != null, "oie.db.* coordinates not provided; skipping (embedded-database configuration)"); + + OieServer server = SharedServer.get(); + + // Discover a real table via the safe metadata path (empty selectLimit is always allowed). + Table target = firstTableWithColumns(server.getConnectorTables(db.driver, db.url, db.user, db.password, + Set.of("%"), "")); + assumeTrue(target != null, "No table with columns found in the target database"); + + // If executed, this injected SELECT's alias becomes the sole returned column name. + String maliciousSelectLimit = "SELECT 1 AS " + MARKER + " FROM ?"; + SortedSet

result = null; + try { + result = server.getConnectorTables(db.driver, db.url, db.user, db.password, Set.of(target.getName()), + maliciousSelectLimit); + } catch (Exception rejectedByAllowlist) { + // Post-fix: the non-allowlisted selectLimit is rejected before any SQL runs -> blocked. + return; + } + + assertFalse(hasColumnNamed(result, MARKER), "selectLimit executed arbitrary SQL (returned an injected '" + + MARKER + "' column) -- CVE-2026-82583 is present"); + } + + @Test + void allowlistedSelectLimitStillReturnsColumns() throws Exception { + Db db = Db.fromSystemProperties(); + assumeTrue(db != null, "oie.db.* coordinates not provided; skipping (embedded-database configuration)"); + String allowlisted = System.getProperty("oie.db.selectLimit", "SELECT * FROM ? LIMIT 1"); + + OieServer server = SharedServer.get(); + Table target = firstTableWithColumns(server.getConnectorTables(db.driver, db.url, db.user, db.password, + Set.of("%"), "")); + assumeTrue(target != null, "No table with columns found in the target database"); + + // The legitimate flow (a selectLimit taken from the configured driver list) must keep working. + SortedSet
result = server.getConnectorTables(db.driver, db.url, db.user, db.password, + Set.of(target.getName()), allowlisted); + assertTrue(result.stream().anyMatch(t -> !t.getColumns().isEmpty()), + "An allowlisted selectLimit should still return table columns"); + assertFalse(hasColumnNamed(result, MARKER), "Unexpected injected column from an allowlisted selectLimit"); + } + + private static Table firstTableWithColumns(SortedSet
tables) { + for (Table table : tables) { + if (table.getColumns() != null && !table.getColumns().isEmpty()) { + return table; + } + } + return null; + } + + private static boolean hasColumnNamed(SortedSet
tables, String columnName) { + for (Table table : tables) { + if (table.getColumns() == null) { + continue; + } + for (Column column : table.getColumns()) { + if (columnName.equalsIgnoreCase(column.getName())) { + return true; + } + } + } + return false; + } + + /** DB coordinates supplied to the harness for the configuration under test. */ + private static final class Db { + final String driver; + final String url; + final String user; + final String password; + + private Db(String driver, String url, String user, String password) { + this.driver = driver; + this.url = url; + this.user = user; + this.password = password; + } + + static Db fromSystemProperties() { + String driver = System.getProperty("oie.db.driver"); + String url = System.getProperty("oie.db.url"); + String user = System.getProperty("oie.db.user"); + String password = System.getProperty("oie.db.password"); + if (driver == null || url == null || user == null || password == null) { + return null; + } + return new Db(driver, url, user, password); + } + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/OieServer.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/OieServer.java index 51ded95d85..9e9de15627 100644 --- a/smoketest/src/test/java/org/openintegrationengine/smoketest/OieServer.java +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/OieServer.java @@ -5,12 +5,17 @@ import java.io.IOException; import java.util.ArrayDeque; +import java.util.Collections; import java.util.Deque; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.SortedSet; import com.mirth.connect.client.core.Client; import com.mirth.connect.client.core.ClientException; +import com.mirth.connect.connectors.jdbc.DatabaseConnectorServletInterface; +import com.mirth.connect.connectors.jdbc.Table; import com.mirth.connect.donkey.model.channel.DeployedState; import com.mirth.connect.donkey.model.message.Message; import com.mirth.connect.donkey.model.message.RawMessage; @@ -89,6 +94,15 @@ private static synchronized void initSerializer(String serverVersion) throws Exc */ String deployChannel(String xml, String label) throws Exception { Channel channel = ObjectXMLSerializer.getInstance().deserialize(xml, Channel.class); + return deployChannel(channel, label); + } + + /** + * Deploys a {@link Channel} built in code (used by the security tests that construct XSLT-step + * and XML-batch channels from a base fixture) and waits for it to reach + * {@link DeployedState#STARTED}. + */ + String deployChannel(Channel channel, String label) throws Exception { String channelId = channel.getId(); if (channelId == null || channelId.isBlank()) { throw new IllegalArgumentException("Channel fixture has no id: " + label); @@ -132,6 +146,21 @@ long submitMessage(String channelId, String rawData, Map sourceM return messageId; } + /** + * Calls the JDBC connector's {@code _getTables} metadata endpoint the way the Database connector + * UI does. Used by the SQL-injection test to exercise the {@code selectLimit} parameter directly. + */ + SortedSet
getConnectorTables(String driver, String url, String username, String password, + Set tableNamePatterns, String selectLimit) throws Exception { + return client.getServlet(DatabaseConnectorServletInterface.class).getTables("", "", driver, url, username, + password, tableNamePatterns, selectLimit, Collections. emptySet()); + } + + /** Returns up to {@code limit} of the most recent messages on a channel, with content. */ + List getMessages(String channelId, int limit) throws ClientException { + return client.getMessages(channelId, new MessageFilter(), true, 0, limit); + } + /** Reads one message back, with content, so assertions can inspect every connector. */ Message fetchMessage(String channelId, long messageId) throws ClientException { MessageFilter filter = new MessageFilter(); diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/SecurityChannels.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/SecurityChannels.java new file mode 100644 index 0000000000..9ea63d0a34 --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/SecurityChannels.java @@ -0,0 +1,86 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Open Integration Engine + +package org.openintegrationengine.smoketest; + +import com.mirth.connect.donkey.model.channel.SourceConnectorPropertiesInterface; +import com.mirth.connect.model.Channel; +import com.mirth.connect.model.Connector; +import com.mirth.connect.model.Transformer; +import com.mirth.connect.model.converters.ObjectXMLSerializer; +import com.mirth.connect.plugins.datatypes.xml.XMLBatchProperties; +import com.mirth.connect.plugins.datatypes.xml.XMLBatchProperties.SplitType; +import com.mirth.connect.plugins.datatypes.xml.XMLDataTypeProperties; +import com.mirth.connect.plugins.xsltstep.XsltStep; + +/** + * Builds the channels the XXE security tests deploy. Each starts from a known-good VM + * reader -> VM writer no-op channel ({@code fixtures/security/base-vm-noop.xml}, a copy of the + * 101-raw-no-op fixture) and customises only the source transformer, so the tests do not have to + * hand-author connector XML. The serializer is already initialised by {@link OieServer}. + */ +final class SecurityChannels { + + private static final String BASE_RESOURCE = "fixtures/security/base-vm-noop.xml"; + + private SecurityChannels() { + } + + private static Channel base(String id, String name) { + Channel channel = ObjectXMLSerializer.getInstance().deserialize(Harness.resource(BASE_RESOURCE), Channel.class); + channel.setId(id); + channel.setName(name); + return channel; + } + + /** + * A channel whose source transformer runs an XSLT step over the raw inbound message. Used to + * prove CVE-2026-78224: an external entity in the source XML is resolved (pre-fix) or the step + * errors because external access is denied (post-fix). + */ + static Channel xsltXxe(String id, String name, String resultVariable) { + Channel channel = base(id, name); + Transformer transformer = channel.getSourceConnector().getTransformer(); + + XsltStep step = new XsltStep(); + step.setName("XSLT XXE"); + step.setSequenceNumber("0"); + step.setEnabled(true); + // sourceXml and template are inserted verbatim into the generated JS as expressions. + step.setSourceXml("connectorMessage.getRawData()"); + step.setResultVariable(resultVariable); + step.setUseCustomFactory(false); + step.setCustomFactory(""); + step.setTemplate("'" + + "" + + "" + + "'"); + + transformer.getElements().add(step); + return channel; + } + + /** + * A channel with an XML data type source and batch processing enabled, splitting on an element + * name. Used to prove CVE-2026-82578: the batch adaptor parses the untrusted XML and resolves + * DOCTYPE entities (pre-fix) or rejects the DOCTYPE (post-fix). + */ + static Channel xmlBatchXxe(String id, String name, String splitElement) { + Channel channel = base(id, name); + Connector source = channel.getSourceConnector(); + + ((SourceConnectorPropertiesInterface) source.getProperties()).getSourceConnectorProperties() + .setProcessBatch(true); + + Transformer transformer = source.getTransformer(); + transformer.setInboundDataType("XML"); + + XMLDataTypeProperties xmlProperties = new XMLDataTypeProperties(); + XMLBatchProperties batchProperties = (XMLBatchProperties) xmlProperties.getBatchProperties(); + batchProperties.setSplitType(SplitType.Element_Name); + batchProperties.setElementName(splitElement); + transformer.setInboundProperties(xmlProperties); + + return channel; + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/XmlBatchXxeTest.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/XmlBatchXxeTest.java new file mode 100644 index 0000000000..455af1f39c --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/XmlBatchXxeTest.java @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Open Integration Engine + +package org.openintegrationengine.smoketest; + +import static org.junit.jupiter.api.Assertions.fail; + +import java.time.Duration; +import java.util.LinkedHashMap; + +import org.junit.jupiter.api.Test; + +import com.mirth.connect.donkey.model.message.ConnectorMessage; +import com.mirth.connect.donkey.model.message.Message; +import com.mirth.connect.donkey.model.message.MessageContent; +import com.mirth.connect.model.Channel; + +/** + * Validates CVE-2026-82578: the XML batch adaptor parsed untrusted batch XML with a DOCTYPE-resolving + * parser, expanding entities (the same parse that resolves external entities for file exfiltration). + * + *

+ * The channel splits a batch by element name. The batch carries an internal DOCTYPE entity referenced + * inside a split element. If the parser resolves it, the split child message contains the marker. + * + *

+ * Pre-fix this test is RED (a split message contains {@code OIE_XXE_MARKER}); post-fix it is GREEN + * (the DOCTYPE is rejected, the batch errors, and no expanded child is produced). + */ +class XmlBatchXxeTest { + + private static final String CHANNEL_ID = "5ec00002-0000-4000-8000-00000000ba7c"; + private static final String MARKER = "OIE_XXE_MARKER"; + + @Test + void doctypeEntityInBatchIsNotExpanded() throws Exception { + OieServer server = SharedServer.get(); + Channel channel = SecurityChannels.xmlBatchXxe(CHANNEL_ID, "SEC XML BATCH XXE", "message"); + String channelId = server.deployChannel(channel, "xml-batch-xxe"); + try { + String payload = "" + + "]>" + + "&x;"; + try { + server.submitMessage(channelId, payload, new LinkedHashMap<>()); + } catch (Exception batchRejected) { + // Post-fix the batch parse throws; no expanded child is produced. Acceptable. + } + + // Fail fast if the entity is ever expanded into a stored message; otherwise the batch was + // rejected (fixed). Bounded so the fixed path does not wait the full deploy/message timeout. + Duration window = min(HarnessConfig.TIMEOUT, Duration.ofSeconds(20)); + long deadline = System.nanoTime() + window.toNanos(); + while (System.nanoTime() < deadline) { + for (Message message : server.getMessages(channelId, 50)) { + if (containsMarker(message)) { + fail("XML batch adaptor expanded a DOCTYPE entity (message contained '" + MARKER + + "') -- CVE-2026-82578 is present"); + } + } + Thread.sleep(500); + } + } finally { + server.removeChannel(channelId); + } + } + + private static boolean containsMarker(Message message) { + if (message.getConnectorMessages() == null) { + return false; + } + for (ConnectorMessage connectorMessage : message.getConnectorMessages().values()) { + if (contentContainsMarker(connectorMessage.getRaw()) || contentContainsMarker(connectorMessage.getTransformed()) + || contentContainsMarker(connectorMessage.getEncoded())) { + return true; + } + } + return false; + } + + private static boolean contentContainsMarker(MessageContent content) { + return content != null && content.getContent() != null && content.getContent().contains(MARKER); + } + + private static Duration min(Duration a, Duration b) { + return a.compareTo(b) <= 0 ? a : b; + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/XsltStepXxeTest.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/XsltStepXxeTest.java new file mode 100644 index 0000000000..9ee675f57b --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/XsltStepXxeTest.java @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Open Integration Engine + +package org.openintegrationengine.smoketest; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.LinkedHashMap; + +import org.junit.jupiter.api.Test; + +import com.mirth.connect.donkey.model.message.ConnectorMessage; +import com.mirth.connect.donkey.model.message.Message; +import com.mirth.connect.donkey.model.message.Status; +import com.mirth.connect.model.Channel; + +/** + * Validates CVE-2026-78224: the XSLT transformer step resolved external entities in the source XML. + * + *

+ * The channel's source transformer runs an XSLT step over the raw inbound message. The message + * carries an external general entity ({@code file:///etc/hostname}, present in both server images). + * + *

+ * Pre-fix this test is RED (the transformer resolves the external entity and the message completes + * successfully, so the source status is not ERROR); post-fix it is GREEN (external DTD/entity access + * is denied, the XSLT step throws, and the source message reaches ERROR). + */ +class XsltStepXxeTest { + + private static final String CHANNEL_ID = "5ec00001-0000-4000-8000-00000000c1a5"; + + @Test + void externalEntityInXsltSourceIsDenied() throws Exception { + OieServer server = SharedServer.get(); + Channel channel = SecurityChannels.xsltXxe(CHANNEL_ID, "SEC XSLT XXE", "xsltResult"); + String channelId = server.deployChannel(channel, "xslt-xxe"); + try { + String payload = "" + + "]>" + + "&x;"; + long messageId = server.submitMessage(channelId, payload, new LinkedHashMap<>()); + + Status sourceStatus = awaitSourceStatus(server, channelId, messageId); + assertEquals(Status.ERROR, sourceStatus, "XSLT step resolved an external entity instead of denying " + + "external access -- CVE-2026-78224 (source status was " + sourceStatus + ")"); + } finally { + server.removeChannel(channelId); + } + } + + /** Polls until the message is processed, then returns the source connector's status. */ + private static Status awaitSourceStatus(OieServer server, String channelId, long messageId) throws Exception { + long deadline = System.nanoTime() + HarnessConfig.TIMEOUT.toNanos(); + Status last = null; + while (System.nanoTime() < deadline) { + Message message = server.fetchMessage(channelId, messageId); + if (message != null) { + ConnectorMessage source = message.getConnectorMessages() == null ? null + : message.getConnectorMessages().get(0); + if (source != null) { + last = source.getStatus(); + } + if (message.isProcessed() && last != null && last != Status.PENDING && last != Status.QUEUED) { + return last; + } + } + Thread.sleep(500); + } + return last; + } +} diff --git a/smoketest/src/test/resources/fixtures/security/base-vm-noop.xml b/smoketest/src/test/resources/fixtures/security/base-vm-noop.xml new file mode 100644 index 0000000000..923b5d43e6 --- /dev/null +++ b/smoketest/src/test/resources/fixtures/security/base-vm-noop.xml @@ -0,0 +1,184 @@ + + 62af393b-ff61-47ec-b5fa-ccd2cd08ce55 + 2 + Noop + + 1 + + 0 + sourceConnector + + + + None + true + false + false + 1 + + + Default Resource + [Default Resource] + + + 1000 + + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + + Channel Reader + SOURCE + true + true + + + + 1 + Destination 1 + + + + false + false + 10000 + false + 0 + false + false + 1 + + false + + + Default Resource + [Default Resource] + + + 1000 + true + + none + ${message.encodedData} + + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + RAW + RAW + + + JavaScript + + + + + + JavaScript + + + + + + + + Channel Writer + DESTINATION + true + true + + + // Modify the message variable below to pre process data +return message; + // This script executes once after a message has been processed +// Responses returned from here will be stored as "Postprocessor" in the response map +return; + // This script executes once when the channel is deployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + // This script executes once when the channel is undeployed +// You only have access to the globalMap and globalChannelMap here to persist data +return; + + true + DEVELOPMENT + false + false + false + false + false + false + STARTED + true + + + SOURCE + STRING + mirth_source + + + TYPE + STRING + mirth_type + + + + None + + + + + Default Resource + [Default Resource] + + + + + + true + + + America/Chicago + + + true + false + + 1 + + + \ No newline at end of file From 42db3da51bbaa0d795c4ca7cb6cadb9cb2e8d434 Mon Sep 17 00:00:00 2001 From: Jon Bartels Date: Mon, 14 Sep 2026 11:41:10 -0400 Subject: [PATCH 2/2] Open java.util (and friends) for the smoke-test harness JVM The client SDK deserializes model objects with XStream, whose reflective converters need the same module access the server's own test task already opens (server/build.gradle). The harness JVM launched plain `java`, so deserializing a SortedSet response -- the JDBC connector's getTables result, exercised by the new SQL-injection test -- failed on JDK 17 with InaccessibleObjectException on TreeSet.m. Add the java.base/java.util (and related) opens to run-harness.sh. The extra per-configuration -D flags are now read into an array so they reach the java command as separate arguments without relying on unquoted word-splitting. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Jon Bartels --- ci/run-harness.sh | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ci/run-harness.sh b/ci/run-harness.sh index 71ea56c6d9..aeb4b0f735 100755 --- a/ci/run-harness.sh +++ b/ci/run-harness.sh @@ -13,13 +13,32 @@ classpath=$(find "$ENGINE_HOME/server-lib" "$ENGINE_HOME/extensions" "$HARNESS_D results="$RESULTS_DIR/$OIE_CONFIGURATION" mkdir -p "$results" +# The client SDK deserializes model objects with XStream, whose reflective converters need the +# same module access the server's own test task opens (server/build.gradle). Without at least +# java.base/java.util opened, deserializing a SortedSet response (e.g. the JDBC connector's +# getTables result) fails with InaccessibleObjectException on JDK 17. +jvm_opts=( + --add-exports=java.base/com.sun.crypto.provider=ALL-UNNAMED + --add-opens=java.base/java.util=ALL-UNNAMED + --add-opens=java.base/java.lang=ALL-UNNAMED + --add-opens=java.base/java.lang.reflect=ALL-UNNAMED + --add-opens=java.base/java.text=ALL-UNNAMED + --add-opens=java.sql/java.sql=ALL-UNNAMED + --add-opens=java.xml/com.sun.org.apache.xalan.internal.xsltc.trax=ALL-UNNAMED +) + +# Split the extra -D flags (set per configuration in run-configuration.sh) into an array so they +# reach the java command as separate arguments without unquoted globbing/word-splitting. +read -ra harness_opts <<< "${OIE_HARNESS_OPTS:-}" + # Lock engine to junit-jupiter to prevent false-pass results. status=0 java \ + "${jvm_opts[@]}" \ -Doie.baseUrl="$OIE_BASE_URL" \ -Doie.configuration="$OIE_CONFIGURATION" \ -Doie.password="$OIE_PASSWORD" \ - ${OIE_HARNESS_OPTS:-} \ + "${harness_opts[@]}" \ -cp "$classpath" \ org.junit.platform.console.ConsoleLauncher execute \ --select-package=org.openintegrationengine.smoketest \