Skip to content

Prevent SQL injection in message metadata column search#361

Open
pacmano1 wants to merge 1 commit into
OpenIntegrationEngine:mainfrom
pacmano1:fix/sql-injection-metadata-column-search
Open

Prevent SQL injection in message metadata column search#361
pacmano1 wants to merge 1 commit into
OpenIntegrationEngine:mainfrom
pacmano1:fix/sql-injection-metadata-column-search

Conversation

@pacmano1

Copy link
Copy Markdown
Contributor

Problem

Custom metadata column names in message search are interpolated into SQL as identifiers (MyBatis ${}) across all database dialects — ${element.columnName} (metaDataSearch) and ${column} (textSearchMetaDataColumns) — and arrive from the REST parameters with no validation, so an authenticated MESSAGES_VIEW user can inject arbitrary SQL. The search value (#{...}) and operator (enum-constrained) were already safe; only the column name is affected. Ref GHSA-mf46-33w3-84j6.

Fix

Validate every referenced metadata column name against the channel's own defined columns before the filter reaches the data layer:

  • MetaDataColumnValidator (new, server/util): pure check, lazy channel-column lookup, returns the first unknown column name (or null).
  • MessageServlet: primary gate at the REST boundary; an unknown column returns 400. Applied to all message-search entry points, in both the parameter and MessageFilter-body forms (getMessages, getMessageCount, reprocessMessages, removeMessages, exportMessagesServer).
  • DonkeyMessageController: defense-in-depth backstop for callers that reach the controller directly (e.g. plugins via MessageController.getInstance()), throwing on an unknown column.

Column names are matched exactly against the channel's (upper-cased) defined columns. No SQL/mapper changes; the value and operator are untouched.

Tests

  • MetaDataColumnValidatorTest — unit tests the check: unknown/known/non-uppercase/null column, and that the channel lookup is skipped when the filter references no custom column.
  • MessageServletTest — REST-boundary reject/allow paths.
  • Full clean :server build green (unit tests included).

Verifying

Verified against a running server (Derby) with a channel that has a custom metadata column:

  • metaDataSearch=<definedColumn> = foo200 (search runs normally).
  • metaDataSearch=<undefinedColumn> = foo400 (rejected before reaching SQL).

…tion

The message search mappers interpolate client-supplied custom metadata
column names into SQL as identifiers (MyBatis ${} substitution) in every
dialect, via the metaDataSearch and textSearchMetaDataColumns filters,
allowing SQL injection by an authenticated user. Validate each referenced
column name against the channel's own defined columns before the filter
reaches the data layer, rejecting unknown names. Enforced at the REST
boundary (MessageServlet, 400) and backstopped in DonkeyMessageController
for callers that reach it directly. The search value and operator were
already bound/enum-constrained.

Signed-off-by: Finnegan's Owner <44065187+pacmano1@users.noreply.github.com>
@pacmano1
pacmano1 force-pushed the fix/sql-injection-metadata-column-search branch from 43f5aaf to fe47f02 Compare July 23, 2026 21:52
@github-actions

Copy link
Copy Markdown

Test Results

668 tests  +14   668 ✅ +14   3m 37s ⏱️ +54s
109 suites + 1     0 💤 ± 0 
109 files   + 1     0 ❌ ± 0 

Results for commit fe47f02. ± Comparison against base commit a08c114.

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.

Pull request overview

This PR closes a SQL injection vector in message search by validating custom metadata column identifiers (previously interpolated into SQL via MyBatis ${}) against the channel’s defined metadata columns before the filter reaches the data layer.

Changes:

  • Added MetaDataColumnValidator utility to detect unknown metadata column references in MessageFilter.
  • Enforced validation at the REST boundary (MessageServlet) and added a defense-in-depth backstop in DonkeyMessageController.
  • Added unit tests covering validator behavior and REST rejection/allow paths.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
server/src/main/java/com/mirth/connect/server/util/MetaDataColumnValidator.java New utility to detect unknown custom metadata columns referenced by message search filters.
server/src/main/java/com/mirth/connect/server/api/servlets/MessageServlet.java Validates filter-referenced metadata columns at API entry points; rejects unknown columns with 400.
server/src/main/java/com/mirth/connect/server/controllers/DonkeyMessageController.java Adds controller-level validation as a backstop for non-REST callers.
server/src/test/java/com/mirth/connect/server/util/MetaDataColumnValidatorTest.java Unit tests for validator behavior and lazy lookup behavior.
server/src/test/java/com/mirth/connect/server/api/servlets/MessageServletTest.java Tests that REST message count rejects/accepts based on metadata column validity.
Comments suppressed due to low confidence (1)

server/src/main/java/com/mirth/connect/server/util/MetaDataColumnValidator.java:69

  • MetaDataColumnValidator can throw a NullPointerException if filter.getMetaDataSearch() contains a null element (e.g., a client sends a JSON array with a null entry). That would turn a bad request into a 500 and could be used for DoS; treat a null element the same as an unknown column and return "null" instead.
        if (hasMetaDataSearch) {
            for (MetaDataSearchElement element : filter.getMetaDataSearch()) {
                if (element.getColumnName() == null || !allowedColumns.contains(element.getColumnName())) {
                    return String.valueOf(element.getColumnName());
                }
            }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +421 to +425
String unknownColumn = MetaDataColumnValidator.findUnknownColumn(filter, () -> channelController.getMetaDataColumns(channelId));
if (unknownColumn != null) {
logger.warn("Rejected message search for channel " + channelId + " referencing unknown metadata column: " + unknownColumn);
throw new MirthApiException(Response.Status.BAD_REQUEST);
}

@mgaffigan mgaffigan Jul 24, 2026

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.

Replacing newlines is playing whack-a-mole, but this should be fixed to structured logging ("{}" in the log message format).

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

It seems fine. See below for notes if you feel like making edits.

Comment on lines +416 to +425
// Primary check at the REST boundary: reject any custom metadata column the filter references
// that is not defined on the channel. Column names are matched exactly against the channel's
// (upper-cased) columns, which come from the in-memory channel cache (no database hit) and are
// only looked up when the filter actually references a custom column. Callers that reach
// DonkeyMessageController directly (e.g. a plugin) are covered by a last-resort backstop there.
String unknownColumn = MetaDataColumnValidator.findUnknownColumn(filter, () -> channelController.getMetaDataColumns(channelId));
if (unknownColumn != null) {
logger.warn("Rejected message search for channel " + channelId + " referencing unknown metadata column: " + unknownColumn);
throw new MirthApiException(Response.Status.BAD_REQUEST);
}

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.

Do we need to do this twice? Seems extra and easy to have bugs at one of the two layers. I suspect the goal was a 400 instead of a 500 - a typed exception might be a better route for that.


@Override
public void removeMessages(String channelId, MessageFilter filter) {
validateMetaDataColumns(channelId, filter);

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.

We're having to "remember" to call this validator several times. Is there an alternative that's harder to accidentally omit? Seems like all entrypoints eventually lead to searchAll

Comment on lines +421 to +425
String unknownColumn = MetaDataColumnValidator.findUnknownColumn(filter, () -> channelController.getMetaDataColumns(channelId));
if (unknownColumn != null) {
logger.warn("Rejected message search for channel " + channelId + " referencing unknown metadata column: " + unknownColumn);
throw new MirthApiException(Response.Status.BAD_REQUEST);
}

@mgaffigan mgaffigan Jul 24, 2026

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.

Replacing newlines is playing whack-a-mole, but this should be fixed to structured logging ("{}" in the log message format).

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.

3 participants