diff --git a/docs/reference/settings/notification-settings.asciidoc b/docs/reference/settings/notification-settings.asciidoc index 145112ef4d27c..c375ddf076a66 100644 --- a/docs/reference/settings/notification-settings.asciidoc +++ b/docs/reference/settings/notification-settings.asciidoc @@ -118,6 +118,17 @@ If you configure multiple email accounts, you must either configure this setting or specify the email account to use in the <> action. See <>. +`xpack.notification.email.recipient_allowlist`:: +(<>) +Specifies addresses to which emails are allowed to be sent. +Emails with recipients (`To:`, `Cc:`, or `Bcc:`) outside of these patterns will be rejected and an +error thrown. This setting defaults to `["*"]` which means all recipients are allowed. +Simple globbing is supported, such as `list-*@company.com` in the list of allowed recipients. + +NOTE: This setting can't be used at the same time as `xpack.notification.email.account.domain_allowlist` +and an error will be thrown if both are set at the same time. This setting can be used to specify domains +to allow by using a wildcard pattern such as `*@company.com`. + `xpack.notification.email.account`:: Specifies account information for sending notifications via email. You can specify the following email account attributes: @@ -129,6 +140,10 @@ Specifies domains to which emails are allowed to be sent. Emails with recipients `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. + +NOTE: This setting can't be used at the same time as `xpack.notification.email.recipient_allowlist` +and an error will be thrown if both are set at the same time. + -- [[email-account-attributes]] diff --git a/server/src/main/java/org/elasticsearch/common/settings/Setting.java b/server/src/main/java/org/elasticsearch/common/settings/Setting.java index e4c47d4da71ea..32e8999f6bb26 100644 --- a/server/src/main/java/org/elasticsearch/common/settings/Setting.java +++ b/server/src/main/java/org/elasticsearch/common/settings/Setting.java @@ -1797,6 +1797,15 @@ public static Setting> stringListSetting(String key, Validator> stringListSetting( + final String key, + final List defaultStringValue, + final Validator> validator, + final Property... properties + ) { + return listSetting(key, null, Function.identity(), s -> defaultStringValue, validator, properties); + } + public static Setting> listSetting( final String key, final List defaultStringValue, diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java index d11cb7521976a..a979d614fe38f 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java @@ -26,7 +26,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; +import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.function.Predicate; @@ -56,9 +58,72 @@ public class EmailService extends NotificationService { (key) -> Setting.simpleString(key, Property.Dynamic, Property.NodeScope) ); + private static final List ALLOW_ALL_DEFAULT = List.of("*"); + private static final Setting> SETTING_DOMAIN_ALLOWLIST = Setting.stringListSetting( "xpack.notification.email.account.domain_allowlist", - List.of("*"), + ALLOW_ALL_DEFAULT, + new Setting.Validator<>() { + @Override + public void validate(List value) { + // Ignored + } + + @Override + @SuppressWarnings("unchecked") + public void validate(List value, Map, Object> settings) { + List recipientAllowPatterns = (List) settings.get(SETTING_RECIPIENT_ALLOW_PATTERNS); + if (value.equals(ALLOW_ALL_DEFAULT) == false && recipientAllowPatterns.equals(ALLOW_ALL_DEFAULT) == false) { + throw new IllegalArgumentException( + "Cannot set both [" + + SETTING_RECIPIENT_ALLOW_PATTERNS.getKey() + + "] and [" + + SETTING_DOMAIN_ALLOWLIST.getKey() + + "] to a non [\"*\"] value at the same time." + ); + } + } + + @Override + public Iterator> settings() { + List> settingRecipientAllowPatterns = List.of(SETTING_RECIPIENT_ALLOW_PATTERNS); + return settingRecipientAllowPatterns.iterator(); + } + }, + Property.Dynamic, + Property.NodeScope + ); + + private static final Setting> SETTING_RECIPIENT_ALLOW_PATTERNS = Setting.stringListSetting( + "xpack.notification.email.recipient_allowlist", + ALLOW_ALL_DEFAULT, + new Setting.Validator<>() { + @Override + public void validate(List value) { + // Ignored + } + + @Override + @SuppressWarnings("unchecked") + public void validate(List value, Map, Object> settings) { + List domainAllowList = (List) settings.get(SETTING_DOMAIN_ALLOWLIST); + if (value.equals(ALLOW_ALL_DEFAULT) == false && domainAllowList.equals(ALLOW_ALL_DEFAULT) == false) { + throw new IllegalArgumentException( + "Connect set both [" + + SETTING_RECIPIENT_ALLOW_PATTERNS.getKey() + + "] and [" + + SETTING_DOMAIN_ALLOWLIST.getKey() + + "] to a non [\"*\"] value at the same time." + ); + } + } + + @Override + public Iterator> settings() { + List> settingDomainAllowlist = List.of(SETTING_DOMAIN_ALLOWLIST); + return settingDomainAllowlist.iterator(); + } + }, Property.Dynamic, Property.NodeScope ); @@ -167,6 +232,7 @@ public class EmailService extends NotificationService { private final CryptoService cryptoService; private final SSLService sslService; private volatile Set allowedDomains; + private volatile Set allowedRecipientPatterns; @SuppressWarnings("this-escape") public EmailService(Settings settings, @Nullable CryptoService cryptoService, SSLService sslService, ClusterSettings clusterSettings) { @@ -192,7 +258,9 @@ public EmailService(Settings settings, @Nullable CryptoService cryptoService, SS 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)); + this.allowedRecipientPatterns = new HashSet<>(SETTING_RECIPIENT_ALLOW_PATTERNS.get(settings)); clusterSettings.addSettingsUpdateConsumer(SETTING_DOMAIN_ALLOWLIST, this::updateAllowedDomains); + clusterSettings.addSettingsUpdateConsumer(SETTING_RECIPIENT_ALLOW_PATTERNS, this::updateAllowedRecipientPatterns); // do an initial load reload(settings); } @@ -201,6 +269,10 @@ void updateAllowedDomains(List newDomains) { this.allowedDomains = new HashSet<>(newDomains); } + void updateAllowedRecipientPatterns(List newPatterns) { + this.allowedRecipientPatterns = new HashSet<>(newPatterns); + } + @Override protected Account createAccount(String name, Settings accountSettings) { Account.Config config = new Account.Config(name, accountSettings, getSmtpSslSocketFactory(), logger); @@ -228,33 +300,47 @@ public EmailSent send(Email email, Authentication auth, Profile profile, String "failed to send email with subject [" + email.subject() + "] and recipient domains " - + getRecipientDomains(email) + + getRecipients(email, true) + ", one or more recipients is not specified in the domain allow list setting [" + SETTING_DOMAIN_ALLOWLIST.getKey() + "]." ); } + if (recipientAddressInAllowList(email, this.allowedRecipientPatterns) == false) { + throw new IllegalArgumentException( + "failed to send email with subject [" + + email.subject() + + "] and recipients " + + getRecipients(email, false) + + ", one or more recipients is not specified in the domain allow list setting [" + + SETTING_RECIPIENT_ALLOW_PATTERNS.getKey() + + "]." + ); + } return send(email, auth, profile, account); } // Visible for testing - static Set getRecipientDomains(Email email) { - return Stream.concat( + static Set getRecipients(Email email, boolean domainsOnly) { + var stream = 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()); + ).map(InternetAddress::getAddress); + + if (domainsOnly) { + // Pull out only the domain of the email address, so foo@bar.com becomes bar.com + stream = stream.map(emailAddress -> emailAddress.substring(emailAddress.lastIndexOf('@') + 1)); + } + + return stream.collect(Collectors.toSet()); } // Visible for testing static boolean recipientDomainsInAllowList(Email email, Set allowedDomainSet) { - if (allowedDomainSet.size() == 0) { + if (allowedDomainSet.isEmpty()) { // Nothing is allowed return false; } @@ -262,12 +348,29 @@ static boolean recipientDomainsInAllowList(Email email, Set allowedDomai // Don't bother checking, because there is a wildcard all return true; } - final Set domains = getRecipientDomains(email); + final Set domains = getRecipients(email, true); final Predicate matchesAnyAllowedDomain = domain -> allowedDomainSet.stream() .anyMatch(allowedDomain -> Regex.simpleMatch(allowedDomain, domain, true)); return domains.stream().allMatch(matchesAnyAllowedDomain); } + // Visible for testing + static boolean recipientAddressInAllowList(Email email, Set allowedRecipientPatterns) { + if (allowedRecipientPatterns.isEmpty()) { + // Nothing is allowed + return false; + } + if (allowedRecipientPatterns.contains("*")) { + // Don't bother checking, because there is a wildcard all + return true; + } + + final Set recipients = getRecipients(email, false); + final Predicate matchesAnyAllowedRecipient = recipient -> allowedRecipientPatterns.stream() + .anyMatch(pattern -> Regex.simpleMatch(pattern, recipient, true)); + return recipients.stream().allMatch(matchesAnyAllowedRecipient); + } + private static EmailSent send(Email email, Authentication auth, Profile profile, Account account) throws MessagingException { assert account != null; try { @@ -304,6 +407,7 @@ private static List> getDynamicSettings() { return Arrays.asList( SETTING_DEFAULT_ACCOUNT, SETTING_DOMAIN_ALLOWLIST, + SETTING_RECIPIENT_ALLOW_PATTERNS, SETTING_PROFILE, SETTING_EMAIL_DEFAULTS, SETTING_SMTP_AUTH, diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java index a0ce8b18d8a96..4a668d0f9817a 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java @@ -69,6 +69,31 @@ public void testSend() throws Exception { assertThat(sent.account(), is("account1")); } + public void testDomainAndRecipientAllowCantBeSetAtSameTime() { + Settings settings = Settings.builder() + .putList("xpack.notification.email.account.domain_allowlist", "bar.com") + .putList("xpack.notification.email.recipient_allowlist", "*-user@potato.com") + .build(); + + IllegalArgumentException e = expectThrows( + IllegalArgumentException.class, + () -> new EmailService( + settings, + null, + mock(SSLService.class), + new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings())) + ) + ); + + assertThat( + e.getMessage(), + containsString( + "Cannot set both [xpack.notification.email.recipient_allowlist] and " + + "[xpack.notification.email.account.domain_allowlist] to a non [\"*\"] value at the same time." + ) + ); + } + public void testAccountSmtpPropertyConfiguration() { Settings settings = Settings.builder() .put("xpack.notification.email.account.account1.smtp.host", "localhost") @@ -140,7 +165,7 @@ public void testExtractDomains() throws Exception { Collections.emptyMap() ); assertThat( - EmailService.getRecipientDomains(email), + EmailService.getRecipients(email, true), containsInAnyOrder("bar.com", "eggplant.com", "example.com", "another.com", "bcc.com") ); @@ -158,7 +183,7 @@ public void testExtractDomains() throws Exception { "htmlbody", Collections.emptyMap() ); - assertThat(EmailService.getRecipientDomains(email), containsInAnyOrder("bar.com", "eggplant.com", "example.com")); + assertThat(EmailService.getRecipients(email, true), containsInAnyOrder("bar.com", "eggplant.com", "example.com")); } public void testAllowedDomain() throws Exception { @@ -322,6 +347,264 @@ public void testChangeDomainAllowListSetting() throws UnsupportedEncodingExcepti assertThat(e2.getMessage(), containsString("port out of range")); } + public void testRecipientAddressInAllowList_EmptyAllowedPatterns() throws UnsupportedEncodingException { + Email email = createTestEmail("foo@bar.com", "baz@potato.com"); + Set allowedPatterns = Set.of(); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(false)); + } + + public void testRecipientAddressInAllowList_WildcardPattern() throws UnsupportedEncodingException { + Email email = createTestEmail("foo@bar.com", "baz@potato.com"); + Set allowedPatterns = Set.of("*"); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(true)); + } + + public void testRecipientAddressInAllowList_SpecificPattern() throws UnsupportedEncodingException { + Email email = createTestEmail("foo@bar.com", "baz@potato.com"); + Set allowedPatterns = Set.of("foo@bar.com"); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(false)); + } + + public void testRecipientAddressInAllowList_MultiplePatterns() throws UnsupportedEncodingException { + Email email = createTestEmail("foo@bar.com", "baz@potato.com"); + Set allowedPatterns = Set.of("foo@bar.com", "baz@potato.com"); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(true)); + } + + public void testRecipientAddressInAllowList_MixedCasePatterns() throws UnsupportedEncodingException { + Email email = createTestEmail("foo@bar.com", "baz@potato.com"); + Set allowedPatterns = Set.of("FOO@BAR.COM", "BAZ@POTATO.COM"); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(true)); + } + + public void testRecipientAddressInAllowList_PartialWildcardPrefixPattern() throws UnsupportedEncodingException { + Email email = createTestEmail("foo@bar.com", "baz@potato.com"); + Set allowedPatterns = Set.of("foo@*", "baz@*"); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(true)); + } + + public void testRecipientAddressInAllowList_PartialWildcardSuffixPattern() throws UnsupportedEncodingException { + Email email = createTestEmail("foo@bar.com", "baz@potato.com"); + Set allowedPatterns = Set.of("*@bar.com", "*@potato.com"); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(true)); + } + + public void testRecipientAddressInAllowList_DisallowedCCAddressesFails() throws UnsupportedEncodingException { + Email email = new Email( + "id", + new Email.Address("sender@domain.com", "Sender"), + createAddressList("foo@bar.com"), + randomFrom(Email.Priority.values()), + ZonedDateTime.now(), + createAddressList("foo@bar.com"), + createAddressList("cc@allowed.com", "cc@notallowed.com"), + null, + "subject", + "body", + "htmlbody", + Collections.emptyMap() + ); + Set allowedPatterns = Set.of("foo@bar.com", "cc@allowed.com"); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(false)); + } + + public void testRecipientAddressInAllowList_DisallowedBCCAddressesFails() throws UnsupportedEncodingException { + Email email = new Email( + "id", + new Email.Address("sender@domain.com", "Sender"), + createAddressList("foo@bar.com"), + randomFrom(Email.Priority.values()), + ZonedDateTime.now(), + createAddressList("foo@bar.com"), + null, + createAddressList("bcc@allowed.com", "bcc@notallowed.com"), + "subject", + "body", + "htmlbody", + Collections.emptyMap() + ); + Set allowedPatterns = Set.of("foo@bar.com", "bcc@allowed.com"); + assertThat(EmailService.recipientAddressInAllowList(email, allowedPatterns), is(false)); + } + + public void testAllowedRecipient() 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.recipientAddressInAllowList(email, Set.of("*"))); + assertFalse(EmailService.recipientAddressInAllowList(email, Set.of())); + assertFalse(EmailService.recipientAddressInAllowList(email, Set.of(""))); + assertTrue(EmailService.recipientAddressInAllowList(email, Set.of("foo@other.com", "*o@bar.com"))); + assertTrue(EmailService.recipientAddressInAllowList(email, Set.of("buzz@other.com", "*.com"))); + assertTrue(EmailService.recipientAddressInAllowList(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.recipientAddressInAllowList(email, Set.of("*@other.com", "*iii@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.recipientAddressInAllowList(email, Set.of("*@other.com", "*@bar.com"))); + assertFalse(EmailService.recipientAddressInAllowList(email, Set.of("*oo@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.recipientAddressInAllowList(email, Set.of("*@other.com", "*@bar.com"))); + assertFalse(EmailService.recipientAddressInAllowList(email, Set.of("*oo@bar.com"))); + } + + public void testSendEmailWithRecipientNotInAllowList() throws Exception { + service.updateAllowedRecipientPatterns(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 recipients [non-whitelisted@invalid.com, foo@bar.com], " + + "one or more recipients is not specified in the domain allow list setting " + + "[xpack.notification.email.recipient_allowlist]." + ) + ); + } + + public void testChangeRecipientAllowListSetting() throws UnsupportedEncodingException, MessagingException { + Settings settings = Settings.builder() + .put("xpack.notification.email.account.account1.foo", "bar") + // Setting a random SMTP server name and an invalid port so that sending emails is guaranteed to fail: + .put("xpack.notification.email.account.account1.smtp.host", randomAlphaOfLength(10)) + .put("xpack.notification.email.account.account1.smtp.port", -100) + .putList("xpack.notification.email.recipient_allowlist", "*oo@bar.com") + .build(); + ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings())); + EmailService emailService = new EmailService(settings, null, mock(SSLService.class), clusterSettings); + 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()); + + // This send will fail because one of the recipients ("non-whitelisted@invalid.com") is in a domain that is not in the allowed list + IllegalArgumentException e1 = expectThrows( + IllegalArgumentException.class, + () -> emailService.send(email, auth, profile, "account1") + ); + assertThat( + e1.getMessage(), + containsString( + "failed to send email with subject [subject] and recipients [non-whitelisted@invalid.com, foo@bar.com], " + + "one or more recipients is not specified in the domain allow list setting " + + "[xpack.notification.email.recipient_allowlist]." + ) + ); + + // Now dynamically add "invalid.com" to the list of allowed domains: + Settings newSettings = Settings.builder() + .putList("xpack.notification.email.recipient_allowlist", "*@bar.com", "*@invalid.com") + .build(); + clusterSettings.applySettings(newSettings); + // Still expect an exception because we're not actually sending the email, but it's no longer because the domain isn't allowed: + IllegalArgumentException e2 = expectThrows( + IllegalArgumentException.class, + () -> emailService.send(email, auth, profile, "account1") + ); + assertThat(e2.getMessage(), containsString("port out of range")); + } + + private Email createTestEmail(String... recipients) throws UnsupportedEncodingException { + return new Email( + "id", + new Email.Address("sender@domain.com", "Sender"), + createAddressList(recipients), + randomFrom(Email.Priority.values()), + ZonedDateTime.now(), + createAddressList(recipients), + null, + null, + "subject", + "body", + "htmlbody", + Collections.emptyMap() + ); + } + private static Email.AddressList createAddressList(String... emails) throws UnsupportedEncodingException { List addresses = new ArrayList<>(); for (String email : emails) {