diff --git a/src/main/java/org/broadinstitute/consent/http/ConsentModule.java b/src/main/java/org/broadinstitute/consent/http/ConsentModule.java index df9e2609e..ea63074b5 100644 --- a/src/main/java/org/broadinstitute/consent/http/ConsentModule.java +++ b/src/main/java/org/broadinstitute/consent/http/ConsentModule.java @@ -87,6 +87,7 @@ import org.broadinstitute.consent.http.service.ontology.OntologyDAO; import org.broadinstitute.consent.http.service.ontology.OntologyIndexService; import org.broadinstitute.consent.http.service.sam.SamService; +import org.broadinstitute.consent.http.util.EmailDenyListValidator; import org.broadinstitute.consent.http.util.HttpClientUtil; import org.broadinstitute.consent.http.util.gson.GsonUtil; import org.jdbi.v3.core.Jdbi; @@ -210,6 +211,11 @@ HttpClientUtil providesHttpClientUtil() { return new HttpClientUtil(config.getServicesConfiguration()); } + @Provides + EmailDenyListValidator providesEmailDenyListValidator() { + return new EmailDenyListValidator(config.getNihConfiguration()); + } + @Provides Jdbi providesJdbi() { return jdbi; diff --git a/src/main/java/org/broadinstitute/consent/http/configurations/ConsentConfiguration.java b/src/main/java/org/broadinstitute/consent/http/configurations/ConsentConfiguration.java index e353475a0..848f44845 100644 --- a/src/main/java/org/broadinstitute/consent/http/configurations/ConsentConfiguration.java +++ b/src/main/java/org/broadinstitute/consent/http/configurations/ConsentConfiguration.java @@ -11,8 +11,6 @@ @JsonIgnoreProperties(ignoreUnknown = true) public class ConsentConfiguration extends Configuration { - public ConsentConfiguration() {} - @Valid @NotNull @JsonProperty private final DataSourceFactory database = new DataSourceFactory(); @Valid @NotNull @JsonProperty @@ -21,12 +19,13 @@ public ConsentConfiguration() {} @Valid @NotNull @JsonProperty private final ServicesConfiguration services = new ServicesConfiguration(); - @Valid @NotNull private JerseyClientConfiguration httpClient = new JerseyClientConfiguration(); + @Valid @NotNull + private final JerseyClientConfiguration httpClient = new JerseyClientConfiguration(); - @Valid @NotNull private MailConfiguration mailConfiguration = new MailConfiguration(); + @Valid @NotNull private final MailConfiguration mailConfiguration = new MailConfiguration(); @Valid @NotNull - private FreeMarkerConfiguration freeMarkerConfiguration = new FreeMarkerConfiguration(); + private final FreeMarkerConfiguration freeMarkerConfiguration = new FreeMarkerConfiguration(); @JsonProperty("httpClient") public JerseyClientConfiguration getJerseyClientConfiguration() { @@ -39,6 +38,8 @@ public JerseyClientConfiguration getJerseyClientConfiguration() { @Valid @NotNull @JsonProperty private final OidcConfiguration oidcConfiguration = new OidcConfiguration(); + @Valid @NotNull @JsonProperty private final NihConfiguration nih = new NihConfiguration(); + public DataSourceFactory getDataSourceFactory() { return database; } @@ -66,4 +67,8 @@ public ElasticSearchConfiguration getElasticSearchConfiguration() { public OidcConfiguration getOidcConfiguration() { return oidcConfiguration; } + + public NihConfiguration getNihConfiguration() { + return nih; + } } diff --git a/src/main/java/org/broadinstitute/consent/http/configurations/NihConfiguration.java b/src/main/java/org/broadinstitute/consent/http/configurations/NihConfiguration.java new file mode 100644 index 000000000..0d73d7029 --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/configurations/NihConfiguration.java @@ -0,0 +1,18 @@ +package org.broadinstitute.consent.http.configurations; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class NihConfiguration { + + private List denyEmailPatterns = List.of(); + + public List getDenyEmailPatterns() { + return denyEmailPatterns; + } + + public void setDenyEmailPatterns(List denyEmailPatterns) { + this.denyEmailPatterns = denyEmailPatterns; + } +} diff --git a/src/main/java/org/broadinstitute/consent/http/util/EmailDenyListValidator.java b/src/main/java/org/broadinstitute/consent/http/util/EmailDenyListValidator.java new file mode 100644 index 000000000..c57429d9d --- /dev/null +++ b/src/main/java/org/broadinstitute/consent/http/util/EmailDenyListValidator.java @@ -0,0 +1,52 @@ +package org.broadinstitute.consent.http.util; + +import java.util.List; +import java.util.Objects; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import org.broadinstitute.consent.http.configurations.NihConfiguration; + +/** + * Validates email addresses against the NIH deny-list configured in {@link NihConfiguration}. + * + *

The deny-list is a collection of regular expressions (sourced from the shared {@code + * global.nih.denyEmailPatterns} helm value) describing restricted address patterns. + * + *

Patterns are compiled once at construction. An email is "denied" when it fully matches any + * configured pattern. + */ +public class EmailDenyListValidator implements ConsentLogger { + + private final List denyPatterns; + + public EmailDenyListValidator(NihConfiguration nihConfiguration) { + this.denyPatterns = + nihConfiguration.getDenyEmailPatterns().stream() + .map(this::compilePattern) + .filter(Objects::nonNull) + .toList(); + } + + /** + * @param email the email address to check; may be null or blank + * @return true if the email fully matches any configured deny pattern + */ + public boolean isDenied(String email) { + if (email == null || email.isBlank()) { + return false; + } + String trimmed = email.trim(); + return denyPatterns.stream().anyMatch(pattern -> pattern.matcher(trimmed).matches()); + } + + private Pattern compilePattern(String regex) { + try { + return Pattern.compile(regex); + } catch (PatternSyntaxException e) { + // Skip an invalid pattern rather than failing startup; a single bad entry in config + // should not take down the service or disable the rest of the deny-list. + logWarn("Ignoring invalid deny email pattern '" + regex + "': " + e.getMessage()); + return null; + } + } +} diff --git a/src/test/java/org/broadinstitute/consent/http/util/EmailDenyListValidatorTest.java b/src/test/java/org/broadinstitute/consent/http/util/EmailDenyListValidatorTest.java new file mode 100644 index 000000000..7b2683ce8 --- /dev/null +++ b/src/test/java/org/broadinstitute/consent/http/util/EmailDenyListValidatorTest.java @@ -0,0 +1,102 @@ +package org.broadinstitute.consent.http.util; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.broadinstitute.consent.http.configurations.NihConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class EmailDenyListValidatorTest { + + // Representative entries from the shared global.nih.denyEmailPatterns list. + private static final String HMAIL_PATTERN = "(?i).+@(.+\\.)*hmail\\.[^.]+"; + private static final String XYZ_TLD_PATTERN = "(?i).+@(.+\\.)+xyz"; + private static final String EXACT_ADDRESS_PATTERN = "(?i)exact_user@msu\\.edu"; + + private EmailDenyListValidator validatorWith(List patterns) { + NihConfiguration config = new NihConfiguration(); + config.setDenyEmailPatterns(patterns); + return new EmailDenyListValidator(config); + } + + @ParameterizedTest + @ValueSource( + strings = { + "user@hmail.com", + "User@HMAIL.COM", + "user@mail.hmail.com", + }) + void deniesEmailMatchingPattern(String email) { + EmailDenyListValidator validator = validatorWith(List.of(HMAIL_PATTERN)); + assertTrue(validator.isDenied(email)); + } + + @Test + void allowsEmailNotMatchingPattern() { + EmailDenyListValidator validator = validatorWith(List.of(HMAIL_PATTERN)); + assertFalse(validator.isDenied("user@non-restricted.org")); + } + + @Test + void allowsLookalikeDomain() { + EmailDenyListValidator validator = validatorWith(List.of(HMAIL_PATTERN)); + assertFalse(validator.isDenied("user@nothmail.com")); + } + + @Test + void deniesCountryOfConcernTld() { + EmailDenyListValidator validator = validatorWith(List.of(XYZ_TLD_PATTERN)); + assertTrue(validator.isDenied("researcher@university.xyz")); + assertFalse(validator.isDenied("researcher@university.edu")); + } + + @Test + void deniesExactBannedAddressOnly() { + EmailDenyListValidator validator = validatorWith(List.of(EXACT_ADDRESS_PATTERN)); + assertTrue(validator.isDenied("exact_user@msu.edu")); + // Full-match semantics: a string merely containing the banned address is not denied. + assertFalse(validator.isDenied("exact_user@msu.edu.good.com")); + } + + @Test + void matchesAgainstAnyConfiguredPattern() { + EmailDenyListValidator validator = + validatorWith(List.of(HMAIL_PATTERN, XYZ_TLD_PATTERN, EXACT_ADDRESS_PATTERN)); + assertTrue(validator.isDenied("user@hmail.com")); + assertTrue(validator.isDenied("user@host.xyz")); + assertTrue(validator.isDenied("exact_user@msu.edu")); + assertFalse(validator.isDenied("user@non-restricted.org")); + } + + @Test + void emptyDenyListDeniesNothing() { + EmailDenyListValidator validator = validatorWith(List.of()); + assertFalse(validator.isDenied("user@hmail.com")); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void nullOrBlankEmailIsNotDenied(String email) { + EmailDenyListValidator validator = validatorWith(List.of(HMAIL_PATTERN)); + assertFalse(validator.isDenied(email)); + } + + @Test + void trimsWhitespaceBeforeMatching() { + EmailDenyListValidator validator = validatorWith(List.of(HMAIL_PATTERN)); + assertTrue(validator.isDenied(" user@hmail.com ")); + } + + @Test + void invalidPatternIsSkippedAndDoesNotFailConstruction() { + // An unclosed group is an invalid regex; it should be skipped while valid patterns still apply. + EmailDenyListValidator validator = validatorWith(List.of("(unclosed", HMAIL_PATTERN)); + assertTrue(validator.isDenied("user@hmail.com")); + assertFalse(validator.isDenied("user@non-restricted.org")); + } +} diff --git a/src/test/resources/consent-ci.yaml b/src/test/resources/consent-ci.yaml index ae5a2056f..a3976713f 100644 --- a/src/test/resources/consent-ci.yaml +++ b/src/test/resources/consent-ci.yaml @@ -74,3 +74,5 @@ oidcConfiguration: extraAuthParams: "" authorityEndpoint: "http://localhost:9999/" +nih: + denyEmailPatterns: [] diff --git a/src/test/resources/consent-config.yml b/src/test/resources/consent-config.yml index aa01a54ab..02f4c66c1 100644 --- a/src/test/resources/consent-config.yml +++ b/src/test/resources/consent-config.yml @@ -59,3 +59,5 @@ oidcConfiguration: addClientIdToScope: true extraAuthParams: prompt=login authorityEndpoint: http://localhost:8000 +nih: + denyEmailPatterns: []