Skip to content

Fix SQLi + XXE CVEs (CVE-2026-82583, -78224, -82578) with regression tests - #441

Open
jonbartels wants to merge 3 commits into
OpenIntegrationEngine:mainfrom
jonbartels:fix/mirth-sqli-xxe-cves
Open

jonbartels wants to merge 3 commits into
OpenIntegrationEngine:mainfrom
jonbartels:fix/mirth-sqli-xxe-cves

Conversation

@jonbartels

@jonbartels jonbartels commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes three publicly-disclosed vulnerabilities (advisory) present in this tree (4.6.0 is below every upstream fix version). Each fix uses an idiom already established in this codebase; the branch is one commit per CVE (fix + its tests together).

CVE Component Fix
CVE-2026-82583 (SQLi, CWE-89) DatabaseConnectorServlet Ignore caller selectLimit; resolve the probe template server-side from the built-in driver list; escape identifiers
CVE-2026-78224 (XSLT XXE, CWE-611) XsltStep Deny external DTDs in the source XML; restrict stylesheet references to the file protocol (fail-closed)
CVE-2026-82578 (XML batch XXE, CWE-611) XMLBatchAdaptor Parse with a hardened, namespace-aware DocumentBuilderFactory, then evaluate XPath

The fixes

CVE-2026-82583 — SQL injection

