diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd57892..2729ee78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,15 @@ CHANGELOG ========= +3.8.0 +------------------ + +* `commons-validator` has been removed as a dependency. This module now does + its own email and domain name validation. This was done to reduce the number + of dependencies and any security vulnerabilities in them. The new email + validation of the local part is somewhat more lax than the previous + validation. + 3.7.2 (2025-05-28) ------------------ diff --git a/pom.xml b/pom.xml index 43023b6a..be0692fb 100644 --- a/pom.xml +++ b/pom.xml @@ -62,11 +62,6 @@ geoip2 4.3.1 - - commons-validator - commons-validator - 1.9.0 - org.junit.jupiter junit-jupiter diff --git a/src/main/java/com/maxmind/minfraud/request/Email.java b/src/main/java/com/maxmind/minfraud/request/Email.java index f0b5f1dc..e4f94e9e 100644 --- a/src/main/java/com/maxmind/minfraud/request/Email.java +++ b/src/main/java/com/maxmind/minfraud/request/Email.java @@ -13,8 +13,6 @@ import java.util.HashMap; import java.util.Map; import java.util.regex.Pattern; -import org.apache.commons.validator.routines.DomainValidator; -import org.apache.commons.validator.routines.EmailValidator; /** * The email information for the transaction. @@ -28,6 +26,9 @@ public final class Email extends AbstractModel { private static final Map equivalentDomains; private static final Map fastmailDomains; private static final Map yahooDomains; + private static final Pattern DOMAIN_LABEL_PATTERN = Pattern.compile( + "^[a-zA-Z0-9]$|^[a-zA-Z0-9][a-zA-Z0-9\\-]*[a-zA-Z0-9]$" + ); private static final Pattern DOT_PATTERN = Pattern.compile("\\."); private static final Pattern TRAILING_DOT_PATTERN = Pattern.compile("\\.+$"); private static final Pattern REPEAT_COM_PATTERN = Pattern.compile("(?:\\.com){2,}$"); @@ -338,7 +339,7 @@ public Builder(boolean enableValidation) { * @throws IllegalArgumentException when address is not a valid email address. */ public Email.Builder address(String address) { - if (enableValidation && !EmailValidator.getInstance().isValid(address)) { + if (enableValidation && !isValidEmail(address)) { throw new IllegalArgumentException( "The email address " + address + " is not valid."); } @@ -373,7 +374,7 @@ public Email.Builder hashAddress() { * @throws IllegalArgumentException when domain is not a valid domain. */ public Email.Builder domain(String domain) { - if (enableValidation && !DomainValidator.getInstance().isValid(domain)) { + if (enableValidation && !isValidDomain(domain)) { throw new IllegalArgumentException("The email domain " + domain + " is not valid."); } this.domain = domain; @@ -458,6 +459,34 @@ private String cleanAddress(String address) { return localPart + "@" + domain; } + private static boolean isValidEmail(String email) { + if (email == null || email.isEmpty()) { + return false; + } + + // In RFC 5321, the forward path limits the mailbox to 254 characters + // even though a domain can be 255 and the local part 64 + if (email.length() > 254) { + return false; + } + + int atIndex = email.lastIndexOf('@'); + if (atIndex <= 0) { + return false; + } + + String localPart = email.substring(0, atIndex); + String domainPart = email.substring(atIndex + 1); + + // The local-part has a maximum length of 64 characters. + if (localPart.length() > 64) { + return false; + } + + return isValidDomain(domainPart); + } + + private String cleanDomain(String domain) { if (domain == null) { return null; @@ -491,6 +520,48 @@ private String cleanDomain(String domain) { return domain; } + private static boolean isValidDomain(String domain) { + if (domain == null || domain.isEmpty()) { + return false; + } + + try { + domain = IDN.toASCII(domain); + } catch (IllegalArgumentException e) { + return false; + } + + if (domain.endsWith(".")) { + domain = domain.substring(0, domain.length() - 1); + } + + if (domain.length() > 255) { + return false; + } + + String[] labels = domain.split("\\."); + + if (labels.length < 2) { + return false; + } + + for (String label : labels) { + if (!isValidDomainLabel(label)) { + return false; + } + } + + return true; + } + + private static boolean isValidDomainLabel(String label) { + if (label == null || label.isEmpty() || label.length() > 63) { + return false; + } + + return DOMAIN_LABEL_PATTERN.matcher(label).matches(); + } + /** * @return The domain of the email address used in the transaction. */ diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index dfc2ebf6..0d7600fb 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -7,7 +7,6 @@ requires com.fasterxml.jackson.databind; requires com.fasterxml.jackson.datatype.jsr310; requires transitive com.maxmind.geoip2; - requires org.apache.commons.validator; requires java.net.http; exports com.maxmind.minfraud; diff --git a/src/test/java/com/maxmind/minfraud/request/EmailTest.java b/src/test/java/com/maxmind/minfraud/request/EmailTest.java index 4025f1a3..687c2519 100644 --- a/src/test/java/com/maxmind/minfraud/request/EmailTest.java +++ b/src/test/java/com/maxmind/minfraud/request/EmailTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; + import com.maxmind.minfraud.request.Email.Builder; import java.math.BigInteger; import java.nio.charset.StandardCharsets; @@ -12,6 +13,8 @@ import java.util.HashMap; import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class EmailTest { @@ -242,11 +245,26 @@ private String toMD5(String s) throws NoSuchAlgorithmException { return String.format("%032x", i); } - @Test - public void testInvalidAddress() { + @ParameterizedTest(name = "Run #{index}: email = \"{0}\"") + @ValueSource(strings = { + "test.com", + "test@", + "@test.com", + "", + " ", + "test@test com.com", + "test@test_domain.com", + "test@-test.com", + "test@test-.com", + "test@.test.com", + "test@test..com", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@test.com", + "test@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com" + }) + void testInvalidAddresses(String invalidAddress) { assertThrows( IllegalArgumentException.class, - () -> new Builder().address("a@test@test.org").build() + () -> new Builder().address(invalidAddress).build() ); } @@ -264,11 +282,32 @@ public void testDomainWithoutValidation() { assertEquals(domain, email.getDomain()); } - @Test - public void testInvalidDomain() { + @ParameterizedTest(name = "Run #{index}: domain = \"{0}\"") + @ValueSource(strings = { + "example", + "", + " ", + " domain.com", + "domain.com ", + "domain com.com", + "domain_name.com", + "domain$.com", + "-domain.com", + "domain-.com", + "domain..com", + ".domain.com", + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.com", + "a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" + + ".a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" + + ".a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" + + ".a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a.a" + + ".com", + "xn--.com" + }) + void testInvalidDomains(String invalidDomain) { assertThrows( IllegalArgumentException.class, - () -> new Builder().domain(" domain.com").build() + () -> new Builder().domain(invalidDomain).build() ); } }