-
Notifications
You must be signed in to change notification settings - Fork 76
Fix SQLi + XXE CVEs (CVE-2026-82583, -78224, -82578) with regression tests #441
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,195 @@ | ||
| <channel version="4.6.0"> | ||
| <id>5ec00001-0000-4000-8000-00000000c1a5</id> | ||
| <nextMetaDataId>2</nextMetaDataId> | ||
| <name>XSLT Step XXE</name> | ||
| <description></description> | ||
| <revision>1</revision> | ||
| <sourceConnector version="4.6.0"> | ||
| <metaDataId>0</metaDataId> | ||
| <name>sourceConnector</name> | ||
| <properties class="com.mirth.connect.connectors.vm.VmReceiverProperties" version="4.6.0"> | ||
| <pluginProperties/> | ||
| <sourceConnectorProperties version="4.6.0"> | ||
| <responseVariable>None</responseVariable> | ||
| <respondAfterProcessing>true</respondAfterProcessing> | ||
| <processBatch>false</processBatch> | ||
| <firstResponse>false</firstResponse> | ||
| <processingThreads>1</processingThreads> | ||
| <resourceIds class="linked-hash-map"> | ||
| <entry> | ||
| <string>Default Resource</string> | ||
| <string>[Default Resource]</string> | ||
| </entry> | ||
| </resourceIds> | ||
| <queueBufferSize>1000</queueBufferSize> | ||
| </sourceConnectorProperties> | ||
| </properties> | ||
| <transformer version="4.6.0"> | ||
| <elements> | ||
| <com.mirth.connect.plugins.xsltstep.XsltStep version="4.6.0"> | ||
| <name>XSLT XXE</name> | ||
| <sequenceNumber>0</sequenceNumber> | ||
| <enabled>true</enabled> | ||
| <sourceXml>connectorMessage.getRawData()</sourceXml> | ||
| <resultVariable>xsltResult</resultVariable> | ||
| <template>'<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"><xsl:output method="text"/><xsl:template match="/"><xsl:value-of select="."/></xsl:template></xsl:stylesheet>'</template> | ||
| <useCustomFactory>false</useCustomFactory> | ||
| <customFactory></customFactory> | ||
| </com.mirth.connect.plugins.xsltstep.XsltStep> | ||
| </elements> | ||
| <inboundDataType>RAW</inboundDataType> | ||
| <outboundDataType>RAW</outboundDataType> | ||
| <inboundProperties class="com.mirth.connect.plugins.datatypes.raw.RawDataTypeProperties" version="4.6.0"> | ||
| <batchProperties class="com.mirth.connect.plugins.datatypes.raw.RawBatchProperties" version="4.6.0"> | ||
| <splitType>JavaScript</splitType> | ||
| <batchScript></batchScript> | ||
| </batchProperties> | ||
| </inboundProperties> | ||
| <outboundProperties class="com.mirth.connect.plugins.datatypes.raw.RawDataTypeProperties" version="4.6.0"> | ||
| <batchProperties class="com.mirth.connect.plugins.datatypes.raw.RawBatchProperties" version="4.6.0"> | ||
| <splitType>JavaScript</splitType> | ||
| <batchScript></batchScript> | ||
| </batchProperties> | ||
| </outboundProperties> | ||
| </transformer> | ||
| <filter version="4.6.0"> | ||
| <elements/> | ||
| </filter> | ||
| <transportName>Channel Reader</transportName> | ||
| <mode>SOURCE</mode> | ||
| <enabled>true</enabled> | ||
| <waitForPrevious>true</waitForPrevious> | ||
| </sourceConnector> | ||
| <destinationConnectors> | ||
| <connector version="4.6.0"> | ||
| <metaDataId>1</metaDataId> | ||
| <name>Destination 1</name> | ||
| <properties class="com.mirth.connect.connectors.vm.VmDispatcherProperties" version="4.6.0"> | ||
| <pluginProperties/> | ||
| <destinationConnectorProperties version="4.6.0"> | ||
| <queueEnabled>false</queueEnabled> | ||
| <sendFirst>false</sendFirst> | ||
| <retryIntervalMillis>10000</retryIntervalMillis> | ||
| <regenerateTemplate>false</regenerateTemplate> | ||
| <retryCount>0</retryCount> | ||
| <rotate>false</rotate> | ||
| <includeFilterTransformer>false</includeFilterTransformer> | ||
| <threadCount>1</threadCount> | ||
| <threadAssignmentVariable></threadAssignmentVariable> | ||
| <validateResponse>false</validateResponse> | ||
| <resourceIds class="linked-hash-map"> | ||
| <entry> | ||
| <string>Default Resource</string> | ||
| <string>[Default Resource]</string> | ||
| </entry> | ||
| </resourceIds> | ||
| <queueBufferSize>1000</queueBufferSize> | ||
| <reattachAttachments>true</reattachAttachments> | ||
| </destinationConnectorProperties> | ||
| <channelId>none</channelId> | ||
| <channelTemplate>${message.encodedData}</channelTemplate> | ||
| <mapVariables/> | ||
| </properties> | ||
| <transformer version="4.6.0"> | ||
| <elements/> | ||
| <inboundDataType>RAW</inboundDataType> | ||
| <outboundDataType>RAW</outboundDataType> | ||
| <inboundProperties class="com.mirth.connect.plugins.datatypes.raw.RawDataTypeProperties" version="4.6.0"> | ||
| <batchProperties class="com.mirth.connect.plugins.datatypes.raw.RawBatchProperties" version="4.6.0"> | ||
| <splitType>JavaScript</splitType> | ||
| <batchScript></batchScript> | ||
| </batchProperties> | ||
| </inboundProperties> | ||
| <outboundProperties class="com.mirth.connect.plugins.datatypes.raw.RawDataTypeProperties" version="4.6.0"> | ||
| <batchProperties class="com.mirth.connect.plugins.datatypes.raw.RawBatchProperties" version="4.6.0"> | ||
| <splitType>JavaScript</splitType> | ||
| <batchScript></batchScript> | ||
| </batchProperties> | ||
| </outboundProperties> | ||
| </transformer> | ||
| <responseTransformer version="4.6.0"> | ||
| <elements/> | ||
| <inboundDataType>RAW</inboundDataType> | ||
| <outboundDataType>RAW</outboundDataType> | ||
| <inboundProperties class="com.mirth.connect.plugins.datatypes.raw.RawDataTypeProperties" version="4.6.0"> | ||
| <batchProperties class="com.mirth.connect.plugins.datatypes.raw.RawBatchProperties" version="4.6.0"> | ||
| <splitType>JavaScript</splitType> | ||
| <batchScript></batchScript> | ||
| </batchProperties> | ||
| </inboundProperties> | ||
| <outboundProperties class="com.mirth.connect.plugins.datatypes.raw.RawDataTypeProperties" version="4.6.0"> | ||
| <batchProperties class="com.mirth.connect.plugins.datatypes.raw.RawBatchProperties" version="4.6.0"> | ||
| <splitType>JavaScript</splitType> | ||
| <batchScript></batchScript> | ||
| </batchProperties> | ||
| </outboundProperties> | ||
| </responseTransformer> | ||
| <filter version="4.6.0"> | ||
| <elements/> | ||
| </filter> | ||
| <transportName>Channel Writer</transportName> | ||
| <mode>DESTINATION</mode> | ||
| <enabled>true</enabled> | ||
| <waitForPrevious>true</waitForPrevious> | ||
| </connector> | ||
| </destinationConnectors> | ||
| <preprocessingScript>// Modify the message variable below to pre process data | ||
| return message;</preprocessingScript> | ||
| <postprocessingScript>// This script executes once after a message has been processed | ||
| // Responses returned from here will be stored as "Postprocessor" in the response map | ||
| return;</postprocessingScript> | ||
| <deployScript>// This script executes once when the channel is deployed | ||
| // You only have access to the globalMap and globalChannelMap here to persist data | ||
| return;</deployScript> | ||
| <undeployScript>// This script executes once when the channel is undeployed | ||
| // You only have access to the globalMap and globalChannelMap here to persist data | ||
| return;</undeployScript> | ||
| <properties version="4.6.0"> | ||
| <clearGlobalChannelMap>true</clearGlobalChannelMap> | ||
| <messageStorageMode>DEVELOPMENT</messageStorageMode> | ||
| <encryptData>false</encryptData> | ||
| <encryptAttachments>false</encryptAttachments> | ||
| <encryptCustomMetaData>false</encryptCustomMetaData> | ||
| <removeContentOnCompletion>false</removeContentOnCompletion> | ||
| <removeOnlyFilteredOnCompletion>false</removeOnlyFilteredOnCompletion> | ||
| <removeAttachmentsOnCompletion>false</removeAttachmentsOnCompletion> | ||
| <initialState>STARTED</initialState> | ||
| <storeAttachments>true</storeAttachments> | ||
| <metaDataColumns> | ||
| <metaDataColumn> | ||
| <name>SOURCE</name> | ||
| <type>STRING</type> | ||
| <mappingName>mirth_source</mappingName> | ||
| </metaDataColumn> | ||
| <metaDataColumn> | ||
| <name>TYPE</name> | ||
| <type>STRING</type> | ||
| <mappingName>mirth_type</mappingName> | ||
| </metaDataColumn> | ||
| </metaDataColumns> | ||
| <attachmentProperties version="4.6.0"> | ||
| <type>None</type> | ||
| <properties/> | ||
| </attachmentProperties> | ||
| <resourceIds class="linked-hash-map"> | ||
| <entry> | ||
| <string>Default Resource</string> | ||
| <string>[Default Resource]</string> | ||
| </entry> | ||
| </resourceIds> | ||
| </properties> | ||
| <exportData> | ||
| <metadata> | ||
| <enabled>true</enabled> | ||
| <lastModified> | ||
| <time>1775955953073</time> | ||
| <timezone>America/Chicago</timezone> | ||
| </lastModified> | ||
| <pruningSettings> | ||
| <archiveEnabled>true</archiveEnabled> | ||
| <pruneErroredMessages>false</pruneErroredMessages> | ||
| </pruningSettings> | ||
| <userId>1</userId> | ||
| </metadata> | ||
| </exportData> | ||
| </channel> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <?xml version="1.0"?><!DOCTYPE r [<!ENTITY x SYSTEM "file:///etc/hostname">]><r>&x;</r> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ERROR |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| <?xml version="1.0"?><r>hello</r> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| TRANSFORMED |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -33,6 +33,7 @@ | |
| 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.ContextFactoryController; | ||
| import com.mirth.connect.server.controllers.ControllerFactory; | ||
|
|
@@ -52,6 +53,13 @@ public DatabaseConnectorServlet(@Context HttpServletRequest request, @Context Se | |
|
|
||
| @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) { | ||
| // selectLimit is a driver-specific, server-owned metadata-probe template, not a caller input. | ||
| // The caller-supplied value is ignored and the template is resolved from the built-in driver | ||
| // list keyed by the JDBC driver class; anything unrecognised uses the safe | ||
| // DatabaseMetaData.getColumns() path. This removes the SQL-injection surface (CVE-2026-82583) | ||
| // without depending on any API-writable configuration. | ||
| String resolvedSelectLimit = resolveSelectLimit(driver); | ||
|
|
||
| CustomDriver customDriver = null; | ||
| Connection connection = null; | ||
| try { | ||
|
|
@@ -150,22 +158,24 @@ public SortedSet<Table> getTables(String channelId, String channelName, String d | |
| // then we'll define to the generic method of getting column information, but | ||
| // this could be extremely slow | ||
| List<Column> columnList = new ArrayList<Column>(); | ||
| if (StringUtils.isEmpty(selectLimit)) { | ||
| if (StringUtils.isEmpty(resolvedSelectLimit)) { | ||
| logger.debug("No select limit is defined, using generic method"); | ||
| 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); | ||
|
|
||
| // retrieve all relevant column information | ||
| for (int i = 0; rs.next(); i++) { | ||
| Column column = new Column(rs.getString("COLUMN_NAME"), rs.getString("TYPE_NAME"), rs.getInt("COLUMN_SIZE")); | ||
| columnList.add(column); | ||
| } | ||
| } else { | ||
| logger.debug("Select limit is defined, using specific select query : '" + selectLimit + "'"); | ||
| logger.debug("Select limit is defined, using specific select query : '" + resolvedSelectLimit + "'"); | ||
|
|
||
| // replace the '?' with the appropriate schema.table name, and use ResultSetMetaData to | ||
| // retrieve column information | ||
| final String schemaTableName = StringUtils.isNotEmpty(schema) ? "\"" + schema + "\".\"" + tableName + "\"" : "\"" + tableName + "\""; | ||
| final String queryString = selectLimit.trim().replaceAll("\\?", Matcher.quoteReplacement(schemaTableName)); | ||
| // replace the '?' with the appropriate schema.table name, and use ResultSetMetaData to | ||
| // retrieve column information | ||
| final String schemaTableName = quoteSchemaTable(schema, tableName); | ||
| final String queryString = resolvedSelectLimit.trim().replaceAll("\\?", Matcher.quoteReplacement(schemaTableName)); | ||
| Statement statement = connection.createStatement(); | ||
| try { | ||
| rs = statement.executeQuery(queryString); | ||
|
|
@@ -192,7 +202,7 @@ public SortedSet<Table> getTables(String channelId, String channelName, String d | |
| columnList = new ArrayList<Column>(); | ||
|
|
||
| logger.debug("Using fallback method for retrieving columns"); | ||
| backupRs = dbMetaData.getColumns(null, null, tableName.replace("/", "//"), null); | ||
| backupRs = dbMetaData.getColumns(null, schema, tableName.replace("/", "//"), null); | ||
|
|
||
| // retrieve all relevant column information | ||
| while (backupRs.next()) { | ||
|
|
@@ -229,6 +239,50 @@ public SortedSet<Table> getTables(String channelId, String channelName, String d | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the driver-specific metadata-probe query from the built-in driver definitions, keyed | ||
| * by the JDBC driver class (including known alternative class names). Returns {@code ""} - the | ||
| * safe {@link DatabaseMetaData#getColumns} path - for anything not built in. | ||
| * <p> | ||
| * The caller-supplied {@code selectLimit} query parameter is deliberately ignored: it is executed | ||
| * as SQL (CVE-2026-82583), and the metadata dialog only ever sends the driver's own template | ||
| * anyway. This method consults only {@link DriverInfo#getDefaultDrivers()}, never the | ||
| * API-writable configured driver list, so a caller cannot introduce an arbitrary query even by | ||
| * first writing it to the driver configuration. Package-private and static so it is unit-testable | ||
| * without a servlet instance or a live server. | ||
| */ | ||
| static String resolveSelectLimit(String driver) { | ||
| if (StringUtils.isBlank(driver)) { | ||
| return ""; | ||
| } | ||
|
|
||
| for (DriverInfo driverInfo : DriverInfo.getDefaultDrivers()) { | ||
| if (driver.equals(driverInfo.getClassName()) | ||
| || (driverInfo.getAlternativeClassNames() != null && driverInfo.getAlternativeClassNames().contains(driver))) { | ||
| return StringUtils.defaultString(driverInfo.getSelectLimit()); | ||
| } | ||
| } | ||
|
|
||
| return ""; | ||
| } | ||
|
|
||
| /** | ||
| * Builds the {@code "schema"."table"} (or {@code "table"}) identifier that replaces the {@code ?} | ||
| * placeholder in the metadata-probe query. The schema and table names come from the database's own | ||
| * metadata ({@link DatabaseMetaData#getSchemas}/{@link DatabaseMetaData#getTables}), but a name may | ||
| * still contain a double quote; embedding it verbatim would break out of the quoting and alter the | ||
| * query (a second-order SQL injection). Following the SQL standard, any embedded {@code "} is | ||
| * 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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nonblocking follow-up:
This portability limitation predates the current PR and does not reopen CVE-2026-82583. A follow-up should use |
||
| String quotedTable = "\"" + StringUtils.defaultString(tableName).replace("\"", "\"\"") + "\""; | ||
| if (StringUtils.isNotEmpty(schema)) { | ||
| return "\"" + schema.replace("\"", "\"\"") + "\"." + quotedTable; | ||
| } | ||
| return quotedTable; | ||
| } | ||
|
|
||
| /** | ||
| * 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 (,) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
schemaremains null whenever the login name does not match the active schema. In that case, bothgetTables(null, null, ...)and thisgetColumns(null, null, tableName, ...)search every visible schema/catalog. Because onlyTABLE_NAMEis 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, andTABLE_NAMEand pass that exact tuple togetColumns(), 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.