Skip to content

Commit d6cdf13

Browse files
authored
Add list of allowed domains for Watcher email action (#84894) (#85029)
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 #84739
1 parent 375fd80 commit d6cdf13

File tree

4 files changed

+239
-0
lines changed

4 files changed

+239
-0
lines changed

docs/changelog/84894.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
pr: 84894
2+
summary: Add list of allowed domains for Watcher email action
3+
area: Watcher
4+
type: enhancement
5+
issues:
6+
- 84739

docs/reference/settings/notification-settings.asciidoc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ or specify the email account to use in the <<actions-email,`email`>> action. See
116116
Specifies account information for sending notifications via email. You
117117
can specify the following email account attributes:
118118
+
119+
120+
`xpack.notification.email.account.domain_allowlist`::
121+
(<<dynamic-cluster-setting,Dynamic>>)
122+
Specifies domains to which emails are allowed to be sent. Emails with recipients (`To:`, `Cc:`, or
123+
`Bcc:`) outside of these domains will be rejected and an error thrown. This setting defaults to
124+
`["*"]` which means all domains are allowed. Simple globbing is supported, such as `*.company.com`
125+
in the list of allowed domains.
119126
--
120127
[[email-account-attributes]]
121128

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import org.apache.logging.log4j.LogManager;
1010
import org.apache.logging.log4j.Logger;
11+
import org.elasticsearch.common.regex.Regex;
1112
import org.elasticsearch.common.settings.ClusterSettings;
1213
import org.elasticsearch.common.settings.SecureSetting;
1314
import org.elasticsearch.common.settings.SecureString;
@@ -24,9 +25,17 @@
2425

2526
import java.util.ArrayList;
2627
import java.util.Arrays;
28+
import java.util.Collections;
29+
import java.util.HashSet;
2730
import java.util.List;
31+
import java.util.Optional;
32+
import java.util.Set;
33+
import java.util.function.Predicate;
34+
import java.util.stream.Collectors;
35+
import java.util.stream.Stream;
2836

2937
import javax.mail.MessagingException;
38+
import javax.mail.internet.InternetAddress;
3039
import javax.net.ssl.SSLSocketFactory;
3140

3241
import static org.elasticsearch.xpack.core.watcher.WatcherField.EMAIL_NOTIFICATION_SSL_PREFIX;
@@ -48,6 +57,14 @@ public class EmailService extends NotificationService<Account> {
4857
(key) -> Setting.simpleString(key, Property.Dynamic, Property.NodeScope)
4958
);
5059

60+
private static final Setting<List<String>> SETTING_DOMAIN_ALLOWLIST = Setting.listSetting(
61+
"xpack.notification.email.account.domain_allowlist",
62+
Collections.singletonList("*"),
63+
String::toString,
64+
Property.Dynamic,
65+
Property.NodeScope
66+
);
67+
5168
private static final Setting.AffixSetting<Settings> SETTING_EMAIL_DEFAULTS = Setting.affixKeySetting(
5269
"xpack.notification.email.account.",
5370
"email_defaults",
@@ -151,6 +168,7 @@ public class EmailService extends NotificationService<Account> {
151168

152169
private final CryptoService cryptoService;
153170
private final SSLService sslService;
171+
private volatile Set<String> allowedDomains;
154172

155173
public EmailService(Settings settings, @Nullable CryptoService cryptoService, SSLService sslService, ClusterSettings clusterSettings) {
156174
super("email", settings, clusterSettings, EmailService.getDynamicSettings(), EmailService.getSecureSettings());
@@ -174,10 +192,16 @@ public EmailService(Settings settings, @Nullable CryptoService cryptoService, SS
174192
clusterSettings.addAffixUpdateConsumer(SETTING_SMTP_LOCAL_PORT, (s, o) -> {}, (s, o) -> {});
175193
clusterSettings.addAffixUpdateConsumer(SETTING_SMTP_SEND_PARTIAL, (s, o) -> {}, (s, o) -> {});
176194
clusterSettings.addAffixUpdateConsumer(SETTING_SMTP_WAIT_ON_QUIT, (s, o) -> {}, (s, o) -> {});
195+
this.allowedDomains = new HashSet<>(SETTING_DOMAIN_ALLOWLIST.get(settings));
196+
clusterSettings.addSettingsUpdateConsumer(SETTING_DOMAIN_ALLOWLIST, (s) -> {});
177197
// do an initial load
178198
reload(settings);
179199
}
180200

201+
void updateAllowedDomains(List<String> newDomains) {
202+
this.allowedDomains = new HashSet<>(newDomains);
203+
}
204+
181205
@Override
182206
protected Account createAccount(String name, Settings accountSettings) {
183207
Account.Config config = new Account.Config(name, accountSettings, getSmtpSslSocketFactory(), logger);
@@ -200,9 +224,51 @@ public EmailSent send(Email email, Authentication auth, Profile profile, String
200224
"failed to send email with subject [" + email.subject() + "] via account [" + accountName + "]. account does not exist"
201225
);
202226
}
227+
if (recipientDomainsInAllowList(email, this.allowedDomains) == false) {
228+
throw new IllegalArgumentException(
229+
"failed to send email with subject ["
230+
+ email.subject()
231+
+ "] and recipient domains "
232+
+ getRecipientDomains(email)
233+
+ ", one or more recipients is not specified in the domain allow list setting ["
234+
+ SETTING_DOMAIN_ALLOWLIST.getKey()
235+
+ "]."
236+
);
237+
}
203238
return send(email, auth, profile, account);
204239
}
205240

241+
// Visible for testing
242+
static Set<String> getRecipientDomains(Email email) {
243+
return Stream.concat(
244+
Optional.ofNullable(email.to()).map(addrs -> Arrays.stream(addrs.toArray())).orElse(Stream.empty()),
245+
Stream.concat(
246+
Optional.ofNullable(email.cc()).map(addrs -> Arrays.stream(addrs.toArray())).orElse(Stream.empty()),
247+
Optional.ofNullable(email.bcc()).map(addrs -> Arrays.stream(addrs.toArray())).orElse(Stream.empty())
248+
)
249+
)
250+
.map(InternetAddress::getAddress)
251+
// Pull out only the domain of the email address, so [email protected] -> bar.com
252+
.map(emailAddress -> emailAddress.substring(emailAddress.lastIndexOf("@") + 1))
253+
.collect(Collectors.toSet());
254+
}
255+
256+
// Visible for testing
257+
static boolean recipientDomainsInAllowList(Email email, Set<String> allowedDomainSet) {
258+
if (allowedDomainSet.size() == 0) {
259+
// Nothing is allowed
260+
return false;
261+
}
262+
if (allowedDomainSet.contains("*")) {
263+
// Don't bother checking, because there is a wildcard all
264+
return true;
265+
}
266+
final Set<String> domains = getRecipientDomains(email);
267+
final Predicate<String> matchesAnyAllowedDomain = domain -> allowedDomainSet.stream()
268+
.anyMatch(allowedDomain -> Regex.simpleMatch(allowedDomain, domain, true));
269+
return domains.stream().allMatch(matchesAnyAllowedDomain);
270+
}
271+
206272
private EmailSent send(Email email, Authentication auth, Profile profile, Account account) throws MessagingException {
207273
assert account != null;
208274
try {
@@ -238,6 +304,7 @@ public Email email() {
238304
private static List<Setting<?>> getDynamicSettings() {
239305
return Arrays.asList(
240306
SETTING_DEFAULT_ACCOUNT,
307+
SETTING_DOMAIN_ALLOWLIST,
241308
SETTING_PROFILE,
242309
SETTING_EMAIL_DEFAULTS,
243310
SETTING_SMTP_AUTH,

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

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,17 @@
1313
import org.elasticsearch.xpack.core.watcher.common.secret.Secret;
1414
import org.junit.Before;
1515

16+
import java.io.UnsupportedEncodingException;
17+
import java.time.ZonedDateTime;
18+
import java.util.ArrayList;
19+
import java.util.Collections;
1620
import java.util.HashSet;
21+
import java.util.List;
1722
import java.util.Properties;
23+
import java.util.Set;
1824

25+
import static org.hamcrest.Matchers.containsInAnyOrder;
26+
import static org.hamcrest.Matchers.containsString;
1927
import static org.hamcrest.Matchers.hasEntry;
2028
import static org.hamcrest.Matchers.hasKey;
2129
import static org.hamcrest.Matchers.is;
@@ -113,4 +121,155 @@ public void testAccountSmtpPropertyConfiguration() {
113121
assertThat(properties5, hasEntry("mail.smtp.quitwait", "true"));
114122
assertThat(properties5, hasEntry("mail.smtp.ssl.trust", "host1,host2,host3"));
115123
}
124+
125+
public void testExtractDomains() throws Exception {
126+
Email email = new Email(
127+
"id",
128+
new Email.Address("[email protected]", "[email protected]"),
129+
createAddressList("[email protected]", "[email protected]"),
130+
randomFrom(Email.Priority.values()),
131+
ZonedDateTime.now(),
132+
133+
createAddressList("[email protected]", "[email protected]"),
134+
createAddressList("[email protected]", "[email protected]"),
135+
"subject",
136+
"body",
137+
"htmlbody",
138+
Collections.emptyMap()
139+
);
140+
assertThat(
141+
EmailService.getRecipientDomains(email),
142+
containsInAnyOrder("bar.com", "eggplant.com", "example.com", "another.com", "bcc.com")
143+
);
144+
145+
email = new Email(
146+
"id",
147+
new Email.Address("[email protected]", "[email protected]"),
148+
createAddressList("[email protected]", "[email protected]"),
149+
randomFrom(Email.Priority.values()),
150+
ZonedDateTime.now(),
151+
152+
null,
153+
null,
154+
"subject",
155+
"body",
156+
"htmlbody",
157+
Collections.emptyMap()
158+
);
159+
assertThat(EmailService.getRecipientDomains(email), containsInAnyOrder("bar.com", "eggplant.com", "example.com"));
160+
}
161+
162+
public void testAllowedDomain() throws Exception {
163+
Email email = new Email(
164+
"id",
165+
new Email.Address("[email protected]", "Mr. Foo Man"),
166+
createAddressList("[email protected]", "[email protected]"),
167+
randomFrom(Email.Priority.values()),
168+
ZonedDateTime.now(),
169+
createAddressList("[email protected]"),
170+
null,
171+
null,
172+
"subject",
173+
"body",
174+
"htmlbody",
175+
Collections.emptyMap()
176+
);
177+
assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("*")));
178+
assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of()));
179+
assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of("")));
180+
assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "bar.com")));
181+
assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "*.com")));
182+
assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("*.CoM")));
183+
184+
// Invalid email in CC doesn't blow up
185+
email = new Email(
186+
"id",
187+
new Email.Address("[email protected]", "Mr. Foo Man"),
188+
createAddressList("[email protected]", "[email protected]"),
189+
randomFrom(Email.Priority.values()),
190+
ZonedDateTime.now(),
191+
createAddressList("[email protected]"),
192+
createAddressList("badEmail"),
193+
null,
194+
"subject",
195+
"body",
196+
"htmlbody",
197+
Collections.emptyMap()
198+
);
199+
assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "bar.com")));
200+
201+
// Check CC
202+
email = new Email(
203+
"id",
204+
new Email.Address("[email protected]", "Mr. Foo Man"),
205+
createAddressList("[email protected]", "[email protected]"),
206+
randomFrom(Email.Priority.values()),
207+
ZonedDateTime.now(),
208+
createAddressList("[email protected]"),
209+
createAddressList("[email protected]"),
210+
null,
211+
"subject",
212+
"body",
213+
"htmlbody",
214+
Collections.emptyMap()
215+
);
216+
assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "bar.com")));
217+
assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of("bar.com")));
218+
219+
// Check BCC
220+
email = new Email(
221+
"id",
222+
new Email.Address("[email protected]", "Mr. Foo Man"),
223+
createAddressList("[email protected]", "[email protected]"),
224+
randomFrom(Email.Priority.values()),
225+
ZonedDateTime.now(),
226+
createAddressList("[email protected]"),
227+
null,
228+
createAddressList("[email protected]"),
229+
"subject",
230+
"body",
231+
"htmlbody",
232+
Collections.emptyMap()
233+
);
234+
assertTrue(EmailService.recipientDomainsInAllowList(email, Set.of("other.com", "bar.com")));
235+
assertFalse(EmailService.recipientDomainsInAllowList(email, Set.of("bar.com")));
236+
}
237+
238+
public void testSendEmailWithDomainNotInAllowList() throws Exception {
239+
service.updateAllowedDomains(Collections.singletonList(randomFrom("bar.*", "bar.com", "b*")));
240+
Email email = new Email(
241+
"id",
242+
new Email.Address("[email protected]", "Mr. Foo Man"),
243+
createAddressList("[email protected]", "[email protected]"),
244+
randomFrom(Email.Priority.values()),
245+
ZonedDateTime.now(),
246+
createAddressList("[email protected]", "[email protected]"),
247+
null,
248+
null,
249+
"subject",
250+
"body",
251+
"htmlbody",
252+
Collections.emptyMap()
253+
);
254+
when(account.name()).thenReturn("account1");
255+
Authentication auth = new Authentication("user", new Secret("passwd".toCharArray()));
256+
Profile profile = randomFrom(Profile.values());
257+
IllegalArgumentException e = expectThrows(IllegalArgumentException.class, () -> service.send(email, auth, profile, "account1"));
258+
assertThat(
259+
e.getMessage(),
260+
containsString(
261+
"failed to send email with subject [subject] and recipient domains "
262+
+ "[bar.com, invalid.com], one or more recipients is not specified in the domain allow list setting "
263+
+ "[xpack.notification.email.account.domain_allowlist]."
264+
)
265+
);
266+
}
267+
268+
private static Email.AddressList createAddressList(String... emails) throws UnsupportedEncodingException {
269+
List<Email.Address> addresses = new ArrayList<>();
270+
for (String email : emails) {
271+
addresses.add(new Email.Address(email, randomAlphaOfLength(10)));
272+
}
273+
return new Email.AddressList(addresses);
274+
}
116275
}

0 commit comments

Comments
 (0)