diff --git a/Dockerfile b/Dockerfile index 90709a63b6..a59672f5ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,6 +58,10 @@ FROM eclipse-temurin:21.0.9_10-jre-noble AS smoketest-harness COPY --from=builder /app/server/setup/server-lib /opt/engine/server-lib COPY --from=builder /app/server/setup/extensions /opt/engine/extensions COPY --from=builder /app/server/setup/conf /opt/engine/conf +# The launcher resolves cli-lib/ relative to its working directory, so the CLI has +# to keep the layout it has in the distribution. +COPY --from=builder /app/server/setup/cli-lib /opt/engine/cli-lib +COPY --from=builder /app/server/setup/mirth-cli-launcher.jar /opt/engine/mirth-cli-launcher.jar COPY --from=builder /app/smoketest/build/install/smoketest-harness /harness ENTRYPOINT ["/bin/bash", "/harness/run-harness.sh"] diff --git a/ci/run-harness.sh b/ci/run-harness.sh index a1582525bb..202d40b208 100755 --- a/ci/run-harness.sh +++ b/ci/run-harness.sh @@ -20,6 +20,7 @@ java \ -Doie.baseUrl="$OIE_BASE_URL" \ -Doie.configuration="$OIE_CONFIGURATION" \ -Doie.password="$OIE_PASSWORD" \ + -Doie.cliHome="$ENGINE_HOME" \ ${OIE_DB_DRIVER:+-Doie.db.driver="$OIE_DB_DRIVER"} \ ${OIE_DB_URL:+-Doie.db.url="$OIE_DB_URL"} \ ${OIE_DB_USERNAME:+-Doie.db.username="$OIE_DB_USERNAME"} \ diff --git a/client/src/main/java/com/mirth/connect/client/ui/CommandLineOptions.java b/client/src/main/java/com/mirth/connect/client/ui/CommandLineOptions.java index 0fdabc3da8..8c9f762ad3 100644 --- a/client/src/main/java/com/mirth/connect/client/ui/CommandLineOptions.java +++ b/client/src/main/java/com/mirth/connect/client/ui/CommandLineOptions.java @@ -3,6 +3,10 @@ package com.mirth.connect.client.ui; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; + import org.apache.commons.lang3.StringUtils; /** @@ -15,57 +19,46 @@ public class CommandLineOptions { private final String password; private final String protocols; private final String cipherSuites; + private final String pinnedClientTrust; /** * Parse command line arguments for Mirth client. */ public CommandLineOptions(String[] args) { + if (args == null) { + args = new String[0]; + } + String server = "https://localhost:8443"; String version = ""; String username = ""; String password = ""; String protocols = ""; String cipherSuites = ""; + String pinnedClientTrust = ""; - if (args == null) { - args = new String[0]; - } + Deque remaining = new ArrayDeque(Arrays.asList(args)); + int idx = 0; + while (true) { + String arg = remaining.pollFirst(); + if (arg == null) { + break; + } - if (args.length > 0) { - server = args[0]; - } - if (args.length > 1) { - version = args[1]; - } - if (args.length > 2) { - if (StringUtils.equalsIgnoreCase(args[2], "-ssl")) { - // -ssl [ [ [ []]]] - if (args.length > 3) { - protocols = args[3]; - } - if (args.length > 4) { - cipherSuites = args[4]; - } - if (args.length > 5) { - username = args[5]; - } - if (args.length > 6) { - password = args[6]; - } + if (StringUtils.equalsIgnoreCase(arg, "-ssl")) { + protocols = StringUtils.defaultString(remaining.pollFirst()); + cipherSuites = StringUtils.defaultString(remaining.pollFirst()); + } else if (StringUtils.equalsIgnoreCase(arg, "-trust")) { + pinnedClientTrust = StringUtils.defaultString(remaining.pollFirst()); } else { - // [ [-ssl [ []]]] - username = args[2]; - if (args.length > 3) { - password = args[3]; - } - if (args.length > 4 && StringUtils.equalsIgnoreCase(args[4], "-ssl")) { - if (args.length > 5) { - protocols = args[5]; - } - if (args.length > 6) { - cipherSuites = args[6]; - } + switch (idx) { + case 0 -> server = arg; + case 1 -> version = arg; + case 2 -> username = arg; + case 3 -> password = arg; + default -> {} // Explicitly ignore extra arguments } + idx++; } } @@ -73,8 +66,9 @@ public CommandLineOptions(String[] args) { this.version = version; this.username = username; this.password = password; - this.protocols = protocols; - this.cipherSuites = cipherSuites; + this.protocols = StringUtils.defaultString(protocols); + this.cipherSuites = StringUtils.defaultString(cipherSuites); + this.pinnedClientTrust = StringUtils.defaultString(pinnedClientTrust); } public String getServer() { @@ -100,4 +94,8 @@ public String getProtocols() { public String getCipherSuites() { return cipherSuites; } + + public String getPinnedClientTrust() { + return pinnedClientTrust; + } } diff --git a/client/src/main/java/com/mirth/connect/client/ui/LoginPanel.java b/client/src/main/java/com/mirth/connect/client/ui/LoginPanel.java index 0414b7accd..dadb0c2bea 100644 --- a/client/src/main/java/com/mirth/connect/client/ui/LoginPanel.java +++ b/client/src/main/java/com/mirth/connect/client/ui/LoginPanel.java @@ -423,7 +423,7 @@ public Void doInBackground() { try { String server = serverName.getText(); - client = new Client(server, PlatformUI.HTTPS_PROTOCOLS, PlatformUI.HTTPS_CIPHER_SUITES); + client = new Client(server, PlatformUI.HTTPS_PROTOCOLS, PlatformUI.HTTPS_CIPHER_SUITES, PlatformUI.PINNED_CLIENT_TRUST); PlatformUI.SERVER_URL = server; // Attempt to login diff --git a/client/src/main/java/com/mirth/connect/client/ui/Mirth.java b/client/src/main/java/com/mirth/connect/client/ui/Mirth.java index 60a74e85c5..336fe2b763 100644 --- a/client/src/main/java/com/mirth/connect/client/ui/Mirth.java +++ b/client/src/main/java/com/mirth/connect/client/ui/Mirth.java @@ -270,6 +270,7 @@ public static void main(String[] args) { if (StringUtils.isNotBlank(opts.getCipherSuites())) { PlatformUI.HTTPS_CIPHER_SUITES = StringUtils.split(opts.getCipherSuites(), ','); } + PlatformUI.PINNED_CLIENT_TRUST = opts.getPinnedClientTrust(); PlatformUI.SERVER_URL = opts.getServer(); PlatformUI.CLIENT_VERSION = opts.getVersion(); diff --git a/client/src/main/java/com/mirth/connect/client/ui/PlatformUI.java b/client/src/main/java/com/mirth/connect/client/ui/PlatformUI.java index 8d40400ed4..81e2d697d5 100644 --- a/client/src/main/java/com/mirth/connect/client/ui/PlatformUI.java +++ b/client/src/main/java/com/mirth/connect/client/ui/PlatformUI.java @@ -33,6 +33,7 @@ public class PlatformUI { public static String SERVER_DATABASE; public static String USER_NAME; public static String CLIENT_VERSION; + public static String PINNED_CLIENT_TRUST; public static String SERVER_VERSION; public static String BUILD_DATE; public static Color DEFAULT_BACKGROUND_COLOR = ServerSettings.DEFAULT_COLOR; diff --git a/client/src/test/java/com/mirth/connect/client/ui/CommandLineOptionsTest.java b/client/src/test/java/com/mirth/connect/client/ui/CommandLineOptionsTest.java index 3c0080ddd7..c592be776d 100644 --- a/client/src/test/java/com/mirth/connect/client/ui/CommandLineOptionsTest.java +++ b/client/src/test/java/com/mirth/connect/client/ui/CommandLineOptionsTest.java @@ -20,6 +20,7 @@ public void testParseSslForm() { assertEquals("secret", opts.getPassword()); assertEquals("TLSv1.2,TLSv1.3", opts.getProtocols()); assertEquals("TLS_RSA_WITH_AES_128_GCM_SHA256", opts.getCipherSuites()); + assertEquals("", opts.getPinnedClientTrust()); } @Test @@ -33,6 +34,49 @@ public void testParseUsernameFormWithSsl() { assertEquals("pw", opts.getPassword()); assertEquals("TLSv1.2", opts.getProtocols()); assertEquals("CIPHER", opts.getCipherSuites()); + assertEquals("", opts.getPinnedClientTrust()); + } + + @Test + public void testParseSslFormWithPinnedClientTrust() { + 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" }; + CommandLineOptions opts = new CommandLineOptions(args); + + assertEquals("https://example:8443", opts.getServer()); + assertEquals("1.0", opts.getVersion()); + assertEquals("alice", opts.getUsername()); + assertEquals("secret", opts.getPassword()); + assertEquals("TLSv1.2,TLSv1.3", opts.getProtocols()); + assertEquals("TLS_RSA_WITH_AES_128_GCM_SHA256", opts.getCipherSuites()); + assertEquals("abcdef1234,5489349", opts.getPinnedClientTrust()); + } + + @Test + public void testParseUsernameFormWithPinnedClientTrust() { + String[] args = new String[] { "https://example:8443", "1.0", "bob", "pw", "-trust", "localhost,a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3" }; + CommandLineOptions opts = new CommandLineOptions(args); + + assertEquals("https://example:8443", opts.getServer()); + assertEquals("1.0", opts.getVersion()); + assertEquals("bob", opts.getUsername()); + assertEquals("pw", opts.getPassword()); + assertEquals("", opts.getProtocols()); + assertEquals("", opts.getCipherSuites()); + assertEquals("localhost,a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3", opts.getPinnedClientTrust()); + } + + @Test + public void testParsePinnedClientTrustAfterredentials() { + String[] args = new String[] { "https://example:8443", "1.0", "bob", "pw", "-trust", "localhost,a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3" }; + CommandLineOptions opts = new CommandLineOptions(args); + + assertEquals("https://example:8443", opts.getServer()); + assertEquals("1.0", opts.getVersion()); + assertEquals("bob", opts.getUsername()); + assertEquals("pw", opts.getPassword()); + assertEquals("", opts.getProtocols()); + assertEquals("", opts.getCipherSuites()); + assertEquals("localhost,a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3", opts.getPinnedClientTrust()); } @Test @@ -45,6 +89,7 @@ public void testNullArgsUsesDefaults() { assertEquals("", opts.getPassword()); assertEquals("", opts.getProtocols()); assertEquals("", opts.getCipherSuites()); + assertEquals("", opts.getPinnedClientTrust()); } @Test @@ -58,5 +103,6 @@ public void testNormal() { assertEquals("", opts.getPassword()); assertEquals("", opts.getProtocols()); assertEquals("", opts.getCipherSuites()); + assertEquals("", opts.getPinnedClientTrust()); } } diff --git a/command/conf/mirth-cli-config.properties b/command/conf/mirth-cli-config.properties index 7be6c7a19b..1d66f81d32 100644 --- a/command/conf/mirth-cli-config.properties +++ b/command/conf/mirth-cli-config.properties @@ -1,4 +1,9 @@ address=https://127.0.0.1:8443 user=admin password=admin -version=0.0.0 \ No newline at end of file +version=0.0.0 + +# Comma-separated: pki, localhost, a SHA-256 certificate thumbprint, or +# insecure_trust_all_certs. The default reaches a server on this machine but no +# other; a remote or self-signed server needs an entry here. +#trust=pki,localhost diff --git a/command/src/main/java/com/mirth/connect/cli/CommandLineInterface.java b/command/src/main/java/com/mirth/connect/cli/CommandLineInterface.java index 3f757d4ca1..fc0e7382c3 100644 --- a/command/src/main/java/com/mirth/connect/cli/CommandLineInterface.java +++ b/command/src/main/java/com/mirth/connect/cli/CommandLineInterface.java @@ -89,6 +89,7 @@ import com.mirth.connect.util.MessageImporter; import com.mirth.connect.util.MessageImporter.MessageImportException; import com.mirth.connect.util.MessageImporter.MessageImportInvalidPathException; +import com.mirth.connect.util.MirthSSLUtil; import com.mirth.connect.util.messagewriter.AttachmentSource; import com.mirth.connect.util.messagewriter.MessageWriter; import com.mirth.connect.util.messagewriter.MessageWriterException; @@ -123,6 +124,7 @@ private void run(String[] args) { Option scriptOption = OptionBuilder.withArgName("script").hasArg().withDescription("script file").create("s"); Option versionOption = OptionBuilder.withArgName("version").hasArg().withDescription("version").create("v"); Option configOption = OptionBuilder.withArgName("config file").hasArg().withDescription("path to default configuration [default: mirth-cli-config.properties]").create("c"); + Option trustOption = OptionBuilder.withArgName("trust").hasArg().withDescription("how to trust the server's certificate: any comma-separated combination of pki, localhost, a SHA-256 thumbprint, or insecure_trust_all_certs [default: pki,localhost]").create("trust"); Option helpOption = new Option("h", "help"); Option debugOption = new Option("d", "debug"); @@ -133,6 +135,7 @@ private void run(String[] args) { options.addOption(passwordOption); options.addOption(scriptOption); options.addOption(versionOption); + options.addOption(trustOption); options.addOption(helpOption); options.addOption(debugOption); @@ -175,9 +178,10 @@ private void run(String[] args) { String user = line.getOptionValue("u", config.getString("user")); String password = line.getOptionValue("p", config.getString("password")); String script = line.getOptionValue("s", config.getString("script")); + String trust = line.getOptionValue("trust", config.getString("trust")); if ((server != null) && (user != null) && (password != null)) { - runShell(server, user, password, script, line.hasOption("d")); + runShell(server, user, password, script, trust, line.hasOption("d")); } else { new HelpFormatter().printHelp("Shell", options); error("all of address, user, password, and version options must be supplied as arguments or in the default configuration file", null); @@ -189,9 +193,11 @@ private void run(String[] args) { } } - private void runShell(String server, String user, String password, String script, boolean debug) { + private void runShell(String server, String user, String password, String script, String trust, boolean debug) { try { - client = new Client(server); + // A null trust takes the client default of pki,localhost, which reaches a + // server on this machine but no other. + client = new Client(server, MirthSSLUtil.DEFAULT_HTTPS_CLIENT_PROTOCOLS, MirthSSLUtil.DEFAULT_HTTPS_CIPHER_SUITES, trust); this.debug = debug; LoginStatus loginStatus = client.login(user, password); @@ -216,7 +222,6 @@ private void runShell(String server, String user, String password, String script runConsole(); } client.logout(); - client.close(); out.println("Disconnected from server."); } catch (ClientException ce) { ce.printStackTrace(); @@ -224,6 +229,12 @@ private void runShell(String server, String user, String password, String script error("Could not load script file.", ioe); } catch (URISyntaxException e) { error("Invalid server address.", e); + } finally { + // The client's connection monitor is a non-daemon thread, so an unclosed + // client keeps the JVM alive instead of letting it exit. + if (client != null) { + client.close(); + } } } diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index 6b71ebdbf3..8c2f8f3961 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -67,6 +67,19 @@ server.includecustomlib = false # administrator administrator.maxheapsize = 512m +# Controls how the client should validate the server's SSL certificate. +# Comma separated list of options: +# - pki +# Trust CA's and peer trust based on the client JVM config. Requires +# the server hostname to match the certificate CN or SAN. This is the +# "normal" trust required by most SSL clients. +# - webserver +# Trust this server's certificate specifically. Do not validate hostname. +# - +# Trust the specified SHA-256 certificate thumbprint. Do not validate hostname. +# - insecure_trust_all_certs +# Trust all certificates without validation. (not recommended) +administrator.pinnedclienttrust = pki,webserver # properties file that will store the configuration map and be loaded during server startup configurationmap.path = ${dir.appdata}/configuration.properties diff --git a/server/src/main/java/com/mirth/connect/client/core/CertificateThumbprintMatcher.java b/server/src/main/java/com/mirth/connect/client/core/CertificateThumbprintMatcher.java new file mode 100644 index 0000000000..ed60ebf138 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/CertificateThumbprintMatcher.java @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Set; + +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; + +import org.apache.commons.lang3.StringUtils; + +/** A collection of trusted certificate thumbprints. */ +final class CertificateThumbprintMatcher { + + private final Set pinnedThumbprints; + + CertificateThumbprintMatcher(Set pinnedThumbprints) { + this.pinnedThumbprints = pinnedThumbprints; + } + + boolean matches(SSLSession session) { + try { + return matches(session.getPeerCertificates()); + } catch (SSLPeerUnverifiedException e) { + return false; + } + } + + boolean matches(Certificate[] certificates) { + if (pinnedThumbprints.isEmpty() || certificates == null || certificates.length == 0) { + return false; + } + + // Leaf only. A match here skips path validation, so nothing proves the rest of + // the chain signed the leaf, and the peer chooses what it sends: matching any + // other element would let an attacker append the (public) pinned certificate. + if (!(certificates[0] instanceof X509Certificate)) { + return false; + } + + try { + return pinnedThumbprints.contains(getThumbprint((X509Certificate) certificates[0])); + } catch (CertificateException e) { + return false; + } + } + + static String normalize(String thumbprint) { + return StringUtils.lowerCase(StringUtils.deleteWhitespace(StringUtils.trimToEmpty(thumbprint)), Locale.ROOT); + } + + private String getThumbprint(X509Certificate certificate) throws CertificateException { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(certificate.getEncoded())); + } catch (NoSuchAlgorithmException | CertificateEncodingException e) { + throw new CertificateException("Unable to calculate certificate thumbprint.", e); + } + } +} \ No newline at end of file diff --git a/server/src/main/java/com/mirth/connect/client/core/Client.java b/server/src/main/java/com/mirth/connect/client/core/Client.java index a927508d67..f87d2fe7fc 100644 --- a/server/src/main/java/com/mirth/connect/client/core/Client.java +++ b/server/src/main/java/com/mirth/connect/client/core/Client.java @@ -151,30 +151,43 @@ public class Client implements UserServletInterface, ConfigurationServletInterfa */ public Client(String address) throws URISyntaxException { // Default timeout is infinite. - this(address, 0, MirthSSLUtil.DEFAULT_HTTPS_CLIENT_PROTOCOLS, MirthSSLUtil.DEFAULT_HTTPS_CIPHER_SUITES, null); + this(address, 0, MirthSSLUtil.DEFAULT_HTTPS_CLIENT_PROTOCOLS, MirthSSLUtil.DEFAULT_HTTPS_CIPHER_SUITES, null, null); } public Client(String address, String[] httpsProtocols, String[] httpsCipherSuites) throws URISyntaxException { // Default timeout is infinite. - this(address, 0, httpsProtocols, httpsCipherSuites, null); + this(address, httpsProtocols, httpsCipherSuites, (String) null); + } + + public Client(String address, String[] httpsProtocols, String[] httpsCipherSuites, String pinnedClientTrust) throws URISyntaxException { + // Default timeout is infinite. + this(address, 0, httpsProtocols, httpsCipherSuites, pinnedClientTrust, null); } public Client(String address, String[] httpsProtocols, String[] httpsCipherSuites, String[] apiProviderClasses) throws URISyntaxException { // Default timeout is infinite. - this(address, 0, httpsProtocols, httpsCipherSuites, apiProviderClasses); + this(address, 0, httpsProtocols, httpsCipherSuites, null, apiProviderClasses); } public Client(String address, int timeout, String[] httpsProtocols, String[] httpsCipherSuites) throws URISyntaxException { - this(address, timeout, httpsProtocols, httpsCipherSuites, null); + this(address, timeout, httpsProtocols, httpsCipherSuites, null, null); } public Client(String address, int timeout, String[] httpsProtocols, String[] httpsCipherSuites, String[] apiProviderClasses) throws URISyntaxException { + this(address, timeout, httpsProtocols, httpsCipherSuites, null, apiProviderClasses); + } + + public Client(String address, int timeout, String[] httpsProtocols, String[] httpsCipherSuites, String pinnedClientTrust) throws URISyntaxException { + this(address, timeout, httpsProtocols, httpsCipherSuites, pinnedClientTrust, null); + } + + public Client(String address, int timeout, String[] httpsProtocols, String[] httpsCipherSuites, String pinnedClientTrust, String[] apiProviderClasses) throws URISyntaxException { if (!address.endsWith("/")) { address += "/"; } URI addressURI = new URI(address); - serverConnection = new ServerConnection(timeout, httpsProtocols, httpsCipherSuites, StringUtils.equalsIgnoreCase(addressURI.getScheme(), "http")); + serverConnection = new ServerConnection(timeout, httpsProtocols, httpsCipherSuites, pinnedClientTrust, addressURI.getHost(), StringUtils.equalsIgnoreCase(addressURI.getScheme(), "http")); ClientConfig config = new ClientConfig().connectorProvider(new ConnectorProvider() { @Override diff --git a/server/src/main/java/com/mirth/connect/client/core/CompositeHostnameVerifier.java b/server/src/main/java/com/mirth/connect/client/core/CompositeHostnameVerifier.java new file mode 100644 index 0000000000..f489b460bc --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/CompositeHostnameVerifier.java @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import java.util.ArrayList; +import java.util.List; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; + +/** A composite hostname verifier that delegates to multiple implementations. */ +public class CompositeHostnameVerifier implements HostnameVerifier { + + private final List hostnameVerifiers; + + public CompositeHostnameVerifier(List hostnameVerifiers) { + this.hostnameVerifiers = new ArrayList(hostnameVerifiers); + } + + @Override + public boolean verify(String host, SSLSession session) { + for (HostnameVerifier hostnameVerifier : hostnameVerifiers) { + if (hostnameVerifier.verify(host, session)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/mirth/connect/client/core/CompositeTrustStrategy.java b/server/src/main/java/com/mirth/connect/client/core/CompositeTrustStrategy.java new file mode 100644 index 0000000000..2071fa865e --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/CompositeTrustStrategy.java @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +import org.apache.http.conn.ssl.TrustStrategy; + +/** A composite trust strategy that delegates to multiple implementations. */ +public class CompositeTrustStrategy implements TrustStrategy { + + private final List trustStrategies; + + public CompositeTrustStrategy(List trustStrategies) { + this.trustStrategies = new ArrayList(trustStrategies); + } + + @Override + public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException { + for (TrustStrategy trustStrategy : trustStrategies) { + if (trustStrategy.isTrusted(chain, authType)) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/server/src/main/java/com/mirth/connect/client/core/LocalhostTrustStrategy.java b/server/src/main/java/com/mirth/connect/client/core/LocalhostTrustStrategy.java new file mode 100644 index 0000000000..b58baaf97e --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/LocalhostTrustStrategy.java @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; + +import org.apache.http.conn.ssl.TrustStrategy; + +import org.apache.commons.lang3.StringUtils; + +/** A "trust all" strategy for localhost hosts. */ +public class LocalhostTrustStrategy implements TrustStrategy, HostnameVerifier { + + private final boolean localhostConnection; + + public LocalhostTrustStrategy(String serverHost) { + localhostConnection = isLocalhost(serverHost); + } + + @Override + public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException { + return localhostConnection; + } + + @Override + public boolean verify(String host, SSLSession session) { + return localhostConnection && isLocalhost(host); + } + + private static boolean isLocalhost(String host) { + return StringUtils.equalsAnyIgnoreCase(host, "localhost", "127.0.0.1", "::1", "0:0:0:0:0:0:0:1"); + } +} \ No newline at end of file diff --git a/server/src/main/java/com/mirth/connect/client/core/PinnedCertificateTrustStrategy.java b/server/src/main/java/com/mirth/connect/client/core/PinnedCertificateTrustStrategy.java new file mode 100644 index 0000000000..002da84e89 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/PinnedCertificateTrustStrategy.java @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Set; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; + +import org.apache.http.conn.ssl.TrustStrategy; + +/** A trust strategy that validates certificates based on pinned thumbprints, ignoring hostname verification. */ +public class PinnedCertificateTrustStrategy implements TrustStrategy, HostnameVerifier { + + private final CertificateThumbprintMatcher certificateThumbprintMatcher; + + public PinnedCertificateTrustStrategy(Set pinnedThumbprints) { + certificateThumbprintMatcher = new CertificateThumbprintMatcher(pinnedThumbprints); + } + + @Override + public boolean isTrusted(X509Certificate[] chain, String authType) throws CertificateException { + if (chain == null) { + return false; + } + + return certificateThumbprintMatcher.matches(chain); + } + + @Override + public boolean verify(String host, SSLSession session) { + return certificateThumbprintMatcher.matches(session); + } +} diff --git a/server/src/main/java/com/mirth/connect/client/core/PinnedClientTrustConfig.java b/server/src/main/java/com/mirth/connect/client/core/PinnedClientTrustConfig.java new file mode 100644 index 0000000000..e9e7b21c88 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/PinnedClientTrustConfig.java @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; + +import org.apache.commons.lang3.StringUtils; + +/** Parses trust list for the client */ +public class PinnedClientTrustConfig { + + private static final String DEFAULT_VALUE = "pki,localhost"; + private static final String LOCALHOST_VALUE = "localhost"; + private static final String INSECURE_TRUST_ALL_CERTS_VALUE = "insecure_trust_all_certs"; + private static final String PKI_VALUE = "pki"; + + private static final Pattern SHA_256_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + private final boolean pkiAllowed; + private final boolean localhostAllowed; + private final boolean trustAllCertificates; + private final Set pinnedThumbprints; + + private PinnedClientTrustConfig(boolean pkiAllowed, boolean localhostAllowed, boolean trustAllCertificates, Set pinnedThumbprints) { + this.pkiAllowed = pkiAllowed; + this.localhostAllowed = localhostAllowed; + this.trustAllCertificates = trustAllCertificates; + this.pinnedThumbprints = pinnedThumbprints; + } + + /* Create from the argument string or default if null/blank. */ + public static PinnedClientTrustConfig parse(String pinnedClientTrust) { + String trimmedValue = StringUtils.defaultIfBlank(pinnedClientTrust, DEFAULT_VALUE); + + String[] tokens = StringUtils.split(trimmedValue, ','); + if (tokens == null || tokens.length == 0) { + throw new IllegalArgumentException("PinnedClientTrust is invalid: " + pinnedClientTrust); + } + + boolean pkiAllowed = false; + boolean localhostAllowed = false; + boolean trustAllCertificates = false; + + Set pinnedThumbprints = new HashSet(); + for (String token : tokens) { + String cleanedToken = StringUtils.trimToEmpty(token); + if (StringUtils.isBlank(cleanedToken)) { + continue; + } + + if (StringUtils.equalsIgnoreCase(cleanedToken, PKI_VALUE)) { + pkiAllowed = true; + } else if (StringUtils.equalsIgnoreCase(cleanedToken, LOCALHOST_VALUE)) { + localhostAllowed = true; + } else if (StringUtils.equalsIgnoreCase(cleanedToken, INSECURE_TRUST_ALL_CERTS_VALUE)) { + trustAllCertificates = true; + } else { + String thumbprint = CertificateThumbprintMatcher.normalize(cleanedToken); + if (!SHA_256_PATTERN.matcher(thumbprint).matches()) { + throw new IllegalArgumentException("PinnedClientTrust contains an invalid token: " + token); + } + pinnedThumbprints.add(thumbprint); + } + } + + if (!pkiAllowed && !localhostAllowed && !trustAllCertificates && pinnedThumbprints.isEmpty()) { + throw new IllegalArgumentException("PinnedClientTrust must enable at least one trust mode."); + } + + return new PinnedClientTrustConfig(pkiAllowed, localhostAllowed, trustAllCertificates, Collections.unmodifiableSet(pinnedThumbprints)); + } + + /** Returns the set of trusted certificate thumbprints. */ + public Set getPinnedThumbprints() { + return pinnedThumbprints; + } + + /** Determines whether the public-key infrastructure (PKI) is trusted. */ + public boolean isPkiTrusted() { + return pkiAllowed; + } + + /** Determines whether localhost should be trusted without validation. */ + public boolean isLocalhostTrusted() { + return localhostAllowed; + } + + /** Determines whether all certificates should be trusted without validation. */ + public boolean isTrustAllCertificates() { + return trustAllCertificates; + } +} diff --git a/server/src/main/java/com/mirth/connect/client/core/PinnedClientTrustSslContextFactory.java b/server/src/main/java/com/mirth/connect/client/core/PinnedClientTrustSslContextFactory.java new file mode 100644 index 0000000000..e70b73b43e --- /dev/null +++ b/server/src/main/java/com/mirth/connect/client/core/PinnedClientTrustSslContextFactory.java @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import java.security.KeyStore; +import java.util.ArrayList; +import java.util.List; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLContext; + +import org.apache.http.conn.ssl.DefaultHostnameVerifier; +import org.apache.http.conn.ssl.NoopHostnameVerifier; +import org.apache.http.conn.ssl.TrustStrategy; +import org.apache.http.conn.ssl.TrustAllStrategy; +import org.apache.http.ssl.SSLContexts; + +/** Entrypoint for interpreting the trust configuration for the client */ +public class PinnedClientTrustSslContextFactory { + + /** Creates an SSL context based on the pinned client trust configuration. */ + public SSLContext createSslContext(PinnedClientTrustConfig pinnedClientTrustConfig, String serverHost) { + try { + if (pinnedClientTrustConfig.isTrustAllCertificates()) { + return SSLContexts.custom().loadTrustMaterial(null, new TrustAllStrategy()).build(); + } + + KeyStore trustStore = null; + if (!pinnedClientTrustConfig.isPkiTrusted()) { + trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + trustStore.load(null, null); + } + + var trustStrategy = createTrustStrategy(pinnedClientTrustConfig, serverHost); + return SSLContexts.custom().loadTrustMaterial(trustStore, trustStrategy).build(); + } catch (Exception e) { + throw new IllegalStateException("Unable to build SSL context.", e); + } + } + + /** Creates a hostname verifier based on the pinned client trust configuration. */ + public HostnameVerifier createHostnameVerifier(PinnedClientTrustConfig pinnedClientTrustConfig, String serverHost) { + if (pinnedClientTrustConfig.isTrustAllCertificates()) { + return NoopHostnameVerifier.INSTANCE; + } + + List hostnameVerifiers = new ArrayList(); + if (pinnedClientTrustConfig.isLocalhostTrusted()) { + hostnameVerifiers.add(new LocalhostTrustStrategy(serverHost)); + } + var pinned = pinnedClientTrustConfig.getPinnedThumbprints(); + if (!pinned.isEmpty()) { + hostnameVerifiers.add(new PinnedCertificateTrustStrategy(pinned)); + } + if (pinnedClientTrustConfig.isPkiTrusted()) { + hostnameVerifiers.add(new DefaultHostnameVerifier()); + } + + return new CompositeHostnameVerifier(hostnameVerifiers); + } + + private TrustStrategy createTrustStrategy(PinnedClientTrustConfig pinnedClientTrustConfig, String serverHost) { + List trustStrategies = new ArrayList(); + if (pinnedClientTrustConfig.isLocalhostTrusted()) { + trustStrategies.add(new LocalhostTrustStrategy(serverHost)); + } + var pinned = pinnedClientTrustConfig.getPinnedThumbprints(); + if (!pinned.isEmpty()) { + trustStrategies.add(new PinnedCertificateTrustStrategy(pinned)); + } + + return new CompositeTrustStrategy(trustStrategies); + } +} diff --git a/server/src/main/java/com/mirth/connect/client/core/ServerConnection.java b/server/src/main/java/com/mirth/connect/client/core/ServerConnection.java index 2b50501805..ae841f3b3d 100644 --- a/server/src/main/java/com/mirth/connect/client/core/ServerConnection.java +++ b/server/src/main/java/com/mirth/connect/client/core/ServerConnection.java @@ -58,9 +58,7 @@ import org.apache.http.conn.ConnectionKeepAliveStrategy; import org.apache.http.conn.socket.ConnectionSocketFactory; import org.apache.http.conn.socket.PlainConnectionSocketFactory; -import org.apache.http.conn.ssl.NoopHostnameVerifier; import org.apache.http.conn.ssl.SSLConnectionSocketFactory; -import org.apache.http.conn.ssl.TrustSelfSignedStrategy; import org.apache.http.entity.AbstractHttpEntity; import org.apache.http.entity.ContentType; import org.apache.http.impl.client.BasicCookieStore; @@ -70,7 +68,6 @@ import org.apache.http.impl.client.HttpClients; import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; import org.apache.http.protocol.HttpContext; -import org.apache.http.ssl.SSLContexts; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.glassfish.jersey.client.ClientRequest; @@ -110,22 +107,32 @@ public class ServerConnection implements Connector { private ExecutorService abortExecutor = Executors.newSingleThreadExecutor(); private IdleConnectionMonitor idleConnectionMonitor; private ConnectionKeepAliveStrategy keepAliveStrategy; + private final PinnedClientTrustSslContextFactory pinnedClientTrustSslContextFactory = new PinnedClientTrustSslContextFactory(); + /** + * @deprecated Use {@link #ServerConnection(int, String[], String[], String, String, boolean)} so trust behavior can be configured explicitly. + */ + @Deprecated public ServerConnection(int timeout, String[] httpsProtocols, String[] httpsCipherSuites) { - this(timeout, httpsProtocols, httpsCipherSuites, false); + this(timeout, httpsProtocols, httpsCipherSuites, null, null, false); } + /** + * @deprecated Use {@link #ServerConnection(int, String[], String[], String, String, boolean)} so trust behavior can be configured explicitly. + */ + @Deprecated public ServerConnection(int timeout, String[] httpsProtocols, String[] httpsCipherSuites, boolean allowHTTP) { - SSLContext sslContext = null; - try { - sslContext = SSLContexts.custom().loadTrustMaterial(null, new TrustSelfSignedStrategy()).build(); - } catch (Exception e) { - logger.error("Unable to build SSL context.", e); - } + this(timeout, httpsProtocols, httpsCipherSuites, null, null, allowHTTP); + } + + public ServerConnection(int timeout, String[] httpsProtocols, String[] httpsCipherSuites, String pinnedClientTrust, String serverHost, boolean allowHTTP) { + PinnedClientTrustConfig pinnedClientTrustConfig = PinnedClientTrustConfig.parse(pinnedClientTrust); + SSLContext sslContext = pinnedClientTrustSslContextFactory.createSslContext(pinnedClientTrustConfig, serverHost); String[] enabledProtocols = MirthSSLUtil.getEnabledHttpsProtocols(httpsProtocols); String[] enabledCipherSuites = MirthSSLUtil.getEnabledHttpsCipherSuites(httpsCipherSuites); - SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, enabledProtocols, enabledCipherSuites, NoopHostnameVerifier.INSTANCE); + SSLConnectionSocketFactory sslConnectionSocketFactory = new SSLConnectionSocketFactory(sslContext, enabledProtocols, enabledCipherSuites, + pinnedClientTrustSslContextFactory.createHostnameVerifier(pinnedClientTrustConfig, serverHost)); RegistryBuilder builder = RegistryBuilder. create().register("https", sslConnectionSocketFactory); if (allowHTTP) { builder.register("http", PlainConnectionSocketFactory.getSocketFactory()); diff --git a/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java b/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java index e4a9ce88c3..ae8b4b5fd4 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/ConfigurationController.java @@ -209,6 +209,11 @@ public static ConfigurationController getInstance() { */ public abstract String generateGuid(); + /** + * Returns the SHA-256 thumbprint for the server certificate in the configured keystore. + */ + public abstract String getServerCertificateThumbprint() throws Exception; + /** * Returns the database driver list used for the Database Reader/Writer connectors. * diff --git a/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java b/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java index bfee2eddbf..739883242d 100644 --- a/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java +++ b/server/src/main/java/com/mirth/connect/server/controllers/DefaultConfigurationController.java @@ -23,6 +23,7 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.KeyStore; +import java.security.MessageDigest; import java.security.Provider; import java.security.SecureRandom; import java.security.cert.Certificate; @@ -36,6 +37,7 @@ import java.util.Date; import java.util.HashMap; import java.util.HashSet; +import java.util.HexFormat; import java.util.Iterator; import java.util.List; import java.util.Locale; @@ -196,6 +198,7 @@ public class DefaultConfigurationController extends ConfigurationController { private static final String XSTREAM_ALLOW_TYPE_HIERARCHIES = "xstream.allowtypehierarchies"; private static final String DEFAULT_STOREPASS = "81uWxplDtB"; + private static final String SERVER_CERTIFICATE_ALIAS = "mirthconnect"; // singleton pattern private static ConfigurationController instance = null; @@ -1511,15 +1514,29 @@ private void configureEncryption(Provider provider, KeyStore keyStore, char[] ke } } + @Override + public String getServerCertificateThumbprint() throws Exception { + KeyStore keyStore = KeyStore.getInstance(mirthConfig.getString("keystore.type", "JCEKS")); + + try (FileInputStream keyStoreInputStream = new FileInputStream(new File(mirthConfig.getString("keystore.path")))) { + keyStore.load(keyStoreInputStream, mirthConfig.getString("keystore.storepass").toCharArray()); + } + + Certificate certificate = keyStore.getCertificate(SERVER_CERTIFICATE_ALIAS); + if (certificate == null) { + throw new IllegalStateException("Certificate alias not found in keystore: " + SERVER_CERTIFICATE_ALIAS); + } + + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded())); + } + /** * Checks for an existing certificate to use for secure communication between the server and * client. If no certficate exists, this will generate a new one. * */ private void generateDefaultCertificate(Provider provider, KeyStore keyStore, char[] keyPassword) throws Exception { - final String certificateAlias = "mirthconnect"; - - if (!keyStore.containsAlias(certificateAlias)) { + if (!keyStore.containsAlias(SERVER_CERTIFICATE_ALIAS)) { // Common CA and SSL cert attributes Date startDate = new Date(); // time from which certificate is valid Date expiryDate = DateUtils.addYears(startDate, 50); // time after which certificate is not valid @@ -1553,7 +1570,7 @@ private void generateDefaultCertificate(Provider provider, KeyStore keyStore, ch logger.debug("generated new certificate with serial number: " + ((X509Certificate) sslCert).getSerialNumber()); // add the generated SSL cert to the keystore using the key password - keyStore.setKeyEntry(certificateAlias, sslKeyPair.getPrivate(), keyPassword, new Certificate[] { + keyStore.setKeyEntry(SERVER_CERTIFICATE_ALIAS, sslKeyPair.getPrivate(), keyPassword, new Certificate[] { sslCert }); } else { logger.debug("found certificate in keystore"); diff --git a/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java b/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java index 8bb847055b..8c50203031 100644 --- a/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java +++ b/server/src/main/java/com/mirth/connect/server/servlets/WebStartServlet.java @@ -50,6 +50,7 @@ import com.mirth.connect.server.controllers.ControllerFactory; import com.mirth.connect.server.controllers.ExtensionController; import com.mirth.connect.server.tools.ClassPathResource; +import com.mirth.connect.server.util.PinnedClientTrustResolver; import com.mirth.connect.server.util.ResourceUtil; import com.mirth.connect.util.MirthSSLUtil; @@ -57,6 +58,7 @@ public class WebStartServlet extends HttpServlet { private Logger logger = LogManager.getLogger(this.getClass()); private ConfigurationController configurationController = ControllerFactory.getFactory().createConfigurationController(); private ExtensionController extensionController = ControllerFactory.getFactory().createExtensionController(); + private final PinnedClientTrustResolver pinnedClientTrustResolver = new PinnedClientTrustResolver(); /* * Override last modified time to always be modified so it updates changes to JNLP. @@ -278,6 +280,17 @@ protected Document getAdministratorJnlp(HttpServletRequest request) throws Excep applicationDescElement.appendChild(cipherSuitesArgumentElement); } + String pinnedClientTrust = pinnedClientTrustResolver.resolve(mirthProperties, configurationController::getServerCertificateThumbprint); + if (StringUtils.isNotBlank(pinnedClientTrust)) { + Element pinnedClientTrustArgumentElement = document.createElement("argument"); + pinnedClientTrustArgumentElement.setTextContent("-trust"); + applicationDescElement.appendChild(pinnedClientTrustArgumentElement); + + Element pinnedClientTrustValueArgumentElement = document.createElement("argument"); + pinnedClientTrustValueArgumentElement.setTextContent(pinnedClientTrust); + applicationDescElement.appendChild(pinnedClientTrustValueArgumentElement); + } + return document; } diff --git a/server/src/main/java/com/mirth/connect/server/util/PinnedClientTrustResolver.java b/server/src/main/java/com/mirth/connect/server/util/PinnedClientTrustResolver.java new file mode 100644 index 0000000000..34fe328bde --- /dev/null +++ b/server/src/main/java/com/mirth/connect/server/util/PinnedClientTrustResolver.java @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.server.util; + +import java.util.concurrent.Callable; +import java.util.StringJoiner; + +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.commons.lang3.StringUtils; + +/** Utility class for interpreting the "administrator.pinnedclienttrust" property */ +public class PinnedClientTrustResolver { + private static final String DEFAULT_VALUE = "pki,webserver"; + private static final String PROPERTY_NAME = "administrator.pinnedclienttrust"; + + public String resolve(PropertiesConfiguration mirthProperties, Callable getServerCert) throws Exception { + String pinnedClientTrust = StringUtils.defaultIfBlank( + StringUtils.trimToNull(mirthProperties.getString(PROPERTY_NAME)), DEFAULT_VALUE); + + var joiner = new StringJoiner(","); + for (String token : pinnedClientTrust.split(",")) { + String cleaned = token.strip(); + if (cleaned.isEmpty()) continue; + + if (cleaned.equalsIgnoreCase("webserver")) { + joiner.add(getServerCert.call()); + } else { + joiner.add(cleaned); + } + } + return joiner.toString(); + } +} diff --git a/server/src/test/java/com/mirth/connect/client/core/PinnedClientTrustConfigTest.java b/server/src/test/java/com/mirth/connect/client/core/PinnedClientTrustConfigTest.java new file mode 100644 index 0000000000..5eabd3b8fd --- /dev/null +++ b/server/src/test/java/com/mirth/connect/client/core/PinnedClientTrustConfigTest.java @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class PinnedClientTrustConfigTest { + + private static final String THUMBPRINT = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + @Test + public void testParseDefaultValue() { + PinnedClientTrustConfig config = PinnedClientTrustConfig.parse(null); + + assertTrue(config.isPkiTrusted()); + assertTrue(config.isLocalhostTrusted()); + assertFalse(config.isTrustAllCertificates()); + assertTrue(config.getPinnedThumbprints().isEmpty()); + } + + @Test + public void testParseMixedValues() { + PinnedClientTrustConfig config = PinnedClientTrustConfig.parse("pki, localhost, " + THUMBPRINT.toUpperCase()); + + assertTrue(config.isPkiTrusted()); + assertTrue(config.isLocalhostTrusted()); + assertFalse(config.isTrustAllCertificates()); + assertTrue(config.getPinnedThumbprints().contains(THUMBPRINT)); + } + + @Test + public void testParseTrustAll() { + PinnedClientTrustConfig config = PinnedClientTrustConfig.parse("insecure_trust_all_certs"); + + assertFalse(config.isPkiTrusted()); + assertFalse(config.isLocalhostTrusted()); + assertTrue(config.isTrustAllCertificates()); + } + + @Test + public void testParseThumbprintDisablesPkiUnlessExplicitlyEnabled() { + PinnedClientTrustConfig config = PinnedClientTrustConfig.parse(THUMBPRINT); + + assertFalse(config.isPkiTrusted()); + assertFalse(config.getPinnedThumbprints().isEmpty()); + } + + @Test(expected = IllegalArgumentException.class) + public void testRejectInvalidToken() { + PinnedClientTrustConfig.parse("nopki"); + } + + @Test + public void testRecognizeLocalhostHosts() { + LocalhostTrustStrategy strategy = new LocalhostTrustStrategy("localhost"); + + assertTrue(strategy.verify("localhost", null)); + assertTrue(strategy.verify("127.0.0.1", null)); + assertTrue(strategy.verify("::1", null)); + assertFalse(strategy.verify("example.com", null)); + } +} \ No newline at end of file diff --git a/server/src/test/java/com/mirth/connect/client/core/PinnedClientTrustSslContextFactoryTest.java b/server/src/test/java/com/mirth/connect/client/core/PinnedClientTrustSslContextFactoryTest.java new file mode 100644 index 0000000000..4dfa6d8f19 --- /dev/null +++ b/server/src/test/java/com/mirth/connect/client/core/PinnedClientTrustSslContextFactoryTest.java @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package com.mirth.connect.client.core; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.SSLSession; + +import org.junit.Test; + +public class PinnedClientTrustSslContextFactoryTest { + + private static final byte[] CERT_BYTES = new byte[] { 97, 98, 99 }; + private static final String THUMBPRINT = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + private final PinnedClientTrustSslContextFactory factory = new PinnedClientTrustSslContextFactory(); + + @Test + public void testHostnameVerifierAllowsPinnedCertificateWithoutHostnameMatch() throws Exception { + HostnameVerifier verifier = factory.createHostnameVerifier(PinnedClientTrustConfig.parse(THUMBPRINT), "example.com"); + + assertTrue(verifier.verify("different-host", mockPinnedSession())); + } + + @Test + public void testHostnameVerifierAllowsLocalhostConnections() { + HostnameVerifier verifier = factory.createHostnameVerifier(PinnedClientTrustConfig.parse("localhost"), "localhost"); + + assertTrue(verifier.verify("localhost", mock(SSLSession.class))); + } + + @Test + public void testHostnameVerifierRejectsNonLocalhostWhenOnlyLocalhostIsAllowed() { + HostnameVerifier verifier = factory.createHostnameVerifier(PinnedClientTrustConfig.parse("localhost"), "example.com"); + + assertFalse(verifier.verify("example.com", mock(SSLSession.class))); + } + + @Test + public void testHostnameVerifierAllowsTrustAll() { + HostnameVerifier verifier = factory.createHostnameVerifier(PinnedClientTrustConfig.parse("insecure_trust_all_certs"), "example.com"); + + assertTrue(verifier.verify("example.com", mock(SSLSession.class))); + } + + @Test + public void testTrustStrategyAllowsPinnedCertificate() throws Exception { + PinnedCertificateTrustStrategy strategy = new PinnedCertificateTrustStrategy(PinnedClientTrustConfig.parse(THUMBPRINT).getPinnedThumbprints()); + + assertTrue(strategy.isTrusted(new X509Certificate[] { mockCertificate() }, "RSA")); + } + + @Test + public void testTrustStrategyAllowsLocalhostConnectionWithoutCertificateChecks() throws Exception { + LocalhostTrustStrategy strategy = new LocalhostTrustStrategy("127.0.0.1"); + + assertTrue(strategy.isTrusted(null, "RSA")); + } + + @Test + public void testTrustStrategyDefersWhenCertificateIsNotPinned() throws Exception { + PinnedCertificateTrustStrategy strategy = new PinnedCertificateTrustStrategy(PinnedClientTrustConfig.parse(THUMBPRINT).getPinnedThumbprints()); + + assertFalse(strategy.isTrusted(new X509Certificate[] { mockDifferentCertificate() }, "RSA")); + } + + @Test + public void testTrustStrategyRejectsPinnedCertificateBehindAnotherLeaf() throws Exception { + PinnedCertificateTrustStrategy strategy = new PinnedCertificateTrustStrategy(PinnedClientTrustConfig.parse(THUMBPRINT).getPinnedThumbprints()); + // An attacker may append the (public) pinned certificate behind its own leaf. + X509Certificate attackerLeaf = mockDifferentCertificate(); + X509Certificate pinned = mockCertificate(); + + assertFalse(strategy.isTrusted(new X509Certificate[] { attackerLeaf, pinned }, "RSA")); + } + + @Test + public void testHostnameVerifierRejectsPinnedCertificateBehindAnotherLeaf() throws Exception { + HostnameVerifier verifier = factory.createHostnameVerifier(PinnedClientTrustConfig.parse(THUMBPRINT), "example.com"); + X509Certificate attackerLeaf = mockDifferentCertificate(); + X509Certificate pinned = mockCertificate(); + SSLSession session = mock(SSLSession.class); + when(session.getPeerCertificates()).thenReturn(new Certificate[] { attackerLeaf, pinned }); + + assertFalse(verifier.verify("different-host", session)); + } + + private SSLSession mockPinnedSession() throws Exception { + SSLSession session = mock(SSLSession.class); + Certificate certificate = mockCertificate(); + when(session.getPeerCertificates()).thenReturn(new Certificate[] { certificate }); + return session; + } + + private X509Certificate mockCertificate() throws Exception { + X509Certificate certificate = mock(X509Certificate.class); + when(certificate.getEncoded()).thenReturn(CERT_BYTES); + return certificate; + } + + private X509Certificate mockDifferentCertificate() throws Exception { + X509Certificate certificate = mock(X509Certificate.class); + when(certificate.getEncoded()).thenReturn(new byte[] { 100, 101, 102 }); + return certificate; + } +} \ No newline at end of file diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineClient.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineClient.java new file mode 100644 index 0000000000..79c72547de --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineClient.java @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Runs the packaged command-line client ({@code mirth-cli-launcher.jar}) as a child + * process. + * + *

The launcher resolves {@code cli-lib/} and {@code ./extensions} relative to its + * working directory and reads its log configuration from {@code conf/} via its manifest + * {@code Class-Path}, so it only runs from a staged distribution. + */ +final class CommandLineClient { + + /** Distribution root; ci/run-harness.sh points this at the tree in the harness image. */ + private static final Path HOME = Path.of(System.getProperty("oie.cliHome", "/opt/engine")); + + private static final long TIMEOUT_SECONDS = HarnessConfig.TIMEOUT.toSeconds(); + + private CommandLineClient() { + } + + /** Runs the CLI against the server under test, feeding it {@code statements} as a script. */ + static Result runScript(String... statements) throws Exception { + return runScriptAgainst(HarnessConfig.BASE_URL, HarnessConfig.PINNED_CLIENT_TRUST, statements); + } + + /** + * As {@link #runScript}, against an address and {@code -trust} configuration of the + * caller's choosing. The stack's server presents a self-signed certificate for a + * hostname no certificate could match, so every run has to say how to trust it: the + * CLI's own default (pki,localhost) reaches a server on its own machine, not this one. + */ + static Result runScriptAgainst(String address, String trust, String... statements) + throws Exception { + Path script = Files.createTempFile("oie-cli-", ".script"); + try { + Files.writeString(script, String.join("\n", statements) + "\n", StandardCharsets.UTF_8); + return run("-a", address, "-u", HarnessConfig.USERNAME, "-p", HarnessConfig.PASSWORD, + "-trust", trust, "-s", script.toString()); + } finally { + Files.deleteIfExists(script); + } + } + + /** Runs the CLI with exactly {@code args}. */ + static Result run(String... args) throws Exception { + Path launcher = HOME.resolve("mirth-cli-launcher.jar"); + if (!Files.isRegularFile(launcher) || !Files.isRegularFile(HOME.resolve("cli-lib/mirth-cli.jar"))) { + throw new AssertionError("No command-line client staged at " + HOME + + "; the harness image is built to carry one (see the Dockerfile" + + " smoketest-harness target) and ci/run-harness.sh sets oie.cliHome."); + } + + List command = new ArrayList<>(); + command.add(Path.of(System.getProperty("java.home"), "bin", "java").toString()); + command.add("-jar"); + command.add(launcher.getFileName().toString()); + command.addAll(Arrays.asList(args)); + + // A file rather than a pipe: reading a pipe would block forever on a CLI that + // never exits, so the timeout below could never fire. + Path outputFile = Files.createTempFile("oie-cli-", ".out"); + try { + Process process = new ProcessBuilder(command) + .directory(HOME.toFile()) + .redirectErrorStream(true) + .redirectOutput(outputFile.toFile()) + .start(); + + // Without -s the CLI reads stdin; close it so such a run cannot wait forever. + process.getOutputStream().close(); + + boolean exited; + try { + exited = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } finally { + process.destroyForcibly(); + } + + String output = Files.readString(outputFile, StandardCharsets.UTF_8); + if (!exited) { + throw new AssertionError("The CLI did not exit within " + TIMEOUT_SECONDS + "s: " + + String.join(" ", command) + "\n--- output ---\n" + output + "--- end output ---"); + } + return new Result(command, process.exitValue(), output); + } finally { + Files.deleteIfExists(outputFile); + } + } + + record Result(List command, int exitCode, String output) { + + @Override + public String toString() { + return "exit=" + exitCode + " from " + String.join(" ", command) + + "\n--- output ---\n" + output + "--- end output ---"; + } + } +} diff --git a/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineInterfaceTest.java b/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineInterfaceTest.java new file mode 100644 index 0000000000..25234bbb0f --- /dev/null +++ b/smoketest/src/test/java/org/openintegrationengine/smoketest/CommandLineInterfaceTest.java @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Mitch Gaffigan + +package org.openintegrationengine.smoketest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Smoke tests for the packaged command-line client, run as a child process against the + * live server. + * + *

Assertions are on output rather than on the exit code: the CLI exits 0 whether or + * not it could log in or run the statements it was given. + */ +@DisplayName("Command-line client") +class CommandLineInterfaceTest { + + @Test + @DisplayName("prints usage for -h without contacting a server") + void printsUsageForHelp() throws Exception { + CommandLineClient.Result result = CommandLineClient.run("-h"); + + assertEquals(0, result.exitCode(), () -> "-h should exit 0, got " + result); + assertTrue(result.output().contains("usage: Shell"), () -> "no usage text in " + result); + + for (String option : List.of("-a

", "-u ", "-p ", + "-s