Skip to content
This repository was archived by the owner on Aug 2, 2022. It is now read-only.

Commit a359247

Browse files
Support host deny list for Destinations (#353)
* Support deny list for destinations * Support deny list for destinations * Addressed comments and added integ tests
1 parent 8f1b7d5 commit a359247

File tree

12 files changed

+204
-29
lines changed

12 files changed

+204
-29
lines changed

Diff for: alerting/build.gradle

+5
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ dependencies {
7373

7474
compile project(":alerting-core")
7575
compile project(":alerting-notification")
76+
implementation "com.github.seancfoley:ipaddress:5.3.3"
7677

7778
testImplementation "org.jetbrains.kotlin:kotlin-test:${kotlin_version}"
7879
}
@@ -121,6 +122,10 @@ testClusters.integTest {
121122
}
122123
}
123124

125+
testClusters.integTest.nodes.each { node ->
126+
node.setting("opendistro.destination.host.deny_list", "[\"10.0.0.0/8\", \"127.0.0.1\"]")
127+
}
128+
124129
integTest {
125130
systemProperty 'tests.security.manager', 'false'
126131
systemProperty 'java.io.tmpdir', es_tmp_dir.absolutePath

Diff for: alerting/src/main/kotlin/com/amazon/opendistroforelasticsearch/alerting/AlertingPlugin.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,8 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R
254254
AlertingSettings.FILTER_BY_BACKEND_ROLES,
255255
DestinationSettings.EMAIL_USERNAME,
256256
DestinationSettings.EMAIL_PASSWORD,
257-
DestinationSettings.ALLOW_LIST
257+
DestinationSettings.ALLOW_LIST,
258+
DestinationSettings.HOST_DENY_LIST
258259
)
259260
}
260261

Diff for: alerting/src/main/kotlin/com/amazon/opendistroforelasticsearch/alerting/MonitorRunner.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import com.amazon.opendistroforelasticsearch.alerting.settings.AlertingSettings.
5252
import com.amazon.opendistroforelasticsearch.alerting.settings.AlertingSettings.Companion.MOVE_ALERTS_BACKOFF_COUNT
5353
import com.amazon.opendistroforelasticsearch.alerting.settings.AlertingSettings.Companion.MOVE_ALERTS_BACKOFF_MILLIS
5454
import com.amazon.opendistroforelasticsearch.alerting.settings.DestinationSettings.Companion.ALLOW_LIST
55+
import com.amazon.opendistroforelasticsearch.alerting.settings.DestinationSettings.Companion.HOST_DENY_LIST
5556
import com.amazon.opendistroforelasticsearch.alerting.settings.DestinationSettings.Companion.loadDestinationSettings
5657
import com.amazon.opendistroforelasticsearch.alerting.util.IndexUtils
5758
import com.amazon.opendistroforelasticsearch.alerting.util.addUserBackendRolesFilter
@@ -121,6 +122,8 @@ class MonitorRunner(
121122
BackoffPolicy.exponentialBackoff(MOVE_ALERTS_BACKOFF_MILLIS.get(settings), MOVE_ALERTS_BACKOFF_COUNT.get(settings))
122123
@Volatile private var allowList = ALLOW_LIST.get(settings)
123124

125+
@Volatile private var hostDenyList = HOST_DENY_LIST.get(settings)
126+
124127
@Volatile private var destinationSettings = loadDestinationSettings(settings)
125128
@Volatile private var destinationContextFactory = DestinationContextFactory(client, xContentRegistry, destinationSettings)
126129

@@ -537,7 +540,8 @@ class MonitorRunner(
537540
actionOutput[MESSAGE_ID] = destination.publish(
538541
actionOutput[SUBJECT],
539542
actionOutput[MESSAGE]!!,
540-
destinationCtx
543+
destinationCtx,
544+
hostDenyList
541545
)
542546
}
543547
}

Diff for: alerting/src/main/kotlin/com/amazon/opendistroforelasticsearch/alerting/model/destination/Destination.kt

