diff --git a/notifications/core-spi/src/main/kotlin/org/opensearch/notifications/spi/model/MessageContent.kt b/notifications/core-spi/src/main/kotlin/org/opensearch/notifications/spi/model/MessageContent.kt index b6640c62..56e9e3ba 100644 --- a/notifications/core-spi/src/main/kotlin/org/opensearch/notifications/spi/model/MessageContent.kt +++ b/notifications/core-spi/src/main/kotlin/org/opensearch/notifications/spi/model/MessageContent.kt @@ -23,10 +23,16 @@ class MessageContent( init { require(!Strings.isNullOrEmpty(title)) { "title is null or empty" } - require(!Strings.isNullOrEmpty(textDescription)) { "text message part is null or empty" } + require(!Strings.isNullOrEmpty(textDescription) || !Strings.isNullOrEmpty(htmlDescription)) { + "both text message part and html message part are null or empty" + } } fun buildMessageWithTitle(): String { - return "$title\n\n$textDescription" + return if (!Strings.isNullOrEmpty(htmlDescription)) { + htmlDescription!! + } else { + "$title\n\n$textDescription" + } } } diff --git a/notifications/core/build.gradle b/notifications/core/build.gradle index a00ccf60..75fbfdf8 100644 --- a/notifications/core/build.gradle +++ b/notifications/core/build.gradle @@ -144,6 +144,8 @@ dependencies { implementation "com.sun.mail:javax.mail:1.6.2" implementation "javax.activation:activation:1.1" implementation "org.slf4j:slf4j-api:${versions.slf4j}" //Needed for httpclient5 + implementation "com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20220608.1" + implementation 'com.google.guava:guava:32.1.1-jre' testImplementation( 'org.assertj:assertj-core:3.16.1', 'org.junit.jupiter:junit-jupiter-api:5.6.2', diff --git a/notifications/core/src/main/config/notifications-core.yml b/notifications/core/src/main/config/notifications-core.yml index 57b2d613..ea1250a2 100644 --- a/notifications/core/src/main/config/notifications-core.yml +++ b/notifications/core/src/main/config/notifications-core.yml @@ -9,6 +9,8 @@ opensearch.notifications.core: email: size_limit: 10000000 minimum_header_length: 160 + enable_html_sanitization: true + html_sanitization_allow_list: ["blocks_group", "formatting_group", "images_group", "links_group", "styles_group", "tables_group"] http: max_connections: 60 max_connection_per_route: 20 diff --git a/notifications/core/src/main/kotlin/org/opensearch/notifications/core/client/EmailMimeProvider.kt b/notifications/core/src/main/kotlin/org/opensearch/notifications/core/client/EmailMimeProvider.kt index d05ad4e7..04610a21 100644 --- a/notifications/core/src/main/kotlin/org/opensearch/notifications/core/client/EmailMimeProvider.kt +++ b/notifications/core/src/main/kotlin/org/opensearch/notifications/core/client/EmailMimeProvider.kt @@ -5,7 +5,13 @@ package org.opensearch.notifications.core.client +import org.opensearch.notifications.core.setting.PluginSettings +import org.opensearch.notifications.core.utils.logger import org.opensearch.notifications.spi.model.MessageContent +import org.owasp.html.AttributePolicy +import org.owasp.html.HtmlChangeListener +import org.owasp.html.HtmlPolicyBuilder +import org.owasp.html.PolicyFactory import java.util.Base64 import javax.activation.DataHandler import javax.mail.Message @@ -19,6 +25,86 @@ import javax.mail.util.ByteArrayDataSource * Object for creating mime mimeMessage from the channel mimeMessage for sending mail. */ internal object EmailMimeProvider { + private val log by logger(EmailMimeProvider::class.java) + + // org.owasp.html.Sanitizers provide some default sanitizers which can be used directly, + // we define these html elements groups and map them to the default sanitizers, users can specify these groups in the allow list, + // and users can still specify individual html tags or elements in the allow list or deny list + private const val HTML_ELEMENTS_GROUP_BLOCKS = "blocks_group" + private const val HTML_ELEMENTS_GROUP_FORMATTING = "formatting_group" + private const val HTML_ELEMENTS_GROUP_IMAGES = "images_group" + private const val HTML_ELEMENTS_GROUP_LINKS = "links_group" + private const val HTML_ELEMENTS_GROUP_STYLES = "styles_group" + private const val HTML_ELEMENTS_GROUP_TABLES = "tables_group" + + // copied from org.owasp.html.Sanitizers.INTEGER + private val INTEGER = AttributePolicy { _, _, value -> + val n = value.length + if (n == 0) { + return@AttributePolicy null + } + for (i in 0 until n) { + val ch = value[i] + if (ch == '.') { + return@AttributePolicy if (i == 0) { + null + } else value.substring(0, i) + // truncate to integer. + } else if (ch !in '0'..'9') { + return@AttributePolicy null + } + } + value + } + private val htmlSanitizationPolicy: PolicyFactory? + get() { + if (PluginSettings.enableEmailHtmlSanitization) { + var policyBuilder = HtmlPolicyBuilder() + if (PluginSettings.emailHtmlSanitizationAllowList.isNotEmpty()) { + PluginSettings.emailHtmlSanitizationAllowList.forEach { e -> + when (e) { + // we do not use the pre-defined sanitizers directly but copy the definition here, + // because found that deny list takes no effect if we use the pre-defined sanitizers + HTML_ELEMENTS_GROUP_BLOCKS -> policyBuilder = policyBuilder.allowCommonBlockElements() + HTML_ELEMENTS_GROUP_FORMATTING -> policyBuilder = policyBuilder.allowCommonInlineFormattingElements() + HTML_ELEMENTS_GROUP_IMAGES -> policyBuilder = policyBuilder.allowUrlProtocols("http", "https").allowElements("img") + .allowAttributes("alt", "src").onElements("img") + .allowAttributes("border", "height", "width").matching(INTEGER) + .onElements("img") + HTML_ELEMENTS_GROUP_LINKS -> policyBuilder = policyBuilder.allowStandardUrlProtocols().allowElements("a") + .allowAttributes("href").onElements("a").requireRelNofollowOnLinks() + HTML_ELEMENTS_GROUP_STYLES -> policyBuilder = policyBuilder.allowStyling() + HTML_ELEMENTS_GROUP_TABLES -> policyBuilder = policyBuilder.allowStandardUrlProtocols() + .allowElements( + "table", "tr", "td", "th", + "colgroup", "caption", "col", + "thead", "tbody", "tfoot" + ) + .allowAttributes("summary").onElements("table") + .allowAttributes("align", "valign") + .onElements( + "table", "tr", "td", "th", + "colgroup", "col", + "thead", "tbody", "tfoot" + ) + .allowTextIn("table") + else -> policyBuilder = policyBuilder.allowElements(e) + } + } + } + + if (PluginSettings.emailHtmlSanitizationDenyList.isNotEmpty()) { + // deny list only accepts individual html tags or elements + PluginSettings.emailHtmlSanitizationDenyList.forEach { e -> + policyBuilder = policyBuilder.disallowElements(e) + } + } + + return policyBuilder.toFactory() + } + return null + } + /** * Create and prepare mime mimeMessage to send mail * @param session The mail session to use to create mime mimeMessage @@ -60,7 +146,33 @@ internal object EmailMimeProvider { // Define the HTML part if (messageContent.htmlDescription != null) { val htmlPart = MimeBodyPart() - htmlPart.setContent(messageContent.htmlDescription, "text/html; charset=UTF-8") + if (PluginSettings.enableEmailHtmlSanitization && htmlSanitizationPolicy != null) { + log.info( + "html sanitization for email enabled, allow list: [" + PluginSettings.emailHtmlSanitizationAllowList.joinToString() + "], deny list: [" + + PluginSettings.emailHtmlSanitizationDenyList.joinToString() + "], will sanitize the html body of the email from $fromAddress to $recipient" + ) + val sanitizedHtml = htmlSanitizationPolicy!!.sanitize( + messageContent.htmlDescription, + object : HtmlChangeListener { + override fun discardedTag(context: String?, elementName: String) { + log.debug("html sanitization for email, discard tag: $elementName") + } + + override fun discardedAttributes( + context: String?, + elementName: String, + vararg attributeNames: String + ) { + log.debug("html sanitization for email, discard attributes: $attributeNames") + } + }, + null + ) + htmlPart.setContent(sanitizedHtml, "text/html; charset=UTF-8") + } else { + htmlPart.setContent(messageContent.htmlDescription, "text/html; charset=UTF-8") + } + // Add the HTML part to the child container msgBody.addBodyPart(htmlPart) } diff --git a/notifications/core/src/main/kotlin/org/opensearch/notifications/core/setting/PluginSettings.kt b/notifications/core/src/main/kotlin/org/opensearch/notifications/core/setting/PluginSettings.kt index 70401dee..1524eded 100644 --- a/notifications/core/src/main/kotlin/org/opensearch/notifications/core/setting/PluginSettings.kt +++ b/notifications/core/src/main/kotlin/org/opensearch/notifications/core/setting/PluginSettings.kt @@ -105,6 +105,21 @@ internal object PluginSettings { */ private const val TOOLTIP_SUPPORT_KEY = "$KEY_PREFIX.tooltip_support" + /** + * Setting to enable email html sanitization + */ + private const val ENABLE_EMAIL_HTML_SANITIZATION_KEY = "$EMAIL_KEY_PREFIX.enable_html_sanitization" + + /** + * Setting for allow list of email html sanitization + */ + private const val EMAIL_HTML_SANITIZATION_ALLOW_LIST_KEY = "$EMAIL_KEY_PREFIX.html_sanitization_allow_list" + + /** + * Setting for deny list of email html sanitization + */ + private const val EMAIL_HTML_SANITIZATION_DENY_LIST_KEY = "$EMAIL_KEY_PREFIX.html_sanitization_deny_list" + /** * Default email size limit as 10MB. */ @@ -169,6 +184,23 @@ internal object PluginSettings { */ private val DEFAULT_DESTINATION_SETTINGS = emptyMap() + /** + * Default enable email html sanitization + */ + private const val DEFAULT_ENABLE_EMAIL_HTML_SANITIZATION = true + + /** + * Default email html sanitization allow list + */ + private val DEFAULT_EMAIL_HTML_SANITIZATION_ALLOW_LIST = listOf( + "blocks_group", "formatting_group", "images_group", "links_group", "styles_group", "tables_group" + ) + + /** + * Default email html sanitization deny list + */ + private val DEFAULT_EMAIL_HTML_SANITIZATION_DENY_LIST = emptyList() + /** * list of allowed config types. */ @@ -229,6 +261,24 @@ internal object PluginSettings { @Volatile var destinationSettings: Map + /** + * Enable email html sanitization + */ + @Volatile + var enableEmailHtmlSanitization: Boolean + + /** + * Email html sanitization allow list + */ + @Volatile + var emailHtmlSanitizationAllowList: List + + /** + * Email html sanitization deny list + */ + @Volatile + var emailHtmlSanitizationDenyList: List + private const val DECIMAL_RADIX: Int = 10 private val log by logger(javaClass) @@ -259,6 +309,18 @@ internal object PluginSettings { tooltipSupport = settings?.getAsBoolean(TOOLTIP_SUPPORT_KEY, true) ?: DEFAULT_TOOLTIP_SUPPORT hostDenyList = settings?.getAsList(HOST_DENY_LIST_KEY, null) ?: DEFAULT_HOST_DENY_LIST destinationSettings = if (settings != null) loadDestinationSettings(settings) else DEFAULT_DESTINATION_SETTINGS + enableEmailHtmlSanitization = + settings?.getAsBoolean(ENABLE_EMAIL_HTML_SANITIZATION_KEY, true) ?: DEFAULT_ENABLE_EMAIL_HTML_SANITIZATION + emailHtmlSanitizationAllowList = settings?.getAsList(EMAIL_HTML_SANITIZATION_ALLOW_LIST_KEY, null) + ?: DEFAULT_EMAIL_HTML_SANITIZATION_ALLOW_LIST + emailHtmlSanitizationDenyList = settings?.getAsList(EMAIL_HTML_SANITIZATION_DENY_LIST_KEY, null) + ?: DEFAULT_EMAIL_HTML_SANITIZATION_DENY_LIST + // html sanitization deny list can be set only when allow list is not empty + // this behavior aligns with the owasp html sanitizer, which disallow any elements by default + // but can use deny list to make an exception based on the allow list + if (emailHtmlSanitizationAllowList.isEmpty() && emailHtmlSanitizationDenyList.isNotEmpty()) { + throw IllegalArgumentException("html_sanitization_deny_list cannot be set if html_sanitization_allow_list is empty!") + } defaultSettings = mapOf( EMAIL_SIZE_LIMIT_KEY to emailSizeLimit.toString(DECIMAL_RADIX), @@ -267,7 +329,8 @@ internal object PluginSettings { MAX_CONNECTIONS_PER_ROUTE_KEY to maxConnectionsPerRoute.toString(DECIMAL_RADIX), CONNECTION_TIMEOUT_MILLISECONDS_KEY to connectionTimeout.toString(DECIMAL_RADIX), SOCKET_TIMEOUT_MILLISECONDS_KEY to socketTimeout.toString(DECIMAL_RADIX), - TOOLTIP_SUPPORT_KEY to tooltipSupport.toString() + TOOLTIP_SUPPORT_KEY to tooltipSupport.toString(), + ENABLE_EMAIL_HTML_SANITIZATION_KEY to enableEmailHtmlSanitization.toString(), ) } @@ -342,6 +405,26 @@ internal object PluginSettings { NodeScope, Dynamic ) + val ENABLE_EMAIL_HTML_SANITIZATION: Setting = Setting.boolSetting( + ENABLE_EMAIL_HTML_SANITIZATION_KEY, + defaultSettings[ENABLE_EMAIL_HTML_SANITIZATION_KEY]!!.toBoolean(), + NodeScope, Final + ) + + val EMAIL_HTML_SANITIZATION_ALLOW_LIST: Setting> = Setting.listSetting( + EMAIL_HTML_SANITIZATION_ALLOW_LIST_KEY, + DEFAULT_EMAIL_HTML_SANITIZATION_ALLOW_LIST, + { it }, + NodeScope, Final + ) + + val EMAIL_HTML_SANITIZATION_DENY_LIST: Setting> = Setting.listSetting( + EMAIL_HTML_SANITIZATION_DENY_LIST_KEY, + DEFAULT_EMAIL_HTML_SANITIZATION_DENY_LIST, + { it }, + NodeScope, Final + ) + private val LEGACY_EMAIL_USERNAME: Setting.AffixSetting = Setting.affixKeySetting( LEGACY_EMAIL_DESTINATION_SETTING_PREFIX, "username", @@ -393,7 +476,10 @@ internal object PluginSettings { TOOLTIP_SUPPORT, HOST_DENY_LIST, EMAIL_USERNAME, - EMAIL_PASSWORD + EMAIL_PASSWORD, + ENABLE_EMAIL_HTML_SANITIZATION, + EMAIL_HTML_SANITIZATION_ALLOW_LIST, + EMAIL_HTML_SANITIZATION_DENY_LIST ) } /** @@ -401,7 +487,10 @@ internal object PluginSettings { * @param clusterService cluster service instance */ private fun updateSettingValuesFromLocal(clusterService: ClusterService) { - allowedConfigTypes = ALLOWED_CONFIG_TYPES.get(clusterService.settings) + val localAllowedConfigTypes = clusterService.settings.get(ALLOWED_CONFIG_TYPE_KEY) + if (localAllowedConfigTypes != null) { + allowedConfigTypes = clusterService.settings.getAsList(ALLOWED_CONFIG_TYPE_KEY) + } emailSizeLimit = EMAIL_SIZE_LIMIT.get(clusterService.settings) emailMinimumHeaderLength = EMAIL_MINIMUM_HEADER_LENGTH.get(clusterService.settings) maxConnections = MAX_CONNECTIONS.get(clusterService.settings) @@ -409,8 +498,20 @@ internal object PluginSettings { connectionTimeout = CONNECTION_TIMEOUT_MILLISECONDS.get(clusterService.settings) socketTimeout = SOCKET_TIMEOUT_MILLISECONDS.get(clusterService.settings) tooltipSupport = TOOLTIP_SUPPORT.get(clusterService.settings) - hostDenyList = HOST_DENY_LIST.get(clusterService.settings) + val localHostDenyList = clusterService.settings.get(HOST_DENY_LIST_KEY) + if (localHostDenyList != null) { + hostDenyList = clusterService.settings.getAsList(HOST_DENY_LIST_KEY) + } destinationSettings = loadDestinationSettings(clusterService.settings) + enableEmailHtmlSanitization = ENABLE_EMAIL_HTML_SANITIZATION.get(clusterService.settings) + val localEmailHtmlSanitizationAllowList = clusterService.settings.get(EMAIL_HTML_SANITIZATION_ALLOW_LIST_KEY) + if (localEmailHtmlSanitizationAllowList != null) { + emailHtmlSanitizationAllowList = clusterService.settings.getAsList(EMAIL_HTML_SANITIZATION_ALLOW_LIST_KEY) + } + val localEmailHtmlSanitizationDenyList = clusterService.settings.get(EMAIL_HTML_SANITIZATION_DENY_LIST_KEY) + if (localEmailHtmlSanitizationDenyList != null) { + emailHtmlSanitizationDenyList = clusterService.settings.getAsList(EMAIL_HTML_SANITIZATION_DENY_LIST_KEY) + } } /** @@ -570,5 +671,8 @@ internal object PluginSettings { allowedConfigTypes = DEFAULT_ALLOWED_CONFIG_TYPES tooltipSupport = DEFAULT_TOOLTIP_SUPPORT hostDenyList = DEFAULT_HOST_DENY_LIST + enableEmailHtmlSanitization = DEFAULT_ENABLE_EMAIL_HTML_SANITIZATION + emailHtmlSanitizationAllowList = DEFAULT_EMAIL_HTML_SANITIZATION_ALLOW_LIST + emailHtmlSanitizationDenyList = DEFAULT_EMAIL_HTML_SANITIZATION_DENY_LIST } } diff --git a/notifications/core/src/test/kotlin/org/opensearch/notifications/core/destinations/ChimeDestinationTests.kt b/notifications/core/src/test/kotlin/org/opensearch/notifications/core/destinations/ChimeDestinationTests.kt index aebcde92..2d7a7e8a 100644 --- a/notifications/core/src/test/kotlin/org/opensearch/notifications/core/destinations/ChimeDestinationTests.kt +++ b/notifications/core/src/test/kotlin/org/opensearch/notifications/core/destinations/ChimeDestinationTests.kt @@ -166,7 +166,7 @@ internal class ChimeDestinationTests { val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { MessageContent("title", "") } - assertEquals("text message part is null or empty", exception.message) + assertEquals("both text message part and html message part are null or empty", exception.message) } @ParameterizedTest diff --git a/notifications/core/src/test/kotlin/org/opensearch/notifications/core/destinations/EmailMimeProviderTests.kt b/notifications/core/src/test/kotlin/org/opensearch/notifications/core/destinations/EmailMimeProviderTests.kt new file mode 100644 index 00000000..fcfe5e66 --- /dev/null +++ b/notifications/core/src/test/kotlin/org/opensearch/notifications/core/destinations/EmailMimeProviderTests.kt @@ -0,0 +1,235 @@ +package org.opensearch.notifications.core.destinations + +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.opensearch.notifications.core.client.EmailMimeProvider +import org.opensearch.notifications.core.setting.PluginSettings +import org.opensearch.notifications.spi.model.MessageContent +import java.io.ByteArrayOutputStream +import java.util.Properties +import javax.mail.Session + +internal class EmailMimeProviderTests { + @AfterEach + fun reset() { + PluginSettings.reset() + } + + @Test + // Use default config to sanitize html, + // h1, p and other basic elements will be kept, images and links will be kept, + // but script and iframe will be sanitized + fun testPrepareMimeMessageWithDefaultHTMLSanitizationConfig() { + val prop = Properties() + prop["mail.transport.protocol"] = "smtp" + prop["mail.smtp.host"] = "0.0.0.0" + prop["mail.smtp.port"] = "587" + val session = Session.getInstance(prop) + val subject = "Test sending HTML email subject" + val htmlBody = "\n" + + "

Test sending HTML email body

\n" + + "

Hello OpenSearch.

\n" + + "\n" + + "\n" + + "Test link for html sanitization\n" + + "\n" + + "\n" + + "\"Test\n" + + val message = MessageContent(subject, "", htmlBody) + val sanitizedMimeMessage = + EmailMimeProvider.prepareMimeMessage(session, "from@from.com", "recipient@recipient.com", message) + val outputStream = ByteArrayOutputStream() + sanitizedMimeMessage.writeTo(outputStream) + val sanitizedMessageStr = outputStream.toString() + + Assertions.assertTrue(sanitizedMessageStr.contains("Test sending HTML email body")) + Assertions.assertTrue(sanitizedMessageStr.contains("Hello OpenSearch.")) + Assertions.assertTrue(sanitizedMessageStr.contains("")) + Assertions.assertFalse(sanitizedMessageStr.contains("Test script for html sanitization")) + Assertions.assertFalse(sanitizedMessageStr.contains("\n" + + "\"Test\n" + + val message = MessageContent(subject, "", htmlBody) + val sanitizedMimeMessage = + EmailMimeProvider.prepareMimeMessage(session, "from@from.com", "recipient@recipient.com", message) + val outputStream = ByteArrayOutputStream() + sanitizedMimeMessage.writeTo(outputStream) + val sanitizedMessageStr = outputStream.toString() + + Assertions.assertTrue(sanitizedMessageStr.contains("Test sending HTML email body")) + Assertions.assertTrue(sanitizedMessageStr.contains("Hello OpenSearch.")) + Assertions.assertTrue(sanitizedMessageStr.contains("")) + Assertions.assertTrue(sanitizedMessageStr.contains("Test script for html sanitization")) + Assertions.assertTrue(sanitizedMessageStr.contains("\n" + + "\"Test\n" + + val message = MessageContent(subject, "", htmlBody) + val sanitizedMimeMessage = + EmailMimeProvider.prepareMimeMessage(session, "from@from.com", "recipient@recipient.com", message) + val outputStream = ByteArrayOutputStream() + sanitizedMimeMessage.writeTo(outputStream) + val sanitizedMessageStr = outputStream.toString() + + Assertions.assertTrue(sanitizedMessageStr.contains("Test sending HTML email body")) + Assertions.assertTrue(sanitizedMessageStr.contains("Hello OpenSearch.")) + Assertions.assertTrue(sanitizedMessageStr.contains("Test link for html sanitization")) + + Assertions.assertFalse(sanitizedMessageStr.contains("")) + Assertions.assertFalse(sanitizedMessageStr.contains("Test script for html sanitization")) + Assertions.assertFalse(sanitizedMessageStr.contains("\n" + + "\"Test\n" + + val message = MessageContent(subject, "", htmlBody) + val sanitizedMimeMessage = + EmailMimeProvider.prepareMimeMessage(session, "from@from.com", "recipient@recipient.com", message) + val outputStream = ByteArrayOutputStream() + sanitizedMimeMessage.writeTo(outputStream) + val sanitizedMessageStr = outputStream.toString() + + Assertions.assertTrue(sanitizedMessageStr.contains("Test sending HTML email body")) + Assertions.assertTrue(sanitizedMessageStr.contains("Hello OpenSearch.")) + + Assertions.assertFalse(sanitizedMessageStr.contains("")) + Assertions.assertFalse(sanitizedMessageStr.contains("Test script for html sanitization")) + Assertions.assertFalse(sanitizedMessageStr.contains("