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, 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