Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ci/harness.compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 24 additions & 3 deletions ci/run-configuration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
;;
Comment on lines +37 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These should come from the configurations/ directory, not hard-coded in this file.

esac

compose=(docker compose -f "$1" -f ci/harness.compose.yml -p "oie-ci-${OIE_CONFIGURATION//[^a-z0-9-]/-}-$$")

cleanup() {
Expand Down
21 changes: 20 additions & 1 deletion ci/run-harness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Comment on lines +16 to +28

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They should be the same as the runtime oieserver - not the other tests, I would presume.


# 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:-}"
Comment on lines +30 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid? If the options come from a file, I should assume this is not required.


# 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 \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -45,13 +47,18 @@ 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);
}

@Override
public SortedSet<Table> getTables(String channelId, String channelName, String driver, String url, String username, String password, Set<String> tableNamePatterns, String selectLimit, Set<String> 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually an issue? The whole point of this connector is to run arbitrary queries against a database, and I've not heard anything of an auth bypass. If we're always validating against the constant from the driver, why is it a parameter? What's the intended use of the parameter?


CustomDriver customDriver = null;
Connection connection = null;
try {
Expand Down Expand Up @@ -229,6 +236,49 @@ public SortedSet<Table> 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)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isBlank here and isEmpty at :160 differ. A whitespace-only selectLimit skips validation and then takes the query branch, trimming to "" and erroring into the fallback, so nothing executes. Is the difference deliberate?

return;
}

Set<String> allowedSelectLimits = new HashSet<String>();
addSelectLimits(allowedSelectLimits, DriverInfo.getDefaultDrivers());

try {
addSelectLimits(allowedSelectLimits, configurationController.getDatabaseDrivers());

@abhinavagarwal07 abhinavagarwal07 Sep 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The allowlist is populated from configuration the same caller can write.

PUT /api/server/databaseDrivers takes a caller-supplied selectLimit. It's annotated DATABASE_DRIVERS_EDIT, but stock DefaultAuthorizationController.isUserAuthorized() returns true unconditionally (:40-45) and is the only implementation in the tree. The value lands in the config property getDatabaseDrivers() reads first, ahead of dbdrivers.xml and the defaults (DefaultConfigurationController.java:753, :685).

So: PUT the payload as a driver's selectLimit, replay it here, reach executeQuery at :178.

Am I reading the stock authorization path right? If so, deriving selectLimit server-side from driver would avoid depending on it.

} 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<String> allowedSelectLimits, List<DriverInfo> 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 (,)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();

@abhinavagarwal07 abhinavagarwal07 Sep 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DocumentBuilderFactory defaults namespaceAware to false; the previous XPath.evaluate(InputSource, ...) path parsed namespace-aware. On JDK 21:

  • //*[local-name()='message' and namespace-uri()='urn:test'] — 1 match before, 0 after
  • <batch xmlns="urn:test"> splits to <message>hello</message>
  • prefixed input splits to <p:message>hi</p:message> with no xmlns:p

Element_Name and Level serialize the same way, so it isn't limited to XPath_Query. dbf.setNamespaceAware(true) restored all three. Was this checked against the old path?

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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Separately: disallow-doctype-decl rejects every DOCTYPE, including internal-only DTDs that previously parsed. Worth a release note?


nodeList = (NodeList) xpath.evaluate(query.toString(), document, XPathConstants.NODESET);
}

if (currentNode < nodeList.getLength()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

@abhinavagarwal07 abhinavagarwal07 Sep 14, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These catch blocks drop the restrictions with no signal when a factory rejects them. Emitted sequence on JDK 21:

Saxon FEATURE_SECURE_PROCESSING ACCESS_EXTERNAL_* source-document XXE
9.5.1-5, 9.7.0-21, 9.9.1-8 accepted both throw IllegalArgumentException file read succeeds
10.9, 11.6, 12.5 accepted accepted blocked

Secure processing succeeding on 9.x means nothing indicates the other two failed — it covers extension functions, not external document access.

useCustomFactory is supported and both tests set it false. In scope here? Failing closed when either attribute can't be set would cover it.

script.append("try { tFactory.setAttribute(Packages.javax.xml.XMLConstants.ACCESS_EXTERNAL_STYLESHEET, ''); } catch (e) {}\n");
Comment on lines +73 to +75

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we expect these to succeed, we should not be swallowing all errors.


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");
Expand Down
Original file line number Diff line number Diff line change
@@ -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("'<xsl:stylesheet version=\"1.0\"/>'");

String script = step.getScript(false);

assertTrue("secure processing should be enabled on the transformer factory",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This asserts the constant names appear in the generated string, so it passes on the Saxon 9.x configuration noted in XsltStep.java. Would stubbing a factory that accepts FEATURE_SECURE_PROCESSING and rejects both attributes be a better fit?

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("'<xsl:stylesheet version=\"1.0\"/>'");

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"));
}
}
5 changes: 5 additions & 0 deletions smoketest/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading