diff --git a/server/src/main/java/com/mirth/connect/server/api/servlets/MessageServlet.java b/server/src/main/java/com/mirth/connect/server/api/servlets/MessageServlet.java index cc98bbe0a9..9cecece5e9 100644 --- a/server/src/main/java/com/mirth/connect/server/api/servlets/MessageServlet.java +++ b/server/src/main/java/com/mirth/connect/server/api/servlets/MessageServlet.java @@ -62,6 +62,7 @@ import com.mirth.connect.server.controllers.EventController; import com.mirth.connect.server.controllers.MessageController; import com.mirth.connect.server.util.DICOMMessageUtil; +import com.mirth.connect.server.util.MetaDataColumnException; import com.mirth.connect.util.MessageImporter.MessageImportException; import com.mirth.connect.util.messagewriter.EncryptionType; import com.mirth.connect.util.messagewriter.MessageWriterOptions; @@ -222,6 +223,11 @@ private void doReprocessMessages(final String channelId, final MessageFilter fil public void run() { try { messageController.reprocessMessages(channelId, filter, replace, metaDataIds); + } catch (MetaDataColumnException e) { + // Reprocess is asynchronous, so the client already received its response; an + // undefined metadata column is rejected here (the run is aborted, nothing is + // reprocessed) and logged rather than surfaced as a response status. + logger.warn("Rejected reprocess for channel {} referencing unknown metadata column: {}", e.getChannelId(), e.getColumnName()); } catch (ControllerException e) { logger.error("Error reprocessing messages for channel " + channelId + ": " + e.getMessage(), e); } @@ -387,7 +393,7 @@ private void sendServerEventWithAttributes(Map attributes) { eventController.dispatchEvent(event); } - + private MessageFilter getMessageFilter(Long minMessageId, Long maxMessageId, Long minOriginalId, Long maxOriginalId, Long minImportId, Long maxImportId, Calendar startDate, Calendar endDate, String textSearch, Boolean textSearchRegex, Set statuses, Set includedMetaDataIds, Set excludedMetaDataIds, String serverId, Set rawContentSearches, Set processedRawContentSearches, Set transformedContentSearches, Set encodedContentSearches, Set sentContentSearches, Set responseContentSearches, Set responseTransformedContentSearches, Set processedResponseContentSearches, Set connectorMapContentSearches, Set channelMapContentSearches, Set sourceMapContentSearches, Set responseMapContentSearches, Set processingErrorContentSearches, Set postprocessorErrorContentSearches, Set responseErrorContentSearches, Set metaDataSearches, Set metaDataCaseInsensitiveSearches, Set textSearchMetaDataColumns, Integer minSendAttempts, Integer maxSendAttempts, Boolean attachment, Boolean error) { MessageFilter filter = new MessageFilter(); filter.setMinMessageId(minMessageId); diff --git a/server/src/main/java/com/mirth/connect/server/controllers/DonkeyMessageController.java b/server/src/main/java/com/mirth/connect/server/controllers/DonkeyMessageController.java index 9a4366c844..ab9f9404ed 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/DonkeyMessageController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/DonkeyMessageController.java @@ -20,6 +20,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.NavigableMap; +import java.util.Optional; import java.util.Set; import java.util.TreeMap; import java.util.TreeSet; @@ -63,6 +64,8 @@ import com.mirth.connect.server.util.DICOMMessageUtil; import com.mirth.connect.server.util.ListRangeIterator; import com.mirth.connect.server.util.ListRangeIterator.ListRangeItem; +import com.mirth.connect.server.util.MetaDataColumnException; +import com.mirth.connect.server.util.MetaDataColumnValidator; import com.mirth.connect.server.util.SqlConfig; import com.mirth.connect.util.AttachmentUtil; import com.mirth.connect.util.MessageEncryptionUtil; @@ -676,6 +679,24 @@ private List searchMessages(MessageFilter filter, String ch } } + /** + * Validation gate against SQL injection through custom metadata column names, which the search + * mappers interpolate into SQL as identifiers (MyBatis ${} substitution). It is called from the + * FilterOptions constructor, which every message search path (get, count, remove, reprocess; + * export runs through get) builds exactly once, before its batch loop. That is the single choke + * point through which a search must pass, so a new search entry point cannot bypass the check. + * Throws MetaDataColumnException, a plain domain exception left to bubble up: on the synchronous + * API paths the engine turns it into a 500 like any other uncaught controller exception, and the + * asynchronous reprocess path catches it to log and abort the run. The injection is blocked + * regardless of the resulting status, because the throw happens before any query runs. + */ + private void validateMetaDataColumns(String channelId, MessageFilter filter) { + Optional unknownColumn = MetaDataColumnValidator.findUnknownColumn(filter, () -> ControllerFactory.getFactory().createChannelController().getMetaDataColumns(channelId)); + if (unknownColumn.isPresent()) { + throw new MetaDataColumnException(channelId, unknownColumn.get()); + } + } + private Map searchAll(SqlSession session, Map params, MessageFilter filter, Long localChannelId, boolean includeMessageData, FilterOptions filterOptions) { Map foundMessages = new HashMap(); @@ -1148,6 +1169,8 @@ private class FilterOptions { private boolean searchText; public FilterOptions(MessageFilter filter, String channelId, boolean readOnly) { + validateMetaDataColumns(channelId, filter); + if (filter.getMinMessageId() != null && filter.getMaxMessageId() != null && filter.getMinMessageId() > filter.getMaxMessageId()) { /* * If the min message id is greater than the max, use them directly so they fail at diff --git a/server/src/main/java/com/mirth/connect/server/util/MetaDataColumnException.java b/server/src/main/java/com/mirth/connect/server/util/MetaDataColumnException.java new file mode 100644 index 0000000000..b92a064e4d --- /dev/null +++ b/server/src/main/java/com/mirth/connect/server/util/MetaDataColumnException.java @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Open Integration Engine + +package com.mirth.connect.server.util; + +/** + * Thrown when a message search filter references a custom metadata column that is not defined on the + * channel. Those column names are interpolated into SQL as identifiers by the search mappers (MyBatis + * ${} substitution), so an undefined name must never reach the data layer. + * + *

+ * This is a plain domain exception. It is thrown from the single validation gate in + * DonkeyMessageController - the FilterOptions constructor, which every message search path builds + * before its batch loop - and is left to bubble up. On the synchronous API paths the engine's + * standard handling turns any uncaught controller exception into a 500, the same way every other + * controller-layer rejection surfaces; deliberately not mapped to a 400, because the engine has no + * central exception-to-status convention and coupling this data-layer type to an HTTP status would be + * the only such case in the codebase. The injection is blocked either way, since the throw happens + * before any query runs. The reprocess path is asynchronous and catches this to log and abort the run. + *

+ * + *

+ * The offending channel and column are kept as fields and the message is deliberately generic, so + * callers log the values with parameterized logging rather than concatenating untrusted input into a + * message string. + *

+ */ +public class MetaDataColumnException extends RuntimeException { + + private final String channelId; + private final String columnName; + + public MetaDataColumnException(String channelId, String columnName) { + super("Message search referenced an undefined metadata column"); + this.channelId = channelId; + this.columnName = columnName; + } + + public String getChannelId() { + return channelId; + } + + public String getColumnName() { + return columnName; + } +} diff --git a/server/src/main/java/com/mirth/connect/server/util/MetaDataColumnValidator.java b/server/src/main/java/com/mirth/connect/server/util/MetaDataColumnValidator.java new file mode 100644 index 0000000000..80643b28a0 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/server/util/MetaDataColumnValidator.java @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Open Integration Engine + +package com.mirth.connect.server.util; + +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; + +import org.apache.commons.collections4.CollectionUtils; + +import com.mirth.connect.donkey.model.channel.MetaDataColumn; +import com.mirth.connect.model.filters.MessageFilter; +import com.mirth.connect.model.filters.elements.MetaDataSearchElement; + +/** + * Validates the custom metadata column names referenced by a message search filter against the + * columns actually defined on a channel. The message search mappers interpolate these names into SQL + * as identifiers (MyBatis ${} substitution), so an unvalidated name is a SQL injection vector. + * + *

+ * This is a pure check: it never throws and never looks anything up. Callers pass in the channel's + * defined columns and decide what to do with an unknown column - the controller rejects the search + * by throwing a MetaDataColumnException before any query runs. + *

+ */ +public final class MetaDataColumnValidator { + + private MetaDataColumnValidator() {} + + /** + * Finds the first metadata column name referenced by the filter that is not defined on the + * channel. Column names are matched exactly against the channel's (upper-cased) column names; a + * {@code null} referenced name is treated as unknown and reported as the string {@code "null"}. + * + *

+ * The defined columns are supplied lazily and are only requested when the filter actually + * references a custom column, so a search that uses none costs no channel lookup. + *

+ * + * @param filter the message search filter to check; may be {@code null}, which validates + * trivially + * @param definedColumnsSupplier supplies the channel's defined metadata columns; only invoked + * when the filter references a custom column, and may return {@code null} for a + * channel with no columns + * @return the first unknown column name referenced by the filter, or {@link Optional#empty()} + * if every referenced column is defined on the channel + */ + public static Optional findUnknownColumn(MessageFilter filter, Supplier> definedColumnsSupplier) { + if (filter == null) { + return Optional.empty(); + } + + boolean hasMetaDataSearch = CollectionUtils.isNotEmpty(filter.getMetaDataSearch()); + boolean hasTextSearchColumns = CollectionUtils.isNotEmpty(filter.getTextSearchMetaDataColumns()); + if (!hasMetaDataSearch && !hasTextSearchColumns) { + return Optional.empty(); + } + + List definedColumns = definedColumnsSupplier.get(); + Set allowedColumns = new HashSet(); + if (definedColumns != null) { + for (MetaDataColumn column : definedColumns) { + if (column != null && column.getName() != null) { + allowedColumns.add(column.getName()); + } + } + } + + if (hasMetaDataSearch) { + for (MetaDataSearchElement element : filter.getMetaDataSearch()) { + if (element == null) { + return Optional.of("null"); + } + if (element.getColumnName() == null || !allowedColumns.contains(element.getColumnName())) { + return Optional.of(String.valueOf(element.getColumnName())); + } + } + } + + if (hasTextSearchColumns) { + for (String columnName : filter.getTextSearchMetaDataColumns()) { + if (columnName == null || !allowedColumns.contains(columnName)) { + return Optional.of(String.valueOf(columnName)); + } + } + } + + return Optional.empty(); + } +} diff --git a/server/src/test/java/com/mirth/connect/server/util/MetaDataColumnExceptionTest.java b/server/src/test/java/com/mirth/connect/server/util/MetaDataColumnExceptionTest.java new file mode 100644 index 0000000000..d1c933cf10 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/util/MetaDataColumnExceptionTest.java @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Open Integration Engine + +package com.mirth.connect.server.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class MetaDataColumnExceptionTest { + + /** + * A plain domain exception (bubbles up to the engine's standard 500 on the synchronous API + * paths). It must not carry the untrusted column name in its message; that goes in a field so + * the reprocess path can log it with parameterized logging. + */ + @Test + public void isPlainRuntimeExceptionWithGenericMessage() { + MetaDataColumnException e = new MetaDataColumnException("channelId", "EVIL\" OR '1'='1"); + assertTrue(e instanceof RuntimeException); + assertEquals("Message search referenced an undefined metadata column", e.getMessage()); + } + + @Test + public void retainsChannelAndColumn() { + MetaDataColumnException e = new MetaDataColumnException("channelId", "EVIL"); + assertEquals("channelId", e.getChannelId()); + assertEquals("EVIL", e.getColumnName()); + } +} diff --git a/server/src/test/java/com/mirth/connect/server/util/MetaDataColumnValidatorTest.java b/server/src/test/java/com/mirth/connect/server/util/MetaDataColumnValidatorTest.java new file mode 100644 index 0000000000..97cb745db7 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/server/util/MetaDataColumnValidatorTest.java @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: Open Integration Engine + +package com.mirth.connect.server.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +import org.junit.Test; + +import com.mirth.connect.donkey.model.channel.MetaDataColumn; +import com.mirth.connect.donkey.model.channel.MetaDataColumnType; +import com.mirth.connect.model.filters.MessageFilter; +import com.mirth.connect.model.filters.elements.MetaDataSearchElement; + +public class MetaDataColumnValidatorTest { + + private static List definedColumns(String... names) { + List columns = new ArrayList(); + for (String name : names) { + columns.add(new MetaDataColumn(name, MetaDataColumnType.STRING, null)); + } + return columns; + } + + private static Supplier> supplier(List columns, AtomicBoolean invoked) { + return () -> { + invoked.set(true); + return columns; + }; + } + + @Test + public void nullFilterReturnsNull() { + assertEquals(Optional.empty(), MetaDataColumnValidator.findUnknownColumn(null, () -> definedColumns("STATUS"))); + } + + @Test + public void noReferencedColumnsReturnsNullAndSkipsLookup() { + AtomicBoolean invoked = new AtomicBoolean(false); + MessageFilter filter = new MessageFilter(); + + assertEquals(Optional.empty(), MetaDataColumnValidator.findUnknownColumn(filter, supplier(definedColumns("STATUS"), invoked))); + assertFalse("Channel columns must not be looked up when the filter references none", invoked.get()); + } + + @Test + public void definedMetaDataSearchColumnReturnsNull() { + AtomicBoolean invoked = new AtomicBoolean(false); + MessageFilter filter = new MessageFilter(); + filter.setMetaDataSearch(Arrays.asList(new MetaDataSearchElement("STATUS", "EQUAL", "x", false))); + + assertEquals(Optional.empty(), MetaDataColumnValidator.findUnknownColumn(filter, supplier(definedColumns("STATUS"), invoked))); + assertTrue("A referenced column must trigger the lookup", invoked.get()); + } + + @Test + public void unknownMetaDataSearchColumnIsReturned() { + MessageFilter filter = new MessageFilter(); + filter.setMetaDataSearch(Arrays.asList(new MetaDataSearchElement("EVIL\" OR '1'='1", "EQUAL", "x", false))); + + assertEquals(Optional.of("EVIL\" OR '1'='1"), MetaDataColumnValidator.findUnknownColumn(filter, () -> definedColumns("STATUS"))); + } + + @Test + public void nonUpperCaseColumnIsReturned() { + MessageFilter filter = new MessageFilter(); + filter.setMetaDataSearch(Arrays.asList(new MetaDataSearchElement("status", "EQUAL", "x", false))); + + assertEquals(Optional.of("status"), MetaDataColumnValidator.findUnknownColumn(filter, () -> definedColumns("STATUS"))); + } + + @Test + public void nullColumnNameIsReturnedAsNullString() { + MessageFilter filter = new MessageFilter(); + filter.setMetaDataSearch(Arrays.asList(new MetaDataSearchElement(null, "EQUAL", "x", false))); + + assertEquals(Optional.of("null"), MetaDataColumnValidator.findUnknownColumn(filter, () -> definedColumns("STATUS"))); + } + + @Test + public void definedTextSearchColumnReturnsNull() { + MessageFilter filter = new MessageFilter(); + filter.setTextSearchMetaDataColumns(new ArrayList(Arrays.asList("STATUS"))); + + assertEquals(Optional.empty(), MetaDataColumnValidator.findUnknownColumn(filter, () -> definedColumns("STATUS"))); + } + + @Test + public void unknownTextSearchColumnIsReturned() { + MessageFilter filter = new MessageFilter(); + filter.setTextSearchMetaDataColumns(new ArrayList(Arrays.asList("BOGUS"))); + + assertEquals(Optional.of("BOGUS"), MetaDataColumnValidator.findUnknownColumn(filter, () -> definedColumns("STATUS"))); + } + + @Test + public void channelWithNoColumnsRejectsAnyReferencedColumn() { + MessageFilter filter = new MessageFilter(); + filter.setMetaDataSearch(Arrays.asList(new MetaDataSearchElement("STATUS", "EQUAL", "x", false))); + + assertEquals(Optional.of("STATUS"), MetaDataColumnValidator.findUnknownColumn(filter, () -> null)); + } + + @Test + public void nullSearchElementIsRejectedNotThrown() { + MessageFilter filter = new MessageFilter(); + List elements = new ArrayList(); + elements.add(null); + filter.setMetaDataSearch(elements); + + // A null element in the list must be treated as unknown (returned), never an NPE. + assertEquals(Optional.of("null"), MetaDataColumnValidator.findUnknownColumn(filter, () -> definedColumns("STATUS"))); + } + + @Test + public void nullDefinedColumnEntryIsIgnoredNotThrown() { + MessageFilter filter = new MessageFilter(); + filter.setMetaDataSearch(Arrays.asList(new MetaDataSearchElement("STATUS", "EQUAL", "x", false))); + + List columns = new ArrayList(); + columns.add(null); + columns.add(new MetaDataColumn("STATUS", MetaDataColumnType.STRING, null)); + + // A null entry in the channel's columns must be skipped, not cause an NPE; STATUS still validates. + assertEquals(Optional.empty(), MetaDataColumnValidator.findUnknownColumn(filter, () -> columns)); + } +}