Skip to content

Commit 3cc59c7

Browse files
committed
Add certificate pinning to address MITM vulnerabilities
Signed-off-by: Mitch Gaffigan <mitch.gaffigan@comcast.net>
1 parent bfe3348 commit 3cc59c7

21 files changed

+745
-59
lines changed

client/src/com/mirth/connect/client/ui/CommandLineOptions.java

Lines changed: 36 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@
33

44
package com.mirth.connect.client.ui;
55

6+
import java.util.ArrayDeque;
7+
import java.util.Arrays;
8+
import java.util.Deque;
9+
610
import org.apache.commons.lang3.StringUtils;
711

812
/**
@@ -15,66 +19,56 @@ public class CommandLineOptions {
1519
private final String password;
1620
private final String protocols;
1721
private final String cipherSuites;
22+
private final String pinnedClientTrust;
1823

1924
/**
2025
* Parse command line arguments for Mirth client.
2126
*/
2227
public CommandLineOptions(String[] args) {
28+
if (args == null) {
29+
args = new String[0];
30+
}
31+
2332
String server = "https://localhost:8443";
2433
String version = "";
2534
String username = "";
2635
String password = "";
2736
String protocols = "";
2837
String cipherSuites = "";
38+
String pinnedClientTrust = "";
2939

30-
if (args == null) {
31-
args = new String[0];
32-
}
40+
Deque<String> remaining = new ArrayDeque<String>(Arrays.asList(args));
41+
int idx = 0;
42+
while (true) {
43+
String arg = remaining.pollFirst();
44+
if (arg == null) {
45+
break;
46+
}
3347

34-
if (args.length > 0) {
35-
server = args[0];
36-
}
37-
if (args.length > 1) {
38-
version = args[1];
39-
}
40-
if (args.length > 2) {
41-
if (StringUtils.equalsIgnoreCase(args[2], "-ssl")) {
42-
// <server> <version> -ssl [<protocols> [<ciphersuites> [<username> [<password>]]]]
43-
if (args.length > 3) {
44-
protocols = args[3];
45-
}
46-
if (args.length > 4) {
47-
cipherSuites = args[4];
48-
}
49-
if (args.length > 5) {
50-
username = args[5];
51-
}
52-
if (args.length > 6) {
53-
password = args[6];
54-
}
48+
if (StringUtils.equalsIgnoreCase(arg, "-ssl")) {
49+
protocols = StringUtils.defaultString(remaining.pollFirst());
50+
cipherSuites = StringUtils.defaultString(remaining.pollFirst());
51+
} else if (StringUtils.equalsIgnoreCase(arg, "-trust")) {
52+
pinnedClientTrust = StringUtils.defaultString(remaining.pollFirst());
5553
} else {
56-
// <server> <version> <username> [<password> [-ssl [<protocols> [<ciphersuites>]]]]
57-
username = args[2];
58-
if (args.length > 3) {
59-
password = args[3];
60-
}
61-
if (args.length > 4 && StringUtils.equalsIgnoreCase(args[4], "-ssl")) {
62-
if (args.length > 5) {
63-
protocols = args[5];
64-
}
65-
if (args.length > 6) {
66-
cipherSuites = args[6];
67-
}
54+
switch (idx) {
55+
case 0 -> server = arg;
56+
case 1 -> version = arg;
57+
case 2 -> username = arg;
58+
case 3 -> password = arg;
59+
default -> {} // Explicitly ignore extra arguments
6860
}
61+
idx++;
6962
}
7063
}
7164

7265
this.server = server;
7366
this.version = version;
7467
this.username = username;
7568
this.password = password;
76-
this.protocols = protocols;
77-
this.cipherSuites = cipherSuites;
69+
this.protocols = StringUtils.defaultString(protocols);
70+
this.cipherSuites = StringUtils.defaultString(cipherSuites);
71+
this.pinnedClientTrust = StringUtils.defaultString(pinnedClientTrust);
7872
}
7973

8074
public String getServer() {
@@ -100,4 +94,8 @@ public String getProtocols() {
10094
public String getCipherSuites() {
10195
return cipherSuites;
10296
}
97+
98+
public String getPinnedClientTrust() {
99+
return pinnedClientTrust;
100+
}
103101
}

client/src/com/mirth/connect/client/ui/LoginPanel.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ public Void doInBackground() {
423423

424424
try {
425425
String server = serverName.getText();
426-
client = new Client(server, PlatformUI.HTTPS_PROTOCOLS, PlatformUI.HTTPS_CIPHER_SUITES);
426+
client = new Client(server, PlatformUI.HTTPS_PROTOCOLS, PlatformUI.HTTPS_CIPHER_SUITES, PlatformUI.PINNED_CLIENT_TRUST);
427427
PlatformUI.SERVER_URL = server;
428428

429429
// Attempt to login

client/src/com/mirth/connect/client/ui/Mirth.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,7 @@ public static void main(String[] args) {
261261
if (StringUtils.isNotBlank(opts.getCipherSuites())) {
262262
PlatformUI.HTTPS_CIPHER_SUITES = StringUtils.split(opts.getCipherSuites(), ',');
263263
}
264+
PlatformUI.PINNED_CLIENT_TRUST = opts.getPinnedClientTrust();
264265
PlatformUI.SERVER_URL = opts.getServer();
265266
PlatformUI.CLIENT_VERSION = opts.getVersion();
266267

client/src/com/mirth/connect/client/ui/PlatformUI.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public class PlatformUI {
3333
public static String SERVER_DATABASE;
3434
public static String USER_NAME;
3535
public static String CLIENT_VERSION;
36+
public static String PINNED_CLIENT_TRUST;
3637
public static String SERVER_VERSION;
3738
public static String BUILD_DATE;
3839
public static Color DEFAULT_BACKGROUND_COLOR = ServerSettings.DEFAULT_COLOR;

client/test/com/mirth/connect/client/ui/CommandLineOptionsTest.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public void testParseSslForm() {
2020
assertEquals("secret", opts.getPassword());
2121
assertEquals("TLSv1.2,TLSv1.3", opts.getProtocols());
2222
assertEquals("TLS_RSA_WITH_AES_128_GCM_SHA256", opts.getCipherSuites());
23+
assertEquals("", opts.getPinnedClientTrust());
2324
}
2425

2526
@Test
@@ -33,6 +34,49 @@ public void testParseUsernameFormWithSsl() {
3334
assertEquals("pw", opts.getPassword());
3435
assertEquals("TLSv1.2", opts.getProtocols());
3536
assertEquals("CIPHER", opts.getCipherSuites());
37+
assertEquals("", opts.getPinnedClientTrust());
38+
}
39+
40+
@Test
41+
public void testParseSslFormWithPinnedClientTrust() {
42+
String[] args = new String[] { "https://example:8443", "1.0", "-ssl", "TLSv1.2,TLSv1.3", "TLS_RSA_WITH_AES_128_GCM_SHA256", "alice", "secret", "-trust", "abcdef1234,5489349" };
43+
CommandLineOptions opts = new CommandLineOptions(args);
44+
45+
assertEquals("https://example:8443", opts.getServer());
46+
assertEquals("1.0", opts.getVersion());
47+
assertEquals("alice", opts.getUsername());
48+
assertEquals("secret", opts.getPassword());
49+
assertEquals("TLSv1.2,TLSv1.3", opts.getProtocols());
50+
assertEquals("TLS_RSA_WITH_AES_128_GCM_SHA256", opts.getCipherSuites());
51+
assertEquals("abcdef1234,5489349", opts.getPinnedClientTrust());
52+
}
53+
54+
@Test
55+
public void testParseUsernameFormWithPinnedClientTrust() {
56+
String[] args = new String[] { "https://example:8443", "1.0", "bob", "pw", "-trust", "localhost,a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3" };
57+
CommandLineOptions opts = new CommandLineOptions(args);
58+
59+
assertEquals("https://example:8443", opts.getServer());
60+
assertEquals("1.0", opts.getVersion());
61+
assertEquals("bob", opts.getUsername());
62+
assertEquals("pw", opts.getPassword());
63+
assertEquals("", opts.getProtocols());
64+
assertEquals("", opts.getCipherSuites());
65+
assertEquals("localhost,a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3", opts.getPinnedClientTrust());
66+
}
67+
68+
@Test
69+
public void testParsePinnedClientTrustAfterredentials() {
70+
String[] args = new String[] { "https://example:8443", "1.0", "bob", "pw", "-trust", "localhost,a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3" };
71+
CommandLineOptions opts = new CommandLineOptions(args);
72+
73+
assertEquals("https://example:8443", opts.getServer());
74+
assertEquals("1.0", opts.getVersion());
75+
assertEquals("bob", opts.getUsername());
76+
assertEquals("pw", opts.getPassword());
77+
assertEquals("", opts.getProtocols());
78+
assertEquals("", opts.getCipherSuites());
79+
assertEquals("localhost,a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3", opts.getPinnedClientTrust());
3680
}
3781

3882
@Test
@@ -45,6 +89,7 @@ public void testNullArgsUsesDefaults() {
4589
assertEquals("", opts.getPassword());
4690
assertEquals("", opts.getProtocols());
4791
assertEquals("", opts.getCipherSuites());
92+
assertEquals("", opts.getPinnedClientTrust());
4893
}
4994

5095
@Test
@@ -58,5 +103,6 @@ public void testNormal() {
58103
assertEquals("", opts.getPassword());
59104
assertEquals("", opts.getProtocols());
60105
assertEquals("", opts.getCipherSuites());
106+
assertEquals("", opts.getPinnedClientTrust());
61107
}
62108
}

server/conf/mirth.properties

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ server.includecustomlib = false
6363

6464
# administrator
6565
administrator.maxheapsize = 512m
66+
# Controls how the client should validate the server's SSL certificate.
67+
# Comma separated list of options:
68+
# - pki
69+
# Trust CA's and peer trust based on the client JVM config. Requires
70+
# the server hostname to match the certificate CN or SAN. This is the
71+
# "normal" trust required by most SSL clients.
72+
# - webserver
73+
# Trust this server's certificate specifically. Do not validate hostname.
74+
# - <thumbprint>
75+
# Trust the specified SHA-256 certificate thumbprint. Do not validate hostname.
76+
# - insecure_trust_all_certs
77+
# Trust all certificates without validation. (not recommended)
78+
administrator.pinnedclienttrust = pki,webserver
6679

6780
# properties file that will store the configuration map and be loaded during server startup
6881
configurationmap.path = ${dir.appdata}/configuration.properties
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Mitch Gaffigan
3+
4+
package com.mirth.connect.client.core;
5+
6+
import java.security.MessageDigest;
7+
import java.security.NoSuchAlgorithmException;
8+
import java.security.cert.Certificate;
9+
import java.security.cert.CertificateEncodingException;
10+
import java.security.cert.CertificateException;
11+
import java.security.cert.X509Certificate;
12+
import java.util.HexFormat;
13+
import java.util.Locale;
14+
import java.util.Set;
15+
16+
import javax.net.ssl.SSLPeerUnverifiedException;
17+
import javax.net.ssl.SSLSession;
18+
19+
import org.apache.commons.lang3.StringUtils;
20+
21+
/** A collection of trusted certificate thumbprints. */
22+
final class CertificateThumbprintMatcher {
23+
24+
private final Set<String> pinnedThumbprints;
25+
26+
CertificateThumbprintMatcher(Set<String> pinnedThumbprints) {
27+
this.pinnedThumbprints = pinnedThumbprints;
28+
}
29+
30+
boolean matches(SSLSession session) {
31+
try {
32+
return matches(session.getPeerCertificates());
33+
} catch (SSLPeerUnverifiedException e) {
34+
return false;
35+
}
36+
}
37+
38+
boolean matches(Certificate[] certificates) {
39+
if (pinnedThumbprints.isEmpty() || certificates == null) {
40+
return false;
41+
}
42+
43+
for (Certificate certificate : certificates) {
44+
if (certificate instanceof X509Certificate) {
45+
try {
46+
if (pinnedThumbprints.contains(getThumbprint((X509Certificate) certificate))) {
47+
return true;
48+
}
49+
} catch (CertificateException e) {
50+
continue;
51+
}
52+
}
53+
}
54+
55+
return false;
56+
}
57+
58+
static String normalize(String thumbprint) {
59+
return StringUtils.lowerCase(StringUtils.deleteWhitespace(StringUtils.trimToEmpty(thumbprint)), Locale.ROOT);
60+
}
61+
62+
private String getThumbprint(X509Certificate certificate) throws CertificateException {
63+
try {
64+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
65+
return HexFormat.of().formatHex(digest.digest(certificate.getEncoded()));
66+
} catch (NoSuchAlgorithmException | CertificateEncodingException e) {
67+
throw new CertificateException("Unable to calculate certificate thumbprint.", e);
68+
}
69+
}
70+
}

server/src/com/mirth/connect/client/core/Client.java

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -151,30 +151,43 @@ public class Client implements UserServletInterface, ConfigurationServletInterfa
151151
*/
152152
public Client(String address) throws URISyntaxException {
153153
// Default timeout is infinite.
154-
this(address, 0, MirthSSLUtil.DEFAULT_HTTPS_CLIENT_PROTOCOLS, MirthSSLUtil.DEFAULT_HTTPS_CIPHER_SUITES, null);
154+
this(address, 0, MirthSSLUtil.DEFAULT_HTTPS_CLIENT_PROTOCOLS, MirthSSLUtil.DEFAULT_HTTPS_CIPHER_SUITES, null, null);
155155
}
156156

157157
public Client(String address, String[] httpsProtocols, String[] httpsCipherSuites) throws URISyntaxException {
158158
// Default timeout is infinite.
159-
this(address, 0, httpsProtocols, httpsCipherSuites, null);
159+
this(address, httpsProtocols, httpsCipherSuites, (String) null);
160+
}
161+
162+
public Client(String address, String[] httpsProtocols, String[] httpsCipherSuites, String pinnedClientTrust) throws URISyntaxException {
163+
// Default timeout is infinite.
164+
this(address, 0, httpsProtocols, httpsCipherSuites, pinnedClientTrust, null);
160165
}
161166

162167
public Client(String address, String[] httpsProtocols, String[] httpsCipherSuites, String[] apiProviderClasses) throws URISyntaxException {
163168
// Default timeout is infinite.
164-
this(address, 0, httpsProtocols, httpsCipherSuites, apiProviderClasses);
169+
this(address, 0, httpsProtocols, httpsCipherSuites, null, apiProviderClasses);
165170
}
166171

167172
public Client(String address, int timeout, String[] httpsProtocols, String[] httpsCipherSuites) throws URISyntaxException {
168-
this(address, timeout, httpsProtocols, httpsCipherSuites, null);
173+
this(address, timeout, httpsProtocols, httpsCipherSuites, null, null);
169174
}
170175

171176
public Client(String address, int timeout, String[] httpsProtocols, String[] httpsCipherSuites, String[] apiProviderClasses) throws URISyntaxException {
177+
this(address, timeout, httpsProtocols, httpsCipherSuites, null, apiProviderClasses);
178+
}
179+
180+
public Client(String address, int timeout, String[] httpsProtocols, String[] httpsCipherSuites, String pinnedClientTrust) throws URISyntaxException {
181+
this(address, timeout, httpsProtocols, httpsCipherSuites, pinnedClientTrust, null);
182+
}
183+
184+
public Client(String address, int timeout, String[] httpsProtocols, String[] httpsCipherSuites, String pinnedClientTrust, String[] apiProviderClasses) throws URISyntaxException {
172185
if (!address.endsWith("/")) {
173186
address += "/";
174187
}
175188
URI addressURI = new URI(address);
176189

177-
serverConnection = new ServerConnection(timeout, httpsProtocols, httpsCipherSuites, StringUtils.equalsIgnoreCase(addressURI.getScheme(), "http"));
190+
serverConnection = new ServerConnection(timeout, httpsProtocols, httpsCipherSuites, pinnedClientTrust, addressURI.getHost(), StringUtils.equalsIgnoreCase(addressURI.getScheme(), "http"));
178191

179192
ClientConfig config = new ClientConfig().connectorProvider(new ConnectorProvider() {
180193
@Override
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Mitch Gaffigan
3+
4+
package com.mirth.connect.client.core;
5+
6+
import java.util.ArrayList;
7+
import java.util.List;
8+
9+
import javax.net.ssl.HostnameVerifier;
10+
import javax.net.ssl.SSLSession;
11+
12+
/** A composite hostname verifier that delegates to multiple implementations. */
13+
public class CompositeHostnameVerifier implements HostnameVerifier {
14+
15+
private final List<HostnameVerifier> hostnameVerifiers;
16+
17+
public CompositeHostnameVerifier(List<HostnameVerifier> hostnameVerifiers) {
18+
this.hostnameVerifiers = new ArrayList<HostnameVerifier>(hostnameVerifiers);
19+
}
20+
21+
@Override
22+
public boolean verify(String host, SSLSession session) {
23+
for (HostnameVerifier hostnameVerifier : hostnameVerifiers) {
24+
if (hostnameVerifier.verify(host, session)) {
25+
return true;
26+
}
27+
}
28+
29+
return false;
30+
}
31+
}

0 commit comments

Comments
 (0)