-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
feat(signature): add HTML signature support #10876
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.fsck.k9.message.html | ||
|
|
||
| import org.jsoup.Jsoup | ||
| import org.jsoup.safety.Safelist | ||
|
|
||
| /** | ||
| * Sanitizes user-supplied HTML signatures before they are inserted into outgoing mail. | ||
| * | ||
| * Uses a Jsoup [Safelist.relaxed] baseline (common formatting tags, images, links, tables) | ||
| * and tightens it so scripting constructs cannot survive a round-trip through the signature | ||
| * field. Specifically, all `on*` event-handler attributes and `javascript:` URLs are removed, | ||
| * and `<script>`, `<style>`, `<iframe>`, `<object>`, and `<embed>` elements are not in the | ||
| * allowlist to begin with. | ||
| */ | ||
| object HtmlSignatureSanitizer { | ||
| private val safelist: Safelist = Safelist.relaxed() | ||
| .addAttributes(":all", "style", "class", "dir") | ||
| .addProtocols("a", "href", "http", "https", "mailto", "tel") | ||
| .addProtocols("img", "src", "http", "https", "data", "cid") | ||
|
|
||
| @JvmStatic | ||
| fun sanitize(html: String): String { | ||
| if (html.isEmpty()) return html | ||
| return Jsoup.clean(html, "", safelist) | ||
| } | ||
| } |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unrelated change, please revert |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please, follow our Testing guidelines when writing unit tests |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| package com.fsck.k9.message | ||
|
|
||
| import assertk.assertThat | ||
| import assertk.assertions.contains | ||
| import assertk.assertions.doesNotContain | ||
| import com.fsck.k9.notification.FakePlatformConfigProvider | ||
| import net.thunderbird.core.logging.legacy.Log | ||
| import net.thunderbird.core.logging.testing.TestLogger | ||
| import net.thunderbird.core.preference.GeneralSettings | ||
| import org.junit.Before | ||
| import org.junit.Test | ||
| import org.mockito.kotlin.doReturn | ||
| import org.mockito.kotlin.mock | ||
|
|
||
| /** | ||
| * Exercises the HTML signature short-circuit added for the HTML signature feature. | ||
| * | ||
| * The existing parameterized [TextBodyBuilderTest] covers the plain-text signature | ||
| * path; these cases add coverage for [TextBodyBuilder.setSignatureIsHtml]. | ||
| */ | ||
| class TextBodyBuilderHtmlSignatureTest { | ||
|
|
||
| @Before | ||
| fun setUp() { | ||
| Log.logger = TestLogger() | ||
| } | ||
|
|
||
| private fun newBuilder(messageContent: String = "hello"): TextBodyBuilder { | ||
| return TextBodyBuilder( | ||
| messageContent, | ||
| mock { on { getConfig() } doReturn GeneralSettings(platformConfigProvider = FakePlatformConfigProvider()) }, | ||
| ).apply { | ||
| setAppendSignature(true) | ||
| setIncludeQuotedText(false) | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| fun `html signature is embedded verbatim in html body without text-to-html conversion`() { | ||
| val htmlSignature = """<p>Sent from <b>Thunderbird</b></p>""" | ||
| val builder = newBuilder().apply { | ||
| setSignatureIsHtml(true) | ||
| setSignature(htmlSignature) | ||
| } | ||
|
|
||
| val body = builder.buildTextHtml().rawText | ||
|
|
||
| // The <b> tag is preserved — it would have been escaped to <b> if we had | ||
| // gone through HtmlConverter.textToHtmlFragment(). | ||
| assertThat(body).contains("<b>Thunderbird</b>") | ||
| assertThat(body).doesNotContain("<b>") | ||
| } | ||
|
|
||
| @Test | ||
| fun `html signature has script tags removed when embedded in html body`() { | ||
| val htmlSignature = """<p>Hi</p><script>alert('xss')</script>""" | ||
| val builder = newBuilder().apply { | ||
| setSignatureIsHtml(true) | ||
| setSignature(htmlSignature) | ||
| } | ||
|
|
||
| val body = builder.buildTextHtml().rawText | ||
|
|
||
| assertThat(body).contains("<p>Hi</p>") | ||
| assertThat(body).doesNotContain("<script>") | ||
| assertThat(body).doesNotContain("alert") | ||
| } | ||
|
|
||
| @Test | ||
| fun `html signature is converted to plain text when building plain body`() { | ||
| val htmlSignature = """<p>Sent from <b>Thunderbird</b></p>""" | ||
| val builder = newBuilder().apply { | ||
| setSignatureIsHtml(true) | ||
| setSignature(htmlSignature) | ||
| } | ||
|
|
||
| val body = builder.buildTextPlain().rawText | ||
|
|
||
| // The HTML tags should be stripped for the plain-text path. | ||
| assertThat(body).contains("Sent from Thunderbird") | ||
| assertThat(body).doesNotContain("<b>") | ||
| assertThat(body).doesNotContain("<p>") | ||
| } | ||
|
|
||
| @Test | ||
| fun `plain signature still goes through text-to-html conversion in html body`() { | ||
| val plainSignature = "-- \r\nAlice" | ||
| val builder = newBuilder().apply { | ||
| setSignatureIsHtml(false) | ||
| setSignature(plainSignature) | ||
| } | ||
|
|
||
| val body = builder.buildTextHtml().rawText | ||
|
|
||
| // The plain-text path wraps the signature in a k9mail-signature div. | ||
| assertThat(body).contains("k9mail-signature") | ||
| } | ||
| } |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please, follow our Testing guidelines when writing unit tests |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| package com.fsck.k9.message.html | ||
|
|
||
| import assertk.assertThat | ||
| import assertk.assertions.contains | ||
| import assertk.assertions.doesNotContain | ||
| import assertk.assertions.isEmpty | ||
| import assertk.assertions.isEqualTo | ||
| import org.junit.Test | ||
|
|
||
| class HtmlSignatureSanitizerTest { | ||
|
|
||
| @Test | ||
| fun `empty input returns empty string`() { | ||
| assertThat(HtmlSignatureSanitizer.sanitize("")).isEmpty() | ||
| } | ||
|
|
||
| @Test | ||
| fun `preserves basic formatting tags`() { | ||
| val input = "<p>Hello <b>world</b></p>" | ||
| assertThat(HtmlSignatureSanitizer.sanitize(input)).contains("<b>world</b>") | ||
| } | ||
|
|
||
| @Test | ||
| fun `preserves anchors with https urls`() { | ||
| val input = """<a href="https://example.com">example</a>""" | ||
| val result = HtmlSignatureSanitizer.sanitize(input) | ||
| assertThat(result).contains("""href="https://example.com"""") | ||
| assertThat(result).contains(">example</a>") | ||
| } | ||
|
|
||
| @Test | ||
| fun `preserves img with https src`() { | ||
| val input = """<img src="https://example.com/logo.png" alt="logo">""" | ||
| val result = HtmlSignatureSanitizer.sanitize(input) | ||
| assertThat(result).contains("""src="https://example.com/logo.png"""") | ||
| } | ||
|
|
||
| @Test | ||
| fun `preserves inline style attributes`() { | ||
| val input = """<span style="color: red;">red</span>""" | ||
| assertThat(HtmlSignatureSanitizer.sanitize(input)).contains("""style="color: red;"""") | ||
| } | ||
|
|
||
| @Test | ||
| fun `strips script elements`() { | ||
| val input = "<p>Hi</p><script>alert('xss')</script>" | ||
| val result = HtmlSignatureSanitizer.sanitize(input) | ||
| assertThat(result).doesNotContain("script") | ||
| assertThat(result).doesNotContain("alert") | ||
| } | ||
|
|
||
| @Test | ||
| fun `strips inline event handler attributes`() { | ||
| val input = """<a href="https://example.com" onclick="alert(1)">click</a>""" | ||
| val result = HtmlSignatureSanitizer.sanitize(input) | ||
| assertThat(result).doesNotContain("onclick") | ||
| assertThat(result).doesNotContain("alert") | ||
| } | ||
|
|
||
| @Test | ||
| fun `strips javascript urls from anchors`() { | ||
| val input = """<a href="javascript:alert(1)">click</a>""" | ||
| val result = HtmlSignatureSanitizer.sanitize(input) | ||
| assertThat(result).doesNotContain("javascript") | ||
| assertThat(result).doesNotContain("alert") | ||
| } | ||
|
|
||
| @Test | ||
| fun `strips iframe elements`() { | ||
| val input = """<iframe src="https://evil.example"></iframe>""" | ||
| assertThat(HtmlSignatureSanitizer.sanitize(input)).doesNotContain("iframe") | ||
| } | ||
|
|
||
| @Test | ||
| fun `plain text passes through unchanged`() { | ||
| val input = "Just some text" | ||
| assertThat(HtmlSignatureSanitizer.sanitize(input)).isEqualTo("Just some text") | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Could be final