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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -387,7 +393,7 @@ private void sendServerEventWithAttributes(Map<String, String> 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<Status> statuses, Set<Integer> includedMetaDataIds, Set<Integer> excludedMetaDataIds, String serverId, Set<String> rawContentSearches, Set<String> processedRawContentSearches, Set<String> transformedContentSearches, Set<String> encodedContentSearches, Set<String> sentContentSearches, Set<String> responseContentSearches, Set<String> responseTransformedContentSearches, Set<String> processedResponseContentSearches, Set<String> connectorMapContentSearches, Set<String> channelMapContentSearches, Set<String> sourceMapContentSearches, Set<String> responseMapContentSearches, Set<String> processingErrorContentSearches, Set<String> postprocessorErrorContentSearches, Set<String> responseErrorContentSearches, Set<MetaDataSearch> metaDataSearches, Set<MetaDataSearch> metaDataCaseInsensitiveSearches, Set<String> textSearchMetaDataColumns, Integer minSendAttempts, Integer maxSendAttempts, Boolean attachment, Boolean error) {
MessageFilter filter = new MessageFilter();
filter.setMinMessageId(minMessageId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -676,6 +679,24 @@ private List<MessageSearchResult> 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<String> unknownColumn = MetaDataColumnValidator.findUnknownColumn(filter, () -> ControllerFactory.getFactory().createChannelController().getMetaDataColumns(channelId));
if (unknownColumn.isPresent()) {
throw new MetaDataColumnException(channelId, unknownColumn.get());
}
}

private Map<Long, MessageSearchResult> searchAll(SqlSession session, Map<String, Object> params, MessageFilter filter, Long localChannelId, boolean includeMessageData, FilterOptions filterOptions) {
Map<Long, MessageSearchResult> foundMessages = new HashMap<Long, MessageSearchResult>();

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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.
* </p>
*
* <p>
* 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.
* </p>
*/
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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* 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.
* </p>
*/
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"}.
*
* <p>
* 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.
* </p>
*
* @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<String> findUnknownColumn(MessageFilter filter, Supplier<List<MetaDataColumn>> 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<MetaDataColumn> definedColumns = definedColumnsSupplier.get();
Set<String> allowedColumns = new HashSet<String>();
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();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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<MetaDataColumn> definedColumns(String... names) {
List<MetaDataColumn> columns = new ArrayList<MetaDataColumn>();
for (String name : names) {
columns.add(new MetaDataColumn(name, MetaDataColumnType.STRING, null));
}
return columns;
}

private static Supplier<List<MetaDataColumn>> supplier(List<MetaDataColumn> 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<String>(Arrays.asList("STATUS")));

assertEquals(Optional.empty(), MetaDataColumnValidator.findUnknownColumn(filter, () -> definedColumns("STATUS")));
}

@Test
public void unknownTextSearchColumnIsReturned() {
MessageFilter filter = new MessageFilter();
filter.setTextSearchMetaDataColumns(new ArrayList<String>(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<MetaDataSearchElement> elements = new ArrayList<MetaDataSearchElement>();
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<MetaDataColumn> columns = new ArrayList<MetaDataColumn>();
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));
}
}
Loading