Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,10 @@ def serverExtensions = [
server: [includes: ['com/mirth/connect/plugins/datapruner/**'],
excludes: ['com/mirth/connect/plugins/datapruner/DataPrunerServletInterface.class']]],

[name: 'passwordscanner', pkg: 'com/mirth/connect/plugins/passwordscanner', lib: 'passwordscanner',
shared: null,
server: [includes: ['com/mirth/connect/plugins/passwordscanner/**']]],

[name: 'mllpmode', pkg: 'com/mirth/connect/plugins/mllpmode', lib: 'mllpmode',
shared: [includes: ['com/mirth/connect/plugins/mllpmode/MLLPModeProperties.class']],
server: [includes: ['com/mirth/connect/plugins/mllpmode/**'],
Expand Down Expand Up @@ -857,7 +861,7 @@ def zippedExtensions = ['jms', 'jdbc', 'dicom', 'http', 'doc', 'smtp', 'tcp', 'f
'destinationsetfilter', 'serverlog', 'datapruner', 'javascriptstep',
'mapper', 'messagebuilder', 'scriptfilestep', 'rulebuilder',
'javascriptrule', 'dicomviewer', 'pdfviewer', 'textviewer', 'httpauth',
'imageviewer', 'globalmapviewer']
'imageviewer', 'globalmapviewer', 'passwordscanner']

// The zip names carry the version, so clear the directory first the way
// Ant did; otherwise zips from previous versions accumulate.
Expand Down
2 changes: 2 additions & 0 deletions server/conf/log4j2.properties
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ logger.recoveryTask.name = com.mirth.connect.donkey.server.channel.RecoveryTask
logger.recoveryTask.level = INFO
logger.fileReceiver.name = com.mirth.connect.connectors.file.FileReceiver
logger.fileReceiver.level = WARN
logger.passwordScanner.name = com.mirth.connect.plugins.passwordscanner
logger.passwordScanner.level = INFO

# Mirth Connect channel logging
logger.transformer.name = transformer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: Mitch Gaffigan <mitch@gaffigan.net>

package com.mirth.connect.plugins.passwordscanner;

import java.util.ArrayList;
import java.util.List;

import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import com.mirth.connect.client.core.ControllerException;
import com.mirth.connect.model.Credentials;
import com.mirth.connect.model.User;
import com.mirth.connect.server.controllers.ControllerFactory;
import com.mirth.connect.server.controllers.UserController;
import com.mirth.connect.server.util.Pre22PasswordChecker;

/**
* Checks each user's current password against a handful of trivially guessable passwords and logs a
* warning for every match. The classic case is a stock admin/admin account.
*/
public class PasswordScanner {

/** Pause between checks. Nothing here is urgent, so stay out of the way of real work. */
private static final long PAUSE_MILLIS = 1000;

private Logger logger = LogManager.getLogger(this.getClass());
private UserController userController = ControllerFactory.getFactory().createUserController();
private Wordlist wordlist;

public PasswordScanner(Wordlist wordlist) {
this.wordlist = wordlist;
}

public void scan() throws ControllerException, InterruptedException {
for (User user : userController.getAllUsers()) {
if (Thread.currentThread().isInterrupted()) {
return;
}

if (hasTrivialPassword(user)) {
logger.warn("User \"{}\" has a trivially guessable password and should change it immediately.", user.getUsername());
}
}
}

private boolean hasTrivialPassword(User user) throws ControllerException, InterruptedException {
List<Credentials> credentials = userController.getUserCredentials(user.getId());
if (credentials.isEmpty()) {
return false;
}

// Credentials are ordered newest first, so the first entry is the password in use.
String hash = credentials.get(0).getPassword();

// Check against the wordlist
List<String> candidates = wordlist.getPasswords();
for (String candidate : candidates) {
if (matches(candidate, hash)) {
return true;
}
}

// Check against the username, unless it's already in the wordlist
if (!candidates.contains(user.getUsername())) {
return matches(user.getUsername(), hash);
}

return false;
}

private boolean matches(String plainPassword, String hash) {
try {
// Throttle to avoid heavy CPU load. Hashing is expensive by design.
Thread.sleep(PAUSE_MILLIS);

if (Pre22PasswordChecker.isPre22Hash(hash)) {
return Pre22PasswordChecker.checkPassword(plainPassword, hash);
}

return userController.checkPassword(plainPassword, hash);
} catch (Exception e) {
logger.debug("Unable to check a password against its hash.", e);
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: Mitch Gaffigan <mitch@gaffigan.net>

package com.mirth.connect.plugins.passwordscanner;

import java.util.Properties;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;

import org.apache.commons.lang3.math.NumberUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import com.mirth.connect.model.ExtensionPermission;
import com.mirth.connect.plugins.ServicePlugin;

/**
* Periodically runs the {@link PasswordScanner} on a background thread.
*/
public class PasswordScannerService implements ServicePlugin {

public static final String PLUGINPOINT = "Password Scanner";

private static final String INTERVAL_SECONDS = "intervalSeconds";
private static final int DEFAULT_INTERVAL_SECONDS = (int) TimeUnit.DAYS.toSeconds(1);
private static final String PASSWORDS = "passwords";

/** Delay the first scan so it doesn't compete with the rest of server startup. */
private static final long STARTUP_DELAY_SECONDS = 30;
private static final long MIN_INTERVAL_SECONDS = 300;

private Logger logger = LogManager.getLogger(this.getClass());
private ScheduledExecutorService executor;
private int intervalSeconds = DEFAULT_INTERVAL_SECONDS;
private volatile Wordlist wordlist = Wordlist.DEFAULT;

@Override
public String getPluginPointName() {
return PLUGINPOINT;
}

@Override
public void init(Properties properties) {
readProperties(properties);
}

@Override
public synchronized void update(Properties properties) {
stop();
readProperties(properties);
start();
}

@Override
public synchronized void start() {
if (executor != null) {
return;
}

executor = Executors.newSingleThreadScheduledExecutor(new ThreadFactory() {
@Override
public Thread newThread(Runnable runnable) {
Thread thread = new Thread(runnable, PLUGINPOINT);
thread.setDaemon(true);
// Scanning passwords is housekeeping; it should always lose to real work.
thread.setPriority(Thread.MIN_PRIORITY);
return thread;
}
});

executor.scheduleWithFixedDelay(new Runnable() {
@Override
public void run() {
scan();
}
}, STARTUP_DELAY_SECONDS, intervalSeconds, TimeUnit.SECONDS);
}

@Override
public synchronized void stop() {
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}

@Override
public Properties getDefaultProperties() {
Properties properties = new Properties();
properties.put(INTERVAL_SECONDS, Integer.toString(DEFAULT_INTERVAL_SECONDS));
properties.put(PASSWORDS, Wordlist.DEFAULT.toString());
return properties;
}

@Override
public ExtensionPermission[] getExtensionPermissions() {
return new ExtensionPermission[0];
}

private synchronized void readProperties(Properties properties) {
int seconds = NumberUtils.toInt(properties.getProperty(INTERVAL_SECONDS), DEFAULT_INTERVAL_SECONDS);
if (seconds < MIN_INTERVAL_SECONDS) {
logger.warn("Invalid {} value \"{}\".", INTERVAL_SECONDS, properties.getProperty(INTERVAL_SECONDS));
} else {
this.intervalSeconds = seconds;
}

Wordlist list = Wordlist.parse(properties.getProperty(PASSWORDS));
if (!list.isEmpty()) {
this.wordlist = list;
}
}

/**
* A scheduled task that throws is never run again, so nothing may escape here.
*/
private void scan() {
try {
new PasswordScanner(wordlist).scan();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.debug("Password scan interrupted.");
} catch (Throwable t) {
logger.error("Error scanning for trivial user passwords.", t);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: Mitch Gaffigan <mitch@gaffigan.net>

package com.mirth.connect.plugins.passwordscanner;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

import org.apache.commons.lang3.StringUtils;

/**
* The passwords the scanner checks, held in a plugin property as a single comma-separated string.
* Escape , and \ characters with a backslash. Leading and trailing whitespace is ignored.
*/
public class Wordlist {

private static final char SEPARATOR = ',';
private static final char ESCAPE = '\\';

public static final Wordlist DEFAULT = new Wordlist(Arrays.asList(
"admin", "password", "Password1", "changeme", "mirth", "letmein",
"welcome", "123456", "admin123", "password1"
));

private List<String> passwords;

public Wordlist(List<String> passwords) {
this.passwords = Collections.unmodifiableList(new ArrayList<String>(passwords));
}

/** The passwords to check, in the order they were configured. */
public List<String> getPasswords() {
return passwords;
}

public boolean isEmpty() {
return passwords.isEmpty();
}

public static Wordlist parse(String value) {
List<String> passwords = new ArrayList<String>();
StringBuilder password = new StringBuilder();
/*
* Index just past the last character that has to be kept. Trailing whitespace sits beyond
* it and is dropped when the password is cut, unless it was escaped.
*/
int end = 0;
boolean escaped = false;

for (char c : StringUtils.defaultString(value).toCharArray()) {
if (escaped) {
password.append(c);
end = password.length();
escaped = false;
} else if (c == ESCAPE) {
escaped = true;
} else if (c == SEPARATOR) {
addPassword(passwords, password, end);
password.setLength(0);
end = 0;
} else if (Character.isWhitespace(c)) {
// Leading whitespace never makes it into the password at all.
if (password.length() > 0) {
password.append(c);
}
} else {
password.append(c);
end = password.length();
}
}

if (escaped) {
// A dangling escape at the very end can only have meant a literal backslash.
password.append(ESCAPE);
end = password.length();
}

addPassword(passwords, password, end);
return new Wordlist(passwords);
}

private static void addPassword(List<String> passwords, StringBuilder password, int end) {
if (end > 0) {
passwords.add(password.substring(0, end));
}
}

/** Renders a property value that {@link #parse} reads back as this word list. */
@Override
public String toString() {
StringBuilder value = new StringBuilder();

for (String password : passwords) {
if (value.length() > 0) {
value.append(SEPARATOR);
}

for (int i = 0; i < password.length(); i++) {
char c = password.charAt(i);
boolean edge = (i == 0 || i == password.length() - 1);

// Interior whitespace survives parsing untouched, so only the edges need escaping.
if (c == SEPARATOR || c == ESCAPE || (edge && Character.isWhitespace(c))) {
value.append(ESCAPE);
}

value.append(c);
}
}

return value.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<pluginMetaData path="passwordscanner">
<name>Password Scanner</name>
<author>Open Integration Engine</author>
<pluginVersion>@mirthversion</pluginVersion>
<mirthVersion>@mirthversion</mirthVersion>
<url>https://openintegrationengine.org</url>
<description>Checks user accounts for trivially guessable passwords and logs a warning for each one it finds.</description>
<serverClasses>
<string>com.mirth.connect.plugins.passwordscanner.PasswordScannerService</string>
</serverClasses>
<library type="SERVER" path="passwordscanner-server.jar" />
</pluginMetaData>
Loading
Loading