DatabaseConnectorServlet.getTables executed the caller-supplied selectLimit query parameter as SQL via Statement.executeQuery. selectLimit is only ever a driver-specific metadata-probe template (the metadata dialog sends the driver's own value), so it is now ignored and resolved server-side from the built-in DriverInfo list keyed by the JDBC driver class; anything unrecognised uses the safe DatabaseMetaData.getColumns() path.

Resolving only from DriverInfo.getDefaultDrivers() — never the API-writable configured driver list — closes the injection regardless of the deployment's authorization model. resolveSelectLimit is static and unit-tested with no database or live server.

The probe query also embeds the schema/table identifier. Those names come from database metadata but may themselves contain a ", which would break out of the quoting (a second-order injection); quoteSchemaTable now doubles any embedded quote per the SQL standard. The generic getColumns() paths are scoped to the connecting user's discovered schema so a same-named table in another schema cannot leak its columns.

Deliberately out of scope (follow-ups): the endpoint's @MirthOperation has no permission and auditable = false, and driver/url are unconstrained. The same gap exists on every other connector test servlet (file/tcp/http/smtp/ws/jms) and is better handled as its own change.

CVE-2026-78224 — XSLT step XXE

XsltStep builds a TransformerFactory inside generated JavaScript, so it was never reached by the Java-side XML hardening elsewhere in the tree. The source XML is attacker-controlled, so the generated script sets ACCESS_EXTERNAL_DTD="" on both the normal and iterator paths — denying external DTDs/entities in it, which is what closes the CVE. The stylesheet is channel-author content, so following the OWASP XXE cheat sheet ("restrict rather than close" external references in your own stylesheets), ACCESS_EXTERNAL_STYLESHEET is restricted to the file protocol rather than blocked: local xsl:import/xsl:include/document() keep working while http(s) SSRF is denied. Neither is swallowed: a factory that rejects the attribute fails the transform rather than running with external access silently open.

FEATURE_SECURE_PROCESSING is deliberately not enabled: on the JDK's built-in Xalan it also disables Java XSLT extension functions, which would break existing stylesheets that call Java.

Note: the stylesheet is loaded from a StringReader with no base URI, so relative hrefs may not resolve regardless; the file filter mainly governs absolute file:///… references.

CVE-2026-82578 — XML batch adaptor XXE

XMLBatchAdaptor evaluated XPath directly over an InputSource, letting the XPath engine 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. namespaceAware is set true to preserve the prior path's namespace-aware parsing (namespace-uri()/prefix-sensitive split queries). The hardened parse is factored into a static parseBatchSecurely helper.

Tests

  • DatabaseConnectorServletTest (server unit test) — resolveSelectLimit returns the built-in template per driver and "" for unknown/injected drivers (proof the caller value never reaches executeQuery); quoteSchemaTable doubles embedded quotes (second-order-injection payload stays a single quoted identifier).
  • XsltStepSecurityTest (server unit test) — the generated script denies external DTD access ('') and restricts stylesheet access to 'file' on both paths, does not emit FEATURE_SECURE_PROCESSING, and is not wrapped in a swallowing catch.
  • ci/tests/200-xslt-step-xxe (fixture) — an external-entity message → ERROR; a well-formed control → TRANSFORMED.
  • XMLBatchAdaptorSecurityTest (server unit test) — external- and internal-entity DOCTYPEs are rejected with a specific DOCTYPE oracle (not catch-all); a benign namespaced batch still parses and a namespace-uri() predicate matches.
  • XmlBatchXxeTest (smoketest) — deploys a batch-splitting channel and pairs the malicious batch with a benign control that must split, so the "marker not expanded" assertion cannot pass on an unrelated failure.

Behavior changes

  • XML batch: disallow-doctype-decl rejects all DOCTYPEs — batches with an internal-subset DTD that previously parsed now error.
  • JDBC metadata: unknown/custom drivers use the generic getColumns() path (correct, possibly slower), now scoped to the connecting user's schema when one is discovered; identifiers are escaped.
  • XSLT: external DTD access (source XML) is denied and the step fails closed if a factory rejects the restriction. External stylesheet references are restricted to the file protocol (OWASP "restrict rather than close"), so local xsl:import/xsl:include/document() still work but http(s) stylesheet fetches are denied. FEATURE_SECURE_PROCESSING is intentionally not enabled, so Java XSLT extension functions keep working.

Verification

  • ./gradlew :server:testDatabaseConnectorServletTest, XsltStepSecurityTest, XMLBatchAdaptorSecurityTest pass; existing datatypes.xml, DocumentSerializer, and jdbc tests still pass.
  • ci/runtests.sh alpine-temurin21-derby — the XSLT fixture (malicious ERROR / benign TRANSFORMED) and the hardened XmlBatchXxeTest run in every configuration; no oie.db.* or --add-opens needed.

🤖 Generated with Claude Code

@jonbartels

Copy link
Copy Markdown
Contributor Author

@abhinavagarwal07 - OpenIntegrationEngine is a fork of Mirth Connect. OIE is often affected by the same historical security risks as OIE. We learned about your security findings at https://abhinavagarwal07.github.io/posts/nextgen-mirth-connect-sqli-xxe/

Would you be willing to evaluate our fixes against your findings please?

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

XML namespace behavior regresses, and the SQL injection test can falsely pass on MariaDB.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Hardens JDBC metadata, XSLT, and XML batch processing against SQL injection and XXE vulnerabilities, with regression coverage.

Changes:

  • Adds allowlist validation for JDBC selectLimit.
  • Secures XSLT and XML batch parsing.
  • Adds unit/smoke tests and CI database configuration.
File summaries
File Description
smoketest/.../base-vm-noop.xml Adds security-test channel fixture.
smoketest/.../XsltStepXxeTest.java Tests XSLT XXE rejection.
smoketest/.../XmlBatchXxeTest.java Tests XML batch entity rejection.
smoketest/.../SecurityChannels.java Builds security-test channels.
smoketest/.../OieServer.java Adds security-test server helpers.
smoketest/.../DatabaseConnectorSqlInjectionTest.java Tests JDBC SQL injection blocking.
smoketest/build.gradle Adds test compile dependencies.
server/.../XsltStepSecurityTest.java Verifies generated XSLT hardening.
server/.../XsltStep.java Secures generated transformer factories.
server/.../XMLBatchAdaptor.java Uses hardened XML parsing.
server/.../DatabaseConnectorServlet.java Validates selectLimit.
ci/run-configuration.sh Supplies database test parameters.
ci/harness.compose.yml Forwards harness JVM options.
Review details
  • Files reviewed: 13/13 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread server/src/main/java/com/mirth/connect/plugins/datatypes/xml/XMLBatchAdaptor.java Outdated
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

Test Results

126 files  + 3  126 suites  +3   5m 14s ⏱️ + 3m 25s
704 tests +14  704 ✅ +14  0 💤 ±0  0 ❌ ±0 
734 runs  +32  734 ✅ +32  0 💤 ±0  0 ❌ ±0 

Results for commit a8157c7. ± Comparison against base commit 9359d9a.

♻️ This comment has been updated with latest results.

@abhinavagarwal07

Copy link
Copy Markdown

@jonbartels Sure. I will review it.

@jonbartels
jonbartels marked this pull request as ready for review September 14, 2026 16:28

@mgaffigan mgaffigan left a comment

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.

I'm not sure about the database connector. XML and XsltStep look legitimate. The tests need to be substantially simplified:

  1. The SQL test should be a unit test - does not need a live server to confirm that it validates
  2. The Xxe repros should be fixture tests. See https://github.com/OpenIntegrationEngine/engine/blob/main/ci/README.md#add-a-fixture-test or https://github.com/OpenIntegrationEngine/engine/tree/main/ci/tests/110-hl7-no-op/channels/01-hl7-no-op

Comment thread ci/run-configuration.sh Outdated
Comment thread ci/run-harness.sh Outdated
Comment thread ci/run-harness.sh Outdated
Comment thread server/src/main/java/com/mirth/connect/plugins/xsltstep/XsltStep.java Outdated

@abhinavagarwal07 abhinavagarwal07 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed at 42db3da5 — notes inline.

The XXE fixes look right for the default JDK factory, and the batch fix covers Element_Name and Level as well as XPath_Query, which is broader than what was reported.

Three things I wanted to check: the selectLimit allowlist is populated from an API-writable source, the XSLT hardening is dropped silently on Saxon 9.x, and the batch parser is no longer namespace-aware. Measurements are in the inline comments — all run standalone on JDK 21, none against a live OIE server.

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.

* 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?

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

// 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://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?

@Test
void selectLimitDoesNotExecuteArbitrarySql() throws Exception {
Db db = Db.fromSystemProperties();
assumeTrue(db != null, "oie.db.* coordinates not provided; skipping (embedded-database configuration)");

@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.

This skips whenever oie.db.* is unset — the embedded-Derby configurations. That's the default deployment and the one the reported impact used (SYSCS_EXPORT_QUERY writing the channel table to an unauthenticated path). Deliberate?

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 "

@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.

Status.ERROR is also reached on a deploy or template failure, so it doesn't separate "external access denied" from "threw for another reason". Would a benign control plus asserting the file contents are absent work?

String channelId = server.deployChannel(channel, "xml-batch-xxe");
try {
String payload = "<?xml version=\"1.0\"?>"
+ "<!DOCTYPE batch [<!ENTITY x \"" + MARKER + "\">]>"

@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.

Internal entity, so this covers expansion rather than external resolution. It passes because disallow-doctype-decl blocks both; under external-general-entities=false alone it'd fail while the file-read path stayed closed. Worth an external canary case?

+ "<batch><message>&x;</message></batch>";
try {
server.submitMessage(channelId, payload, new LinkedHashMap<>());
} catch (Exception batchRejected) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Same as the SQLi test — a failure before XMLBatchAdaptor runs is indistinguishable from the fix working.


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?

@tonygermano tonygermano left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As you rework this to address some of the other feedback, can you split it up into multiple commits? Probably one for any harness pre-work that needs to be done, and then 1 commit per CVE? It should be plainly obvious which changes are related to which fixes.

@jonbartels
jonbartels force-pushed the fix/mirth-sqli-xxe-cves branch from 42db3da to b767e4e Compare September 15, 2026 17:27
@jonbartels

Copy link
Copy Markdown
Contributor Author

Thanks all — this was substantive feedback and it changed the shape of the PR. I force-pushed a rework: the branch is now three per-CVE commits (ed4e002 SQLi, fb55c13 XSLT, b767e4e XML batch), each with its fix and test together. Summary of what changed and why:

SQL injection (CVE-2026-82583) — reworked, not just re-tested. @mgaffigan's question ("why is it a parameter?") and @abhinavagarwal07's point about the allowlist source are the same problem from two sides, and both are right. I verified it: setDatabaseDrivers is nominally gated by DATABASE_DRIVERS_EDIT, but DefaultAuthorizationController.isUserAuthorized() returns true unconditionally, and getDatabaseDrivers() reads the API-writable PROPERTIES_DATABASE_DRIVERS ahead of dbdrivers.xml/defaults — so my original allowlist was populated by a source the same caller could write (bypassable), and in the stock build there's no privilege boundary being crossed at all.

So selectLimit is no longer trusted from the caller. getTables now ignores the parameter and resolves the metadata-probe template server-side from DriverInfo.getDefaultDrivers(), keyed by the JDBC driver class; anything unrecognised uses the safe DatabaseMetaData.getColumns() path. It consults only the built-in list (never the API-writable config), so the injection is closed regardless of the authorization model. resolveSelectLimit is static and covered by a plain unit test (@mgaffigan) — no DB, no live server.

  • Trade-off worth calling out: an admin-configured custom (non-built-in) driver no longer gets its optimized probe and falls back to the generic getColumns() path (correct, just slower). Fully removing selectLimit from the interface/client is a breaking API change I left as a follow-up.

XSLT step XXE (CVE-2026-78224). Now fails closed — the setAttribute calls are no longer wrapped in a swallowing try/catch (@mgaffigan, @abhinavagarwal07): a factory that rejects ACCESS_EXTERNAL_* now fails the transform instead of running with external access silently open. The repro is a ci/tests fixture (200-xslt-step-xxe) with a malicious external-entity message (→ ERROR) and a well-formed control (→ TRANSFORMED).

XML batch XXE (CVE-2026-82578). Kept the hardened-DBF parse and added setNamespaceAware(true) — the prior XPath.evaluate(InputSource) path parsed namespace-aware, so this preserves namespace-uri()/prefix-sensitive split queries (@copilot, @abhinavagarwal07). This one stays a Java smoke test rather than a fixture: a rejected batch surfaces as a submit-time exception, which the fixture runner (assert-on-result) can't model cleanly.

Behavior-change note: disallow-doctype-decl rejects all DOCTYPEs, including internal-only DTDs that previously parsed. Happy to add a changelog entry if the project keeps one.

The two XXE fixes themselves are essentially unchanged from what you all endorsed. CI is green across all seven configurations. Re-requesting review — thanks again.

DatabaseConnectorServlet.getTables executed the caller-supplied selectLimit query
parameter as SQL via Statement.executeQuery. selectLimit is only ever a
driver-specific metadata-probe template (the metadata dialog sends the driver's
own value), so it is now ignored and resolved server-side from the built-in
DriverInfo list keyed by the JDBC driver class; anything unrecognised uses the
safe DatabaseMetaData.getColumns() path.

Resolving only from DriverInfo.getDefaultDrivers() -- never the API-writable
configured driver list -- closes the injection regardless of the deployment's
authorization model. resolveSelectLimit is static and covered by a unit test that
needs no database or live server.

The probe query also embeds the schema/table identifier. Those names come from
database metadata but may themselves contain a double quote, which would break out
of the quoting (a second-order injection); quoteSchemaTable now doubles any
embedded quote per the SQL standard. The generic getColumns() paths are scoped to
the connecting user's discovered schema so a same-named table in another schema
cannot leak its columns.

Trade-off: an admin-configured custom (non-built-in) driver no longer gets its
optimized probe and falls back to the generic getColumns() path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jon Bartels <jonathan.bartels@gmail.com>
@jonbartels
jonbartels force-pushed the fix/mirth-sqli-xxe-cves branch from b767e4e to 55f3ac1 Compare September 16, 2026 14:58
@jonbartels

Copy link
Copy Markdown
Contributor Author

Pushed another rework after a cross-review (two independent AIs comparing this branch against BridgeLink's backport 73a8fb46). One commit per CVE still. Every claim below was checked against the code, not taken on trust — the ones I couldn't substantiate I left out.

Adopted:

  • XSLT — dropped FEATURE_SECURE_PROCESSING (CVE-2026-78224). Two reviewers independently reproduced on JDK 17/21 that FSP disables Java XSLT extension functions on the built-in Xalan, which would break existing stylesheets that call Java. The two ACCESS_EXTERNAL_* attributes close the XXE on their own (verified — independent JAXP properties, no FSP required), and they stay fail-closed. So FSP is gone and extension functions keep working; the fixture still errors on the external entity.
  • SQLi — second-order sink closed + schema scoping (CVE-2026-82583). The probe query embedded the schema/table identifier in "..." without escaping; a DB identifier containing a " could break out. quoteSchemaTable now doubles embedded quotes (SQL standard), with a unit test on the injection payload. The generic getColumns() fallbacks are also scoped to the connecting user's discovered schema so a same-named table in another schema can't leak columns.
  • XML batch — added a fast, precise unit test (CVE-2026-82578). The hardened parse is factored into parseBatchSecurely, unit-tested to reject external- and internal-entity DOCTYPEs with a specific DOCTYPE oracle (not catch-all) and to still parse a benign namespaced batch (locking in namespace-awareness). The live smoke test now pairs the malicious batch with a benign control that must split, so "marker not expanded" can't pass on an unrelated failure.
  • Docs: the selectLimit parameter description now states it's ignored/server-derived; the PR body has a behavior-changes section (DOCTYPE rejection, custom-driver probe fallback, extension functions preserved).

Rejected: BridgeLink catches and continues when a custom XSLT factory rejects the security attributes (fails open — reproducible external-file resolution on Xalan 2.7.2). This branch fails closed there; that's deliberate and I'm keeping it. The endpoint's missing permission/audit and unconstrained driver/url remain a separate follow-up.

./gradlew :server:test for the three touched packages is green.

jonbartels and others added 2 commits September 16, 2026 13:33
XsltStep builds a TransformerFactory inside generated JavaScript, so it was missed
by the Java-side XML hardening elsewhere in the tree. The attacker-controlled source
XML is now protected by ACCESS_EXTERNAL_DTD = "", denying external DTDs/entities in
it -- this is what closes the CVE. These are not swallowed: a factory that rejects
the attribute fails the transform rather than running with external access silently
left open.

The stylesheet is channel-author content, so per the OWASP XXE cheat sheet ("restrict
rather than close" external references in your own stylesheets) ACCESS_EXTERNAL_STYLESHEET
is restricted to the "file" protocol rather than blocked: local xsl:import / xsl:include /
document() keep working, while http(s) SSRF is denied.

FEATURE_SECURE_PROCESSING is deliberately not enabled: on the JDK's built-in Xalan
it also disables Java XSLT extension functions, which would break existing stylesheets
that call Java.

Covered by a unit test on the generated script and a ci/tests fixture: an
external-entity message errors, a well-formed control transforms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jon Bartels <jonathan.bartels@gmail.com>
XMLBatchAdaptor evaluated XPath directly over an InputSource, letting the XPath
engine 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. namespaceAware is set true to preserve the prior XPath path's
namespace-aware parsing (namespace-uri()/prefix-sensitive split queries).

The hardened parse is factored into a static parseBatchSecurely helper and covered
by a server unit test that asserts external- and internal-entity DOCTYPEs are
rejected (a specific DOCTYPE oracle, not catch-all) and that a benign namespaced
batch still parses and splits. A smoke test additionally deploys a batch-splitting
channel and pairs the malicious batch with a benign control that must split, so the
"marker not expanded" assertion cannot pass on an unrelated failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jon Bartels <jonathan.bartels@gmail.com>
@jonbartels
jonbartels force-pushed the fix/mirth-sqli-xxe-cves branch from 55f3ac1 to a8157c7 Compare September 16, 2026 17:33
@jonbartels

Copy link
Copy Markdown
Contributor Author

Small XSLT adjustment after comparing against the Saga/BridgeLink backport: the previous revision set ACCESS_EXTERNAL_STYLESHEET="", which also blocked legitimate xsl:import / xsl:include / document() in author stylesheets.

Rather than leave stylesheet access fully open (as the BridgeLink/Saga port does), I followed the OWASP XXE cheat sheet, which says to "restrict rather than close" external references in your own stylesheets via a protocol filter. So ACCESS_EXTERNAL_STYLESHEET is now 'file': local includes/document() work, http(s) SSRF is denied. ACCESS_EXTERNAL_DTD="" (the source-XML XXE that is the actual CVE) and the fail-closed posture are unchanged, and the ci fixture still errors on the external-entity payload.

Still one commit per CVE; only the XSLT commit changed.

@gibson9583 gibson9583 left a comment

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.

The disclosed CVEs are addressed by the current code:

  • CVE-2026-82583: caller-supplied and API-configured selectLimit values no longer reach executeQuery; only immutable built-in templates are selected.
  • CVE-2026-78224: external DTD access and HTTP(S) stylesheet/document access are denied, and factories that reject the security attributes fail closed.
  • CVE-2026-82578: every affected XML-batch mode parses through the hardened parser before XPath evaluation or child traversal, and all DOCTYPE declarations are rejected.

I found no bypass of those three reported attack paths.

I am requesting changes for one merge-blocking functional regression described inline: the JDBC generic metadata path discards catalog/schema identity and can combine columns from same-named tables. This behavior existed in the old fallback, but the PR newly routes custom drivers that previously had a configured metadata query into that path.

Please preserve the catalog/schema/table tuple through table and column discovery, and add an endpoint-level regression test covering a login that differs from the active schema with duplicate table names.

rs = dbMetaData.getColumns(null, null, tableName, null);
// Scope to the discovered schema (may be null) so a same-named table in
// another schema does not leak its columns into the result.
rs = dbMetaData.getColumns(null, schema, tableName, null);

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.

schema remains null whenever the login name does not match the active schema. In that case, both getTables(null, null, ...) and this getColumns(null, null, tableName, ...) search every visible schema/catalog. Because only TABLE_NAME is retained, columns from same-named tables are combined.

The ambiguous fallback existed previously, but this PR newly routes custom drivers into it by ignoring their configured metadata query. Please preserve each result row’s TABLE_CAT, TABLE_SCHEM, and TABLE_NAME and pass that exact tuple to getColumns(), or explicitly restrict discovery to the connection’s active catalog/schema.

Please add an endpoint-level regression test where the login differs from the active schema and duplicate table names exist.

* doubled so the value is always a single quoted identifier. Package-private and static so it is
* unit-testable without a live database.
*/
static String quoteSchemaTable(String schema, String tableName) {

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.

Nonblocking follow-up:

quoteSchemaTable() assumes every supported database uses SQL-standard double quotes for identifiers. MySQL/MariaDB use backticks by default unless ANSI_QUOTES is enabled, so their optimized probe can fail and enter the generic fallback.

This portability limitation predates the current PR and does not reopen CVE-2026-82583. A follow-up should use DatabaseMetaData.getIdentifierQuoteString() with delimiter-aware escaping, or remove the optimized SQL probe in favor of exact catalog/schema metadata lookup. Runtime MySQL/MariaDB coverage would also be valuable.

@pacmano1

Copy link
Copy Markdown
Contributor

As you all know, I prefer to start the car after the battery is replaced, so I (and Claude)
checked all three fixes on a live engine.

I wrote a test per CVE that runs the attack against a deployed channel and asserts the
attack works. Same tests against main 9359d9a8 and against this branch a8157c78:

main this branch
SQLi (CVE-2026-82583) succeeds blocked
XSLT external entity (CVE-2026-78224) succeeds blocked
XSLT external entity, no credentials succeeds blocked
XML batch external entity (CVE-2026-82578) succeeds blocked
your two XXE tests fail pass
raw passthrough, HL7 ADT passthrough pass pass

"Blocked" there means the engine refused the attack for a reason it named, not that
something unrelated broke. The two XSLT rows failed on accessExternalDTD and the batch on
disallow-doctype-decl out of parseBatchSecurely. The SQLi row has no error at all:
_getTables returned the CHANNEL table's real columns instead of the OIE_SQLI_MARKER
alias I injected, so the injected query never ran.

A stylesheet pulling in a lookup file with document() still works after you backed off
ACCESS_EXTERNAL_STYLESHEET="".

Chris reached the same conclusion by reading the code, so treat this as that result from a
running engine.

I did not test Chris's metadata regression: these tests exercise the attack paths, not
whether column discovery returns the right columns. One detail that may help there: the
schema scoping added in this PR only engages when the login name matches a schema name, so
where it does not, getColumns still runs with a null schema and same-named tables in
other schemas still merge.

Tony is right. _getTables declares no permission, where updateChannel and
setChannelEnabled declare CHANNELS_MANAGE. Our Role-Based Access Control plugin keys on
the declared permission, so it has nothing to match for this one and allows it: a read-only
user reaches the endpoint. Credentials are no barrier on a default install either, because
Derby is embedded and takes none, and jdbc:derby:appdata/mirthdb is the engine's own
database. My test used exactly that, with empty username and password. SQLite ships as well
and is also credential-free, though I have not tried it.

There are two halves to that. getTables could declare a permission here, the way the
channel operations do, and our plugin could stop allowing operations it has no mapping for.
I filed the plugin half as diridium-com/role-based-access-control#9. Neither belongs in
this PR.

Ran on JREs 17 and 21, Derby, linux/arm64. Not amd64, and not the other databases.

The tests are throwaway: they assert the attacks work, so merging them would leave a suite
that fails forever.

@pacmano1 pacmano1 left a comment

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.

Approve based on my last comment demonstrating running engine behavior before and after the fix.

@pacmano1
pacmano1 requested a review from gibson9583 September 18, 2026 19:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants