From 49865f173dda065a269a0cfcb3f6c8a54678db84 Mon Sep 17 00:00:00 2001 From: Mitch Gaffigan Date: Sat, 29 Aug 2026 14:57:21 -0500 Subject: [PATCH 1/2] Fix PasswordRequirementsTest not running Signed-off-by: Mitch Gaffigan --- ...wordRequirementsTests.java => PasswordRequirementsTest.java} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename server/src/test/java/com/mirth/connect/server/util/{PasswordRequirementsTests.java => PasswordRequirementsTest.java} (97%) diff --git a/server/src/test/java/com/mirth/connect/server/util/PasswordRequirementsTests.java b/server/src/test/java/com/mirth/connect/server/util/PasswordRequirementsTest.java similarity index 97% rename from server/src/test/java/com/mirth/connect/server/util/PasswordRequirementsTests.java rename to server/src/test/java/com/mirth/connect/server/util/PasswordRequirementsTest.java index e21b4a683e..66494fdd96 100644 --- a/server/src/test/java/com/mirth/connect/server/util/PasswordRequirementsTests.java +++ b/server/src/test/java/com/mirth/connect/server/util/PasswordRequirementsTest.java @@ -14,7 +14,7 @@ import com.mirth.connect.client.core.ControllerException; import com.mirth.connect.model.PasswordRequirements; -public class PasswordRequirementsTests extends TestCase { +public class PasswordRequirementsTest extends TestCase { protected void setUp() throws Exception { super.setUp(); From 7385ea74d332630a6b29d6f0d5ad359fad4f3476 Mon Sep 17 00:00:00 2001 From: Mitch Gaffigan Date: Sat, 29 Aug 2026 14:57:34 -0500 Subject: [PATCH 2/2] Add HIBP password checker Signed-off-by: Mitch Gaffigan --- server/conf/mirth.properties | 3 + .../connect/model/PasswordRequirements.java | 10 +++ .../server/util/BreachedPasswordChecker.java | 68 +++++++++++++++++++ .../util/PasswordRequirementsChecker.java | 11 ++- server/src/test/resources/mirth.properties | 1 + 5 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 server/src/main/java/com/mirth/connect/server/util/BreachedPasswordChecker.java diff --git a/server/conf/mirth.properties b/server/conf/mirth.properties index 6b71ebdbf3..175c10f9aa 100644 --- a/server/conf/mirth.properties +++ b/server/conf/mirth.properties @@ -20,6 +20,9 @@ password.expiration = 0 password.graceperiod = 0 password.reuseperiod = 0 password.reuselimit = 0 +# Check new passwords against the Have I Been Pwned range API, as recommended by +# NIST SP 800-63B (see https://haveibeenpwned.com/NIST). Secured by k-anonymity. +password.breachedurl = https://api.pwnedpasswords.com/range/ # Only used for migration purposes, do not modify version = 4.6.0 diff --git a/server/src/main/java/com/mirth/connect/model/PasswordRequirements.java b/server/src/main/java/com/mirth/connect/model/PasswordRequirements.java index c4276ff578..28715ae717 100644 --- a/server/src/main/java/com/mirth/connect/model/PasswordRequirements.java +++ b/server/src/main/java/com/mirth/connect/model/PasswordRequirements.java @@ -29,6 +29,7 @@ public class PasswordRequirements implements Serializable { private int gracePeriod; private int reusePeriod; private int reuseLimit; + private String breachedUrl; public PasswordRequirements() { this.minLength = 0; @@ -42,6 +43,7 @@ public PasswordRequirements() { this.gracePeriod = 0; this.reusePeriod = 0; this.reuseLimit = 0; + this.breachedUrl = ""; } public PasswordRequirements(int minLength, int minUpper, int minLower, int minNumeric, int minSpecial, int retryLimit, int lockoutPeriod, int expiration, int gracePeriod, int reusePeriod, int reuseLimit) { @@ -145,4 +147,12 @@ public int getReuseLimit() { public void setReuseLimit(int reuseLimit) { this.reuseLimit = reuseLimit; } + + public String getBreachedUrl() { + return breachedUrl; + } + + public void setBreachedUrl(String breachedUrl) { + this.breachedUrl = breachedUrl; + } } diff --git a/server/src/main/java/com/mirth/connect/server/util/BreachedPasswordChecker.java b/server/src/main/java/com/mirth/connect/server/util/BreachedPasswordChecker.java new file mode 100644 index 0000000000..62d9c3bb97 --- /dev/null +++ b/server/src/main/java/com/mirth/connect/server/util/BreachedPasswordChecker.java @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2025 Mitch Gaffigan + +package com.mirth.connect.server.util; + +import java.util.Locale; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; + +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import com.mirth.connect.util.HttpUtil; +import com.mirth.connect.util.MirthSSLUtil; + +/** + * Checks a candidate password against a Have I Been Pwned style range API + */ +public class BreachedPasswordChecker { + + private static final Logger logger = LogManager.getLogger(BreachedPasswordChecker.class); + + private static final int REQUEST_TIMEOUT = 3000; + + private BreachedPasswordChecker() { + // nop, static + } + + /** Determines whether the password appears in a known breach. */ + public static boolean checkBreached(String plainPassword, String rangeUrl) { + if (StringUtils.isBlank(rangeUrl)) { + throw new IllegalArgumentException("rangeUrl must not be blank"); + } + + // HIBP uses the first 20 bits of the SHA-1 hash to limit disclosure (k-anonymity) + String hash = DigestUtils.sha1Hex(plainPassword).toUpperCase(Locale.ROOT); + String prefix = hash.substring(0, 5); + String suffix = hash.substring(5); + + // getOrEmpty returns an empty string on any failure, so the check fails open + String response = getOrEmpty(StringUtils.appendIfMissing(rangeUrl, "/") + prefix); + return response.contains(suffix); + } + + /** Insulate password checks from network and service failures */ + private static String getOrEmpty(String url) { + FutureTask task = new FutureTask(() -> HttpUtil.executeGetRequest(url, + REQUEST_TIMEOUT, true, MirthSSLUtil.DEFAULT_HTTPS_CLIENT_PROTOCOLS, MirthSSLUtil.DEFAULT_HTTPS_CIPHER_SUITES)); + Thread thread = new Thread(task, "Breached Password Checker"); + thread.setDaemon(true); + thread.start(); + + try { + return task.get(REQUEST_TIMEOUT, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ""; + } catch (Exception e) { + logger.warn("The breached password service at " + url + " did not respond within " + + REQUEST_TIMEOUT + "ms. Skipping the breached password check."); + return ""; + } finally { + task.cancel(true); + } + } +} diff --git a/server/src/main/java/com/mirth/connect/server/util/PasswordRequirementsChecker.java b/server/src/main/java/com/mirth/connect/server/util/PasswordRequirementsChecker.java index a099588e24..c6e46e1851 100644 --- a/server/src/main/java/com/mirth/connect/server/util/PasswordRequirementsChecker.java +++ b/server/src/main/java/com/mirth/connect/server/util/PasswordRequirementsChecker.java @@ -46,6 +46,8 @@ public class PasswordRequirementsChecker implements Serializable { private static final String PASSWORD_MUST_CONTAIN_AN_UPPERCASE_LETTER = "Password must contain %d uppercase letter(s)"; private static final String PASSWORD_MUST_NOT_CONTAIN_AN_UPPERCASE_LETTER = "Password not must contain an uppercase letter"; + private static final String PASSWORD_HAS_BEEN_BREACHED = "Password has appeared in a known data breach"; + private static final String PASSWORD_MINLENGTH = "password.minlength"; private static final String PASSWORD_MIN_NUMERIC = "password.minnumeric"; private static final String PASSWORD_MIN_LOWER = "password.minlower"; @@ -57,6 +59,7 @@ public class PasswordRequirementsChecker implements Serializable { private static final String PASSWORD_LOCKOUT_PERIOD = "password.lockoutperiod"; private static final String PASSWORD_REUSE_PERIOD = "password.reuseperiod"; private static final String PASSWORD_REUSE_LIMIT = "password.reuselimit"; + private static final String PASSWORD_BREACHED_URL = "password.breachedurl"; private static PasswordRequirementsChecker instance = null; @@ -88,6 +91,7 @@ public PasswordRequirements loadPasswordRequirements(PropertiesConfiguration sec passwordRequirements.setLockoutPeriod(securityProperties.getInt(PASSWORD_LOCKOUT_PERIOD, 0)); passwordRequirements.setReusePeriod(securityProperties.getInt(PASSWORD_REUSE_PERIOD, 0)); passwordRequirements.setReuseLimit(securityProperties.getInt(PASSWORD_REUSE_LIMIT, 0)); + passwordRequirements.setBreachedUrl(securityProperties.getString(PASSWORD_BREACHED_URL, "")); return passwordRequirements; } @@ -123,10 +127,15 @@ public List doesPasswordMeetRequirements(Integer userId, String plainPas addResult(resultList, checkReusePeriod(previousCredentials, plainPassword, passwordRequirements.getReusePeriod())); addResult(resultList, checkReuseLimit(previousCredentials, plainPassword, passwordRequirements.getReuseLimit())); } catch (ControllerException e) { - addResult(resultList, "There was an error checking against previous user passwords."); + addResult(resultList, "There was an error checking against previous user passwords"); } } + String breachedUrl = passwordRequirements.getBreachedUrl(); + if (resultList.isEmpty() && StringUtils.isNotBlank(breachedUrl)) { + addResult(resultList, BreachedPasswordChecker.checkBreached(plainPassword, breachedUrl) ? PASSWORD_HAS_BEEN_BREACHED : null); + } + if (resultList.size() == 0) { return null; } else { diff --git a/server/src/test/resources/mirth.properties b/server/src/test/resources/mirth.properties index cbc3a21cec..2483200f53 100644 --- a/server/src/test/resources/mirth.properties +++ b/server/src/test/resources/mirth.properties @@ -20,6 +20,7 @@ password.expiration = 0 password.graceperiod = 0 password.reuseperiod = 0 password.reuselimit = 0 +password.breachedurl = # Only used for migration purposes, do not modify version = 4.6.0