diff --git a/.gitignore b/.gitignore index e42187762ba..f0779e6424e 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ store/.pmd store/.ruleset eclipse-ivysettings.xml store/tmp/ +common-passwords.txt diff --git a/build.xml b/build.xml index a1aaaee681b..9449da7552a 100644 --- a/build.xml +++ b/build.xml @@ -6,6 +6,7 @@ + diff --git a/common/src/java-test/com/zimbra/common/util/UnmodifiableBloomFilterTest.java b/common/src/java-test/com/zimbra/common/util/UnmodifiableBloomFilterTest.java new file mode 100644 index 00000000000..34381378900 --- /dev/null +++ b/common/src/java-test/com/zimbra/common/util/UnmodifiableBloomFilterTest.java @@ -0,0 +1,124 @@ +/* + * ***** BEGIN LICENSE BLOCK ***** + * Zimbra Collaboration Suite Server + * Copyright (C) 2019 Synacor, Inc. + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software Foundation, + * version 2 of the License. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + * ***** END LICENSE BLOCK ***** + */ +package com.zimbra.common.util; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +public class UnmodifiableBloomFilterTest { + + protected UnmodifiableBloomFilter bloomFilter = UnmodifiableBloomFilter + .createFilterFromFile("src/java-test/common-passwords.txt"); + + @Before + public void setUp() { + assertTrue(bloomFilter.isInitialized()); + assertFalse(bloomFilter.isDisabled()); + } + + @Test + public void testMightContain() { + assertTrue(bloomFilter.isInitialized()); + assertTrue(bloomFilter.mightContain("test123")); + assertTrue(bloomFilter.mightContain("hunter2")); + } + + @Test + public void testMightContainFalse() { + assertTrue(bloomFilter.isInitialized()); + assertFalse(bloomFilter.mightContain("not-in-the-test-file")); + } + + @Test + public void testCreateFilterFromMissingFile() { + UnmodifiableBloomFilter missingFileFilter = UnmodifiableBloomFilter + .createFilterFromFile("src/java-test/fake-file-not-found"); + // expect to immediately initialize + assertTrue(missingFileFilter.isInitialized()); + assertTrue(missingFileFilter.isDisabled()); + assertFalse(missingFileFilter.mightContain("test123")); + } + + @Test + public void testCreateFilterFromEmptySpecifiedFile() { + UnmodifiableBloomFilter noFileFilter = UnmodifiableBloomFilter + .createFilterFromFile(""); + // expect to immediately consider empty file as initialized + assertTrue(noFileFilter.isInitialized()); + assertTrue(noFileFilter.isDisabled()); + assertFalse(noFileFilter.mightContain("test123")); + } + + @Test + public void testCreateFilterFromNullSpecifiedFile() { + UnmodifiableBloomFilter noFileFilter = UnmodifiableBloomFilter + .createFilterFromFile(null); + // expect to immediately consider null file as initialized + assertTrue(noFileFilter.isInitialized()); + assertTrue(noFileFilter.isDisabled()); + assertFalse(noFileFilter.mightContain("test123")); + } + + @Test + public void testMightContainLazyLoad() { + UnmodifiableBloomFilter lazyFilter = UnmodifiableBloomFilter + .createLazyFilterFromFile("src/java-test/common-passwords.txt"); + // expect to initialize on demand + assertFalse(lazyFilter.isInitialized()); + assertFalse(lazyFilter.isDisabled()); + assertTrue(lazyFilter.mightContain("test123")); + assertTrue(lazyFilter.mightContain("hunter2")); + assertTrue(lazyFilter.isInitialized()); + assertFalse(lazyFilter.isDisabled()); + } + + @Test + public void testCreateLazyFilterFromMissingFile() { + UnmodifiableBloomFilter missingFileFilter = UnmodifiableBloomFilter + .createLazyFilterFromFile("src/java-test/fake-file-not-found"); + // expect to initialize on demand + assertFalse(missingFileFilter.isInitialized()); + assertFalse(missingFileFilter.mightContain("test123")); + assertTrue(missingFileFilter.isInitialized()); + // file not found results in disabled instance + assertTrue(missingFileFilter.isDisabled()); + } + + @Test + public void testCreateLazyFilterFromEmptySpecifiedFile() { + UnmodifiableBloomFilter noFileFilter = UnmodifiableBloomFilter + .createLazyFilterFromFile(""); + // expect to immediately consider empty file as initialized + assertTrue(noFileFilter.isInitialized()); + assertTrue(noFileFilter.isDisabled()); + assertFalse(noFileFilter.mightContain("test123")); + } + + @Test + public void testCreateLazyFilterFromNullSpecifiedFile() { + UnmodifiableBloomFilter noFileFilter = UnmodifiableBloomFilter + .createLazyFilterFromFile(null); + // expect to immediately consider null file as initialized + assertTrue(noFileFilter.isInitialized()); + assertTrue(noFileFilter.isDisabled()); + assertFalse(noFileFilter.mightContain("test123")); + } + +} diff --git a/common/src/java-test/common-passwords.txt b/common/src/java-test/common-passwords.txt new file mode 100644 index 00000000000..5141a614c24 --- /dev/null +++ b/common/src/java-test/common-passwords.txt @@ -0,0 +1,14 @@ +test123 +bad-pass +hunter2 +abc123 +football +monkey +letmein +696969 +shadow +master +666666 +qwertyuiop +123321 +mustang \ No newline at end of file diff --git a/common/src/java/com/zimbra/common/localconfig/LC.java b/common/src/java/com/zimbra/common/localconfig/LC.java index 6e656498143..8ffc090a720 100644 --- a/common/src/java/com/zimbra/common/localconfig/LC.java +++ b/common/src/java/com/zimbra/common/localconfig/LC.java @@ -1436,6 +1436,12 @@ public enum PUBLIC_SHARE_VISIBILITY { samePrimaryDomain, all, none }; // to switch to Tika com.zimbra.cs.convert.TikaExtractionClient public static final KnownKey attachment_extraction_client_class = KnownKey.newKey("com.zimbra.cs.convert.LegacyConverterClient"); + // list file for blocking common passwords + public static final KnownKey common_passwords_txt = KnownKey.newKey("${zimbra_home}/conf/common-passwords.txt"); + + // enable blocking common passwords + public static final KnownKey zimbra_block_common_passwords_enabled = KnownKey.newKey(false); + static { // Automatically set the key name with the variable name. for (Field field : LC.class.getFields()) { diff --git a/common/src/java/com/zimbra/common/util/UnmodifiableBloomFilter.java b/common/src/java/com/zimbra/common/util/UnmodifiableBloomFilter.java new file mode 100644 index 00000000000..b7c39af8c6e --- /dev/null +++ b/common/src/java/com/zimbra/common/util/UnmodifiableBloomFilter.java @@ -0,0 +1,163 @@ +/* + * ***** BEGIN LICENSE BLOCK ***** + * Zimbra Collaboration Suite Server + * Copyright (C) 2019 Synacor, Inc. + * + * This program is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software Foundation, + * version 2 of the License. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU General Public License for more details. + * You should have received a copy of the GNU General Public License along with this program. + * If not, see . + * ***** END LICENSE BLOCK ***** + */ +package com.zimbra.common.util; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.LineNumberReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import com.google.common.hash.BloomFilter; +import com.google.common.hash.Funnels; + +public class UnmodifiableBloomFilter { + + private static final Double DEFAULT_TOLERANCE = 0.03; + private static final Integer DEFAULT_BUFFER_SIZE = 4096; + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private BloomFilter cache; + private boolean isInitialized = false; + private double tolerance; + private final String fileLocation; + + private UnmodifiableBloomFilter(String fileLocation, BloomFilter cache, double tolerance) { + this.fileLocation = fileLocation; + this.cache = cache; + this.tolerance = tolerance; + this.isInitialized = StringUtil.isNullOrEmpty(fileLocation) ? true : false; + } + + private UnmodifiableBloomFilter(BloomFilter cache) { + this.fileLocation = null; + this.cache = cache; + this.isInitialized = true; + } + + public boolean mightContain(E entry) { + ensureInitialized(); + return cache != null && cache.mightContain(entry); + } + + /** + * @return True if an attempt to load/set the inner cache was made + */ + public boolean isInitialized() { + return isInitialized; + } + + /** + * @return True if the instance is initialized but still null. + */ + public boolean isDisabled() { + return isInitialized && cache == null; + } + + @SuppressWarnings("unchecked") + private void ensureInitialized() { + if (isInitialized) { + return; + } + synchronized (fileLocation) { + if (isInitialized) { + return; + } + this.cache = (BloomFilter) loadFilter(new File(fileLocation), tolerance); + isInitialized = true; + } + } + + /** + * Wraps a bloom filter so it may not be modified. + * @param filter The filter to wrap + * @return Unmodifiable bloom filter + */ + public static UnmodifiableBloomFilter createFilter(BloomFilter filter) { + return new UnmodifiableBloomFilter(filter); + } + + /** + * @see UnmodifiableBloomFilter#createFilterFromFile(String, double) + */ + public static UnmodifiableBloomFilter createFilterFromFile(String fileLocation) { + return createFilterFromFile(fileLocation, DEFAULT_TOLERANCE); + } + + /** + * Creates an instance that will immediately initialize from file. + * @param fileLocation The file to load from + * @param tolerance The expected false positive tolerance (0.xx-1) + * @return A string instance that cannot be modified externally + */ + public static UnmodifiableBloomFilter createFilterFromFile(String fileLocation, double tolerance) { + UnmodifiableBloomFilter bloom = createLazyFilterFromFile(fileLocation, tolerance); + bloom.ensureInitialized(); + return bloom; + } + + /** + * @see UnmodifiableBloomFilter#createLazyFilterFromFile(String, double) + */ + public static UnmodifiableBloomFilter createLazyFilterFromFile(String fileLocation) { + return createLazyFilterFromFile(fileLocation, DEFAULT_TOLERANCE); + } + + /** + * Creates a bloom filter instance that will initialize from file on first use. + * @param fileLocation The file to load from + * @param tolerance The expected false positive tolerance (0.xx-1) + * @return A lazy loading string instance that cannot be modified externally + */ + public static UnmodifiableBloomFilter createLazyFilterFromFile(String fileLocation, double tolerance) { + // password filter file is unset, return disabled cache instance without warn + if (StringUtil.isNullOrEmpty(fileLocation)) { + return new UnmodifiableBloomFilter(null, null, tolerance); + } + return new UnmodifiableBloomFilter(fileLocation, null, tolerance); + } + + private static BloomFilter loadFilter(File file, Double tolerance) { + try ( + BufferedReader reader = new BufferedReader(new FileReader(file), DEFAULT_BUFFER_SIZE)) { + // determine entry count for accurate filter creation + long entryCount = countEntries(new FileReader(file)); + ZimbraLog.cache.debug("Creating bloom filter for file with %d entries", entryCount); + BloomFilter pendingCache = BloomFilter + .create(Funnels.stringFunnel(DEFAULT_CHARSET), entryCount, tolerance); + // fill the filter as we read + String st; + while ((st = reader.readLine()) != null) { + pendingCache.put(st); + } + return pendingCache; + } catch (IOException e) { + ZimbraLog.cache.warnQuietly("Unable to load bloom filter from file.", e); + // return an instance with disabled cache since we failed during read/load + return null; + } + } + + private static long countEntries(FileReader fileReader) throws IOException { + try (LineNumberReader reader = new LineNumberReader(fileReader, DEFAULT_BUFFER_SIZE)) { + reader.skip(Long.MAX_VALUE); + return reader.getLineNumber() + 1L; + } + } +} diff --git a/pkg-builder.pl b/pkg-builder.pl index 4280439d991..6e6128ba6bd 100755 --- a/pkg-builder.pl +++ b/pkg-builder.pl @@ -256,6 +256,7 @@ () cpy_file( "store-conf/conf/globs2.zimbra", "$stage_base_dir/opt/zimbra/conf/globs2.zimbra" ); cpy_file( "store-conf/conf/spnego_java_options.in", "$stage_base_dir/opt/zimbra/conf/spnego_java_options.in" ); cpy_file( "store-conf/conf/contacts/zimbra-contact-fields.xml", "$stage_base_dir/opt/zimbra/conf/zimbra-contact-fields.xml" ); + cpy_file( "store-conf/conf/common-passwords.txt", "$stage_base_dir/opt/zimbra/conf/common-passwords.txt" ); return ["store-conf/conf"]; } diff --git a/store-conf/README.md b/store-conf/README.md index 31c4b54c758..81aebcfa8f7 100644 --- a/store-conf/README.md +++ b/store-conf/README.md @@ -14,3 +14,10 @@ - `Everything under build/conf after a build should end up under /opt/zimbra/conf` ~ + + +## Common Password List + +- `The common password list is sourced from the [SecLists repository].` + +[SecLists repository]: https://github.com/danielmiessler/SecLists \ No newline at end of file diff --git a/store-conf/build.xml b/store-conf/build.xml index 4aa69023f2b..b6770fb9a26 100644 --- a/store-conf/build.xml +++ b/store-conf/build.xml @@ -1,10 +1,20 @@ - + + + + + + + + + + + diff --git a/store-conf/conf/common/common-passwords.gz b/store-conf/conf/common/common-passwords.gz new file mode 100644 index 00000000000..4bc051224e7 Binary files /dev/null and b/store-conf/conf/common/common-passwords.gz differ diff --git a/store/src/java/com/zimbra/cs/account/ldap/LdapProvisioning.java b/store/src/java/com/zimbra/cs/account/ldap/LdapProvisioning.java index 4036aae6306..e65af30bf30 100644 --- a/store/src/java/com/zimbra/cs/account/ldap/LdapProvisioning.java +++ b/store/src/java/com/zimbra/cs/account/ldap/LdapProvisioning.java @@ -1,7 +1,7 @@ /* * ***** BEGIN LICENSE BLOCK ***** * Zimbra Collaboration Suite Server - * Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2018, 2019 Synacor, Inc. + * Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2018, 2019, 2020 Synacor, Inc. * * This program is free software: you can redistribute it and/or modify it under * the terms of the GNU General Public License as published by the Free Software Foundation, @@ -58,6 +58,7 @@ import com.zimbra.common.service.ServiceException.Argument; import com.zimbra.common.soap.AdminConstants; import com.zimbra.common.soap.Element; +import com.zimbra.common.util.UnmodifiableBloomFilter; import com.zimbra.common.util.Constants; import com.zimbra.common.util.EmailUtil; import com.zimbra.common.util.L10nUtil; @@ -264,6 +265,9 @@ public class LdapProvisioning extends LdapProv implements CacheAwareProvisioning private final INamedEntryCache xmppComponentCache; private final INamedEntryCache zimletCache; + private final UnmodifiableBloomFilter commonPasswordFilter; + private boolean blockCommonPasswordsEnabled; + private LdapConfig cachedGlobalConfig = null; private GlobalGrant cachedGlobalGrant = null; private static final Random sPoolRandom = new Random(); @@ -315,6 +319,10 @@ public LdapProvisioning(CacheMode cacheMode) { zimletCache = cache.zimletCache(); alwaysOnClusterCache = cache.alwaysOnClusterCache(); + commonPasswordFilter = UnmodifiableBloomFilter + .createLazyFilterFromFile(LC.common_passwords_txt.value()); + blockCommonPasswordsEnabled = LC.zimbra_block_common_passwords_enabled.booleanValue(); + setDIT(); setHelper(new ZLdapHelper(this)); allDLs = new Groups(this); @@ -6090,7 +6098,6 @@ private String getString(Account acct, Cos cos, ZMutableEntry entry, String name return cos.getAttr(name); } - /** * called to check password strength. Should pass in either an Account, or Cos/Attributes (during creation). * @@ -6114,6 +6121,10 @@ private void checkPasswordStrength(String password, Account acct, Cos cos, ZMuta new Argument(Provisioning.A_zimbraPasswordMaxLength, maxLength, Argument.Type.NUM)); } + if (blockCommonPasswordsEnabled && commonPasswordFilter.mightContain(password)) { + throw AccountServiceException.INVALID_PASSWORD("password is known to be too common"); + } + int minUpperCase = getInt(acct, cos, entry, Provisioning.A_zimbraPasswordMinUpperCaseChars, 0); int minLowerCase = getInt(acct, cos, entry, Provisioning.A_zimbraPasswordMinLowerCaseChars, 0); int minNumeric = getInt(acct, cos, entry, Provisioning.A_zimbraPasswordMinNumericChars, 0);