Browse Source

Add list of allowed domains for Watcher email action (#84894)

This adds the `xpack.notification.email.account.domain_allowlist` dynamic cluster setting that
allows an administrator to specify a list of domains to which emails are allowed to be sent. The
default value for this setting is `["*"]` which means all domains are allowed. It supports
rudimentary globbing (`*`) in the domain name, so `*.company.com` will work as a valid option.

Resolves https://github.com/elastic/elasticsearch/issues/84739
Lee Hinman 3 years ago
parent
commit
ef01949562

+ 6 - 0
docs/changelog/84894.yaml

@@ -0,0 +1,6 @@
+pr: 84894
+summary: Add list of allowed domains for Watcher email action
+area: Watcher
+type: enhancement
+issues:
+ - 84739

+ 7 - 0
docs/reference/settings/notification-settings.asciidoc

@@ -116,6 +116,13 @@ or specify the email account to use in the <<actions-email,`email`>> action. See
 Specifies account information for sending notifications via email. You
 can specify the following email account attributes:
 +
+
+`xpack.notification.email.account.domain_allowlist`::
+(<<dynamic-cluster-setting,Dynamic>>)
+Specifies domains to which emails are allowed to be sent. Emails with recipients (`To:`, `Cc:`, or
+`Bcc:`) outside of these domains will be rejected and an error thrown. This setting defaults to
+`["*"]` which means all domains are allowed. Simple globbing is supported, such as `*.company.com`
+in the list of allowed domains.
 --
 [[email-account-attributes]]
 

+ 67 - 0
x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java

@@ -8,6 +8,7 @@ package org.elasticsearch.xpack.watcher.notification.email;
 
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
+import org.elasticsearch.common.regex.Regex;
 import org.elasticsearch.common.settings.ClusterSettings;
 import org.elasticsearch.common.settings.SecureSetting;
 import org.elasticsearch.common.settings.SecureString;
@@ -24,9 +25,17 @@ import org.elasticsearch.xpack.watcher.notification.NotificationService;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import javax.mail.MessagingException;
+import javax.mail.internet.InternetAddress;
 import javax.net.ssl.SSLSocketFactory;
 
 import static org.elasticsearch.xpack.core.watcher.WatcherField.EMAIL_NOTIFICATION_SSL_PREFIX;
@@ -48,6 +57,14 @@ public class EmailService extends NotificationService<Account> {
         (key) -> Setting.simpleString(key, Property.Dynamic, Property.NodeScope)
     );
 
+    private static final Setting<List<String>> SETTING_DOMAIN_ALLOWLIST = Setting.listSetting(
+        "xpack.notification.email.account.domain_allowlist",
+        Collections.singletonList("*"),
+        String::toString,
+        Property.Dynamic,
+        Property.NodeScope
+    );
+
     private static final Setting.AffixSetting<Settings> SETTING_EMAIL_DEFAULTS = Setting.affixKeySetting(
         "xpack.notification.email.account.",
         "email_defaults",
@@ -151,6 +168,7 @@ public class EmailService extends NotificationService<Account> {
 
     private final CryptoService cryptoService;
     private final SSLService sslService;
+    private volatile Set<String> allowedDomains;
 
     public EmailService(Settings settings, @Nullable CryptoService cryptoService, SSLService sslService, ClusterSettings clusterSettings) {
         super("email", settings, clusterSettings, EmailService.getDynamicSettings(), EmailService.getSecureSettings());
@@ -174,10 +192,16 @@ public class EmailService extends NotificationService<Account> {
         clusterSettings.addAffixUpdateConsumer(SETTING_SMTP_LOCAL_PORT, (s, o) -> {}, (s, o) -> {});
         clusterSettings.addAffixUpdateConsumer(SETTING_SMTP_SEND_PARTIAL, (s, o) -> {}, (s, o) -> {});
         clusterSettings.addAffixUpdateConsumer(SETTING_SMTP_WAIT_ON_QUIT, (s, o) -> {}, (s, o) -> {});
+        this.allowedDomains = new HashSet<>(SETTING_DOMAIN_ALLOWLIST.get(settings));
+        clusterSettings.addSettingsUpdateConsumer(SETTING_DOMAIN_ALLOWLIST, (s) -> {});
         // do an initial load
         reload(settings);
     }
 
+    void updateAllowedDomains(List<String> newDomains) {
+        this.allowedDomains = new HashSet<>(newDomains);
+    }
+
     @Override
     protected Account createAccount(String name, Settings accountSettings) {
         Account.Config config = new Account.Config(name, accountSettings, getSmtpSslSocketFactory(), logger);
@@ -200,9 +224,51 @@ public class EmailService extends NotificationService<Account> {
                 "failed to send email with subject [" + email.subject() + "] via account [" + accountName + "]. account does not exist"
             );
         }
+        if (recipientDomainsInAllowList(email, this.allowedDomains) == false) {
+            throw new IllegalArgumentException(
+                "failed to send email with subject ["
+                    + email.subject()
+                    + "] and recipient domains "
+                    + getRecipientDomains(email)
+                    + ", one or more recipients is not specified in the domain allow list setting ["
+                    + SETTING_DOMAIN_ALLOWLIST.getKey()
+                    + "]."
+            );
+        }
         return send(email, auth, profile, account);
     }
 
+    // Visible for testing
+    static Set<String> getRecipientDomains(Email email) {
+        return Stream.concat(
+            Optional.ofNullable(email.to()).map(addrs -> Arrays.stream(addrs.toArray())).orElse(Stream.empty()),
+            Stream.concat(
+                Optional.ofNullable(email.cc()).map(addrs -> Arrays.stream(addrs.toArray())).orElse(Stream.empty()),
+                Optional.ofNullable(email.bcc()).map(addrs -> Arrays.stream(addrs.toArray())).orElse(Stream.empty())
+            )
+        )
+            .map(InternetAddress::getAddress)
+            // Pull out only the domain of the email address, so foo@bar.com -> bar.com
+            .map(emailAddress -> emailAddress.substring(emailAddress.lastIndexOf("@") + 1))
+            .collect(Collectors.toSet());
+    }
+
+    // Visible for testing
+    static boolean recipientDomainsInAllowList(Email email, Set<String> allowedDomainSet) {
+        if (allowedDomainSet.size() == 0) {
+            // Nothing is allowed
+            return false;
+        }
+        if (allowedDomainSet.contains("*")) {
+            // Don't bother checking, because there is a wildcard all
+            return true;
+        }
+        final Set<String> domains = getRecipientDomains(email);
+        final Predicate<String> matchesAnyAllowedDomain = domain -> allowedDomainSet.stream()
+            .anyMatch(allowedDomain -> Regex.simpleMatch(allowedDomain, domain, true));
+        return domains.stream().allMatch(matchesAnyAllowedDomain);
+    }
+
     private EmailSent send(Email email, Authentication auth, Profile profile, Account account) throws MessagingException {
         assert account != null;
         try {
@@ -238,6 +304,7 @@ public class EmailService extends NotificationService<Account> {
     private static List<Setting<?>> getDynamicSettings() {
         return Arrays.asList(
             SETTING_DEFAULT_ACCOUNT,
+            SETTING_DOMAIN_ALLOWLIST,
             SETTING_PROFILE,
             SETTING_EMAIL_DEFAULTS,
             SETTING_SMTP_AUTH,

+ 159 - 0
x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java

@@ -13,9 +13,17 @@ import org.elasticsearch.xpack.core.ssl.SSLService;
 import org.elasticsearch.xpack.core.watcher.common.secret.Secret;
 import org.junit.Before;
 
+import java.io.UnsupportedEncodingException;
+import java.time.ZonedDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Properties;
+import java.util.Set;
 
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.containsString;
 import static org.hamcrest.Matchers.hasEntry;
 import static org.hamcrest.Matchers.hasKey;
 import static org.hamcrest.Matchers.is;
@@ -113,4 +121,155 @@ public class EmailServiceTests extends ESTestCase {
         assertThat(properties5, hasEntry("mail.smtp.quitwait", "true"));
         assertThat(properties5, hasEntry("mail.smtp.ssl.trust", "host1,host2,host3"));
     }
+
+    public void testExtractDomains() throws Exception {
+        Email email = new Email(
+            "id",
+            new Email.Address("foo@bar.com", "foo@bar.com"),
+            createAddressList("foo@bar.com", "baz@eggplant.com"),
+            randomFrom(Email.Priority.values()),
+            ZonedDateTime.now(),
+            createAddressList("foo@bar.com", "bar@eggplant.com", "person@example.com"),
+            createAddressList("me@another.com", "other@bar.com"),
+            createAddressList("onemore@bar.com", "private@bcc.com"),
+            "subject",
+            "body",
+            "htmlbody",
+            Collections.emptyMap()
+        );
+        assertThat(
+            EmailService.getRecipientDomains(email),
+            containsInAnyOrder("bar.com", "eggplant.com", "example.com", "another.com", "bcc.com")
+        );
+
+        email = new Email(
+            "id",
+            new Email.Address("foo@bar.com", "foo@bar.com"),
+            createAddressList("foo@bar.com", "baz@potato.com"),
+            randomFrom(Email.Priority.values()),
+            ZonedDateTime.now(),
+            createAddressList("foo@bar.com", "bar@eggplant.com", "person@example.com"),
+            null,
+            null,
+            "subject",
+            "body",
+            "htmlbody",
+            Collections.emptyMap()
+        );
+        assertThat(EmailService.getRecipientDomains(email), containsInAnyOrder("bar.com", "eggplant.com", "example.com"));
+    }
+
+    public void testAllowedDomain() throws Exception {
+        Email email = new Email(
+            "id",
+            new Email.Address("foo@bar.com", "Mr. Foo Man"),
+            createAddressList("foo@bar.com", "baz@potato.com"),
+            randomFrom(Email.Priority.values()),
+            ZonedDateTime.now(),
+            createAddressList("foo@bar.com"),
+            null,
+            null,
+            "subject",
+            "body",
+            "htmlbody",
+            Collections.emptyMap()
+        );
+        assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("*")));
+        assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of()));
+        assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of("")));
+        assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "bar.com")));
+        assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "*.com")));
+        assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("*.CoM")));
+
+        // Invalid email in CC doesn't blow up
+        email = new Email(
+            "id",
+            new Email.Address("foo@bar.com", "Mr. Foo Man"),
+            createAddressList("foo@bar.com", "baz@potato.com"),
+            randomFrom(Email.Priority.values()),
+            ZonedDateTime.now(),
+            createAddressList("foo@bar.com"),
+            createAddressList("badEmail"),
+            null,
+            "subject",
+            "body",
+            "htmlbody",
+            Collections.emptyMap()
+        );
+        assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "bar.com")));
+
+        // Check CC
+        email = new Email(
+            "id",
+            new Email.Address("foo@bar.com", "Mr. Foo Man"),
+            createAddressList("foo@bar.com", "baz@potato.com"),
+            randomFrom(Email.Priority.values()),
+            ZonedDateTime.now(),
+            createAddressList("foo@bar.com"),
+            createAddressList("thing@other.com"),
+            null,
+            "subject",
+            "body",
+            "htmlbody",
+            Collections.emptyMap()
+        );
+        assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "bar.com")));
+        assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of("bar.com")));
+
+        // Check BCC
+        email = new Email(
+            "id",
+            new Email.Address("foo@bar.com", "Mr. Foo Man"),
+            createAddressList("foo@bar.com", "baz@potato.com"),
+            randomFrom(Email.Priority.values()),
+            ZonedDateTime.now(),
+            createAddressList("foo@bar.com"),
+            null,
+            createAddressList("thing@other.com"),
+            "subject",
+            "body",
+            "htmlbody",
+            Collections.emptyMap()
+        );
+        assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "bar.com")));
+        assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of("bar.com")));
+    }
+
+    public void testSendEmailWithDomainNotInAllowList() throws Exception {
+        service.updateAllowedDomains(Collections.singletonList(randomFrom("bar.*", "bar.com", "b*")));
+        Email email = new Email(
+            "id",
+            new Email.Address("foo@bar.com", "Mr. Foo Man"),
+            createAddressList("foo@bar.com", "baz@potato.com"),
+            randomFrom(Email.Priority.values()),
+            ZonedDateTime.now(),
+            createAddressList("foo@bar.com", "non-whitelisted@invalid.com"),
+            null,
+            null,
+            "subject",
+            "body",
+            "htmlbody",
+            Collections.emptyMap()
+        );
+        when(account.name()).thenReturn("account1");
+        Authentication auth = new Authentication("user", new Secret("passwd".toCharArray()));
+        Profile profile = randomFrom(Profile.values());
+        IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> service.send(email, auth, profile, "account1"));
+        assertThat(
+            e.getMessage(),
+            containsString(
+                "failed to send email with subject [subject] and recipient domains "
+                    + "[bar.com, invalid.com], one or more recipients is not specified in the domain allow list setting "
+                    + "[xpack.notification.email.account.domain_allowlist]."
+            )
+        );
+    }
+
+    private static Email.AddressList createAddressList(String... emails) throws UnsupportedEncodingException {
+        List<Email.Address> addresses = new ArrayList<>();
+        for (String email : emails) {
+            addresses.add(new Email.Address(email, randomAlphaOfLength(10)));
+        }
+        return new Email.AddressList(addresses);
+    }
 }