+15-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import com.amazon.opendistroforelasticsearch.alerting.elasticapi.optionalUserFie
2929
import com.amazon.opendistroforelasticsearch.alerting.model.destination.email.Email
3030
import com.amazon.opendistroforelasticsearch.alerting.util.DestinationType
3131
import com.amazon.opendistroforelasticsearch.alerting.util.IndexUtils.Companion.NO_SCHEMA_VERSION
32+
import com.amazon.opendistroforelasticsearch.alerting.util.isHostInDenylist
3233
import com.amazon.opendistroforelasticsearch.commons.authuser.User
3334
import org.apache.logging.log4j.LogManager
3435
import org.elasticsearch.common.io.stream.StreamInput
@@ -236,7 +237,13 @@ data class Destination(
236237
}
237238

238239
@Throws(IOException::class)
239-
fun publish(compiledSubject: String?, compiledMessage: String, destinationCtx: DestinationContext): String {
240+
fun publish(
241+
compiledSubject: String?,
242+
compiledMessage: String,
243+
destinationCtx: DestinationContext,
244+
denyHostRanges: List<String>
245+
): String {
246+
240247
val destinationMessage: BaseMessage
241248
val responseContent: String
242249
val responseStatusCode: Int
@@ -285,6 +292,7 @@ data class Destination(
285292
}
286293
}
287294

295+
validateDestinationUri(destinationMessage, denyHostRanges)
288296
val response = Notification.publish(destinationMessage) as DestinationResponse
289297
responseContent = response.responseContent
290298
responseStatusCode = response.statusCode
@@ -307,4 +315,10 @@ data class Destination(
307315
}
308316
return content
309317
}
318+
319+
private fun validateDestinationUri(destinationMessage: BaseMessage, denyHostRanges: List<String>) {
320+
if (destinationMessage.isHostInDenylist(denyHostRanges)) {
321+
throw IOException("The destination address is invalid.")
322+
}
323+
}
310324
}

Diff for: alerting/src/main/kotlin/com/amazon/opendistroforelasticsearch/alerting/settings/DestinationSettings.kt

+8
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,14 @@ class DestinationSettings {
5757
Function { key: String -> SecureSetting.secureString(key, null) }
5858
)
5959

60+
val HOST_DENY_LIST: Setting<List<String>> = Setting.listSetting(
61+
"opendistro.destination.host.deny_list",
62+
emptyList<String>(),
63+
Function.identity(),
64+
Setting.Property.NodeScope,
65+
Setting.Property.Final
66+
)
67+
6068
fun loadDestinationSettings(settings: Settings): Map<String, SecureDestinationSettings> {
6169
// Only loading Email Destination settings for now since those are the only secure settings needed.
6270
// If this logic needs to be expanded to support other Destinations, different groups can be retrieved similar

Diff for: alerting/src/main/kotlin/com/amazon/opendistroforelasticsearch/alerting/util/AlertingUtils.kt

+16
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@
1515

1616
package com.amazon.opendistroforelasticsearch.alerting.util
1717

18+
import com.amazon.opendistroforelasticsearch.alerting.destination.message.BaseMessage
1819
import com.amazon.opendistroforelasticsearch.alerting.model.destination.Destination
1920
import com.amazon.opendistroforelasticsearch.alerting.settings.DestinationSettings
2021
import com.amazon.opendistroforelasticsearch.commons.authuser.User
2122
import org.elasticsearch.ElasticsearchStatusException
2223
import org.elasticsearch.action.ActionListener
2324
import org.elasticsearch.rest.RestStatus
25+
import inet.ipaddr.IPAddressString
26+
import java.net.InetAddress
27+
import org.elasticsearch.transport.TransportChannel.logger
2428

2529
/**
2630
* RFC 5322 compliant pattern matching: https://www.ietf.org/rfc/rfc5322.txt
@@ -41,6 +45,18 @@ fun isValidEmail(email: String): Boolean {
4145
/** Allowed Destinations are ones that are specified in the [DestinationSettings.ALLOW_LIST] setting. */
4246
fun Destination.isAllowed(allowList: List<String>): Boolean = allowList.contains(this.type.value)
4347

48+
fun BaseMessage.isHostInDenylist(networks: List<String>): Boolean {
49+
val ipStr = IPAddressString(this.uri.host)
50+
for (network in networks) {
51+
val netStr = IPAddressString(network)
52+
if (netStr.contains(ipStr)) {
53+
logger.error("Host: {} resolves to: {} which is in denylist: {}.", uri.getHost(), InetAddress.getByName(uri.getHost()), netStr)
54+
return true
55+
}
56+
}
57+
return false
58+
}
59+
4460
/**
4561
1. If filterBy is enabled
4662
a) Don't allow to create monitor/ destination (throw error) if the logged-on user has no backend roles configured.

Diff for: alerting/src/test/kotlin/com/amazon/opendistroforelasticsearch/alerting/MonitorRunnerIT.kt

+53
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import com.amazon.opendistroforelasticsearch.alerting.model.Monitor
2727
import com.amazon.opendistroforelasticsearch.alerting.core.model.SearchInput
2828
import com.amazon.opendistroforelasticsearch.alerting.model.ActionExecutionResult
2929
import com.amazon.opendistroforelasticsearch.alerting.model.action.Throttle
30+
import com.amazon.opendistroforelasticsearch.alerting.model.destination.CustomWebhook
3031
import com.amazon.opendistroforelasticsearch.alerting.model.destination.Destination
3132
import com.amazon.opendistroforelasticsearch.alerting.model.destination.email.Email
3233
import com.amazon.opendistroforelasticsearch.alerting.model.destination.email.Recipient
@@ -653,6 +654,58 @@ class MonitorRunnerIT : AlertingRestTestCase() {
653654
verifyAlert(alerts.single(), monitor, ACTIVE)
654655
}
655656

657+
fun `test execute monitor with custom webhook destination`() {
658+
val customWebhook = CustomWebhook("http://15.16.17.18", null, null, 80, null, "PUT", emptyMap(), emptyMap(), null, null)
659+
val destination = createDestination(
660+
Destination(
661+
type = DestinationType.CUSTOM_WEBHOOK,
662+
name = "testDesination",
663+
user = randomUser(),
664+
lastUpdateTime = Instant.now(),
665+
chime = null,
666+
slack = null,
667+
customWebhook = customWebhook,
668+
email = null
669+
))
670+
val action = randomAction(destinationId = destination.id)
671+
val trigger = randomTrigger(condition = ALWAYS_RUN, actions = listOf(action))
672+
val monitor = createMonitor(randomMonitor(triggers = listOf(trigger)))
673+
executeMonitor(adminClient(), monitor.id)
674+
675+
val alerts = searchAlerts(monitor)
676+
assertEquals("Alert not saved", 1, alerts.size)
677+
verifyAlert(alerts.single(), monitor, ERROR)
678+
Assert.assertTrue(alerts.single().errorMessage?.contains("Connect timed out") as Boolean)
679+
}
680+
681+
fun `test execute monitor with custom webhook destination and denied host`() {
682+
683+
listOf("http://10.1.1.1", "127.0.0.1").forEach {
684+
val customWebhook = CustomWebhook(it, null, null, 80, null, "PUT", emptyMap(), emptyMap(), null, null)
685+
val destination = createDestination(
686+
Destination(
687+
type = DestinationType.CUSTOM_WEBHOOK,
688+
name = "testDesination",
689+
user = randomUser(),
690+
lastUpdateTime = Instant.now(),
691+
chime = null,
692+
slack = null,
693+
customWebhook = customWebhook,
694+
email = null
695+
))
696+
val action = randomAction(destinationId = destination.id)
697+
val trigger = randomTrigger(condition = ALWAYS_RUN, actions = listOf(action))
698+
val monitor = createMonitor(randomMonitor(triggers = listOf(trigger)))
699+
executeMonitor(adminClient(), monitor.id)
700+
701+
val alerts = searchAlerts(monitor)
702+
assertEquals("Alert not saved", 1, alerts.size)
703+
verifyAlert(alerts.single(), monitor, ERROR)
704+
705+
Assert.assertTrue(alerts.single().errorMessage?.contains("The destination address is invalid") as Boolean)
706+
}
707+
}
708+
656709
fun `test execute AD monitor doesn't return search result without user`() {
657710
// TODO: change to REST API call to test security enabled case
658711
if (!securityEnabled()) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.amazon.opendistroforelasticsearch.alerting.util
2+
3+
import com.amazon.opendistroforelasticsearch.alerting.destination.message.BaseMessage
4+
import com.amazon.opendistroforelasticsearch.alerting.destination.message.CustomWebhookMessage
5+
import org.elasticsearch.test.ESTestCase
6+
import java.util.HashMap
7+
8+
class AlertingUtilsTests : ESTestCase() {
9+
10+
private val HOST_DENY_LIST = listOf(
11+
"127.0.0.0/8",
12+
"10.0.0.0/8",
13+
"172.16.0.0/12",
14+
"192.168.0.0/16",
15+
"0.0.0.0/8",
16+
"9.9.9.9" // ip
17+
)
18+
19+
fun `test ips in denylist`() {
20+
val ips = listOf(
21+
"127.0.0.1", // 127.0.0.0/8
22+
"10.0.0.1", // 10.0.0.0/8
23+
"10.11.12.13", // 10.0.0.0/8
24+
"172.16.0.1", // "172.16.0.0/12"
25+
"192.168.0.1", // 192.168.0.0/16"
26+
"0.0.0.1", // 0.0.0.0/8
27+
"9.9.9.9"
28+
)
29+
for (ip in ips) {
30+
val bm = createMessageWithHost(ip)
31+
assertEquals(true, bm.isHostInDenylist(HOST_DENY_LIST))
32+
}
33+
}
34+
35+
fun `test url in denylist`() {
36+
val urls = listOf("https://www.amazon.com", "https://mytest.com", "https://mytest.com")
37+
for (url in urls) {
38+
val bm = createMessageWithURl(url)
39+
assertEquals(false, bm.isHostInDenylist(HOST_DENY_LIST))
40+
}
41+
}
42+
43+
private fun createMessageWithHost(host: String): BaseMessage {
44+
return CustomWebhookMessage.Builder("abc")
45+
.withHost(host)
46+
.withPath("incomingwebhooks/383c0e2b-d028-44f4-8d38-696754bc4574")
47+
.withMessage("{\"Content\":\"Message test\"}")
48+
.withMethod("POST")
49+
.withQueryParams(HashMap<String, String>()).build()
50+
}
51+
52+
private fun createMessageWithURl(url: String): BaseMessage {
53+
return CustomWebhookMessage.Builder("abc")
54+
.withUrl(url)
55+
.withPath("incomingwebhooks/383c0e2b-d028-44f4-8d38-696754bc4574")
56+
.withMessage("{\"Content\":\"Message test\"}")
57+
.withMethod("POST")
58+
.withQueryParams(HashMap<String, String>()).build()
59+
}
60+
}

Diff for: notification/src/main/java/com/amazon/opendistroforelasticsearch/alerting/destination/client/DestinationHttpClient.java

+2-26
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,7 @@ private CloseableHttpResponse getHttpResponse(BaseMessage message) throws Except
110110
HttpRequestBase httpRequest;
111111
if (message instanceof CustomWebhookMessage) {
112112
CustomWebhookMessage customWebhookMessage = (CustomWebhookMessage) message;
113-
uri = buildUri(customWebhookMessage.getUrl(), customWebhookMessage.getScheme(), customWebhookMessage.getHost(),
114-
customWebhookMessage.getPort(), customWebhookMessage.getPath(), customWebhookMessage.getQueryParams());
113+
uri = customWebhookMessage.getUri();
115114
httpRequest = constructHttpRequest(((CustomWebhookMessage) message).getMethod());
116115
// set headers
117116
Map<String, String> headerParams = customWebhookMessage.getHeaderParams();
@@ -124,7 +123,7 @@ private CloseableHttpResponse getHttpResponse(BaseMessage message) throws Except
124123
}
125124
} else {
126125
httpRequest = new HttpPost();
127-
uri = buildUri(message.getUrl().trim(), null, null, -1, null, null);
126+
uri = message.getUri();
128127
}
129128

130129
httpRequest.setURI(uri);
@@ -149,29 +148,6 @@ private HttpRequestBase constructHttpRequest(String method) {
149148
}
150149
}
151150

152-
private URI buildUri(String endpoint, String scheme, String host,
153-
int port, String path, Map<String, String> queryParams)
154-
throws Exception {
155-
try {
156-
if(Strings.isNullOrEmpty(endpoint)) {
157-
logger.info("endpoint empty. Fall back to host:port/path");
158-
if (Strings.isNullOrEmpty(scheme)) {
159-
scheme = "https";
160-
}
161-
URIBuilder uriBuilder = new URIBuilder();
162-
if(queryParams != null) {
163-
for (Map.Entry<String, String> e : queryParams.entrySet())
164-
uriBuilder.addParameter(e.getKey(), e.getValue());
165-
}
166-
return uriBuilder.setScheme(scheme).setHost(host).setPort(port).setPath(path).build();
167-
}
168-
return new URIBuilder(endpoint).build();
169-
} catch (URISyntaxException exception) {
170-
logger.error("Error occured while building Uri");
171-
throw new IllegalStateException("Error creating URI");
172-
}
173-
}
174-
175151
public String getResponseString(CloseableHttpResponse response) throws IOException {
176152
HttpEntity entity = response.getEntity();
177153
if (entity == null)

Diff for: notification/src/main/java/com/amazon/opendistroforelasticsearch/alerting/destination/message/BaseMessage.java

+29
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@
1515

1616
package com.amazon.opendistroforelasticsearch.alerting.destination.message;
1717

18+
import org.apache.http.client.utils.URIBuilder;
1819
import org.elasticsearch.common.Strings;
1920

21+
import java.net.URI;
22+
import java.net.URISyntaxException;
23+
import java.util.Map;
24+
2025
/**
2126
* This class holds the generic parameters required for a
2227
* message.
@@ -68,4 +73,28 @@ public String getUrl() {
6873
return url;
6974
}
7075

76+
public URI getUri() {
77+
return buildUri(getUrl().trim(), null, null, -1, null, null);
78+
}
79+
80+
protected URI buildUri(String endpoint, String scheme, String host,
81+
int port, String path, Map<String, String> queryParams) {
82+
try {
83+
if(Strings.isNullOrEmpty(endpoint)) {
84+
if (Strings.isNullOrEmpty(scheme)) {
85+
scheme = "https";
86+
}
87+
URIBuilder uriBuilder = new URIBuilder();
88+
if(queryParams != null) {
89+
for (Map.Entry<String, String> e : queryParams.entrySet())
90+
uriBuilder.addParameter(e.getKey(), e.getValue());
91+
}
92+
return uriBuilder.setScheme(scheme).setHost(host).setPort(port).setPath(path).build();
93+
}
94+
return new URIBuilder(endpoint).build();
95+
} catch (URISyntaxException exception) {
96+
throw new IllegalStateException("Error creating URI");
97+
}
98+
}
99+
71100
}

Diff for: notification/src/main/java/com/amazon/opendistroforelasticsearch/alerting/destination/message/CustomWebhookMessage.java

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.apache.http.client.methods.HttpPut;
2121
import org.elasticsearch.common.Strings;
2222

23+
import java.net.URI;
2324
import java.util.Map;
2425

2526
/**
@@ -217,4 +218,7 @@ public Map<String, String> getHeaderParams() {
217218
return headerParams;
218219
}
219220

221+
public URI getUri() {
222+
return buildUri(getUrl(), getScheme(), getHost(), getPort(), getPath(), getQueryParams());
223+
}
220224
}

0 commit comments

Comments
 (0)