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/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 \ 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