Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ data class Identity(
val email: String? = null,
val signature: String? = null,
val signatureUse: Boolean = false,
val signatureIsHtml: Boolean = false,
val replyTo: String? = null,
) : Parcelable {
// TODO remove when callers are converted to Kotlin
fun withName(name: String?) = copy(name = name)
fun withSignature(signature: String?) = copy(signature = signature)
fun withSignatureUse(signatureUse: Boolean) = copy(signatureUse = signatureUse)
fun withSignatureIsHtml(signatureIsHtml: Boolean) = copy(signatureIsHtml = signatureIsHtml)
fun withEmail(email: String?) = copy(email = email)
}
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,15 @@ open class LegacyAccountDto(
identities[0] = newIdentity
}

@get:Synchronized
@set:Synchronized
var signatureIsHtml: Boolean
get() = identities[0].signatureIsHtml
set(signatureIsHtml) {
val newIdentity = identities[0].withSignatureIsHtml(signatureIsHtml)
identities[0] = newIdentity
}

@get:JvmName("shouldMigrateToOAuth")
@get:Synchronized
@set:Synchronized
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ class LegacyAccountStorageHandler(
val email = storage.getStringOrNull(keyGen.create("$IDENTITY_EMAIL_KEY.$ident"))
val signatureUse = storage.getBoolean(keyGen.create("signatureUse.$ident"), false)
val signature = storage.getStringOrNull(keyGen.create("signature.$ident"))
val signatureIsHtml = storage.getBoolean(keyGen.create("signatureIsHtml.$ident"), false)
val description = storage.getStringOrNull(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$ident"))
val replyTo = storage.getStringOrNull(keyGen.create("replyTo.$ident"))
if (email != null) {
Expand All @@ -279,6 +280,7 @@ class LegacyAccountStorageHandler(
email = email,
signatureUse = signatureUse,
signature = signature,
signatureIsHtml = signatureIsHtml,
description = description,
replyTo = replyTo,
)
Expand All @@ -293,11 +295,13 @@ class LegacyAccountStorageHandler(
val email = storage.getStringOrNull(keyGen.create("email"))
val signatureUse = storage.getBoolean(keyGen.create("signatureUse"), false)
val signature = storage.getStringOrNull(keyGen.create("signature"))
val signatureIsHtml = storage.getBoolean(keyGen.create("signatureIsHtml"), false)
val identity = Identity(
name = name,
email = email,
signatureUse = signatureUse,
signature = signature,
signatureIsHtml = signatureIsHtml,
description = email,
)
newIdentities.add(identity)
Expand Down Expand Up @@ -556,6 +560,7 @@ class LegacyAccountStorageHandler(
editor.putString(keyGen.create("$IDENTITY_EMAIL_KEY.$ident"), identity.email)
editor.putBoolean(keyGen.create("signatureUse.$ident"), identity.signatureUse)
editor.putString(keyGen.create("signature.$ident"), identity.signature)
editor.putBoolean(keyGen.create("signatureIsHtml.$ident"), identity.signatureIsHtml)
editor.putString(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$ident"), identity.description)
editor.putString(keyGen.create("replyTo.$ident"), identity.replyTo)
ident++
Expand All @@ -577,6 +582,7 @@ class LegacyAccountStorageHandler(
editor.remove(keyGen.create("$IDENTITY_EMAIL_KEY.$identityIndex"))
editor.remove(keyGen.create("signatureUse.$identityIndex"))
editor.remove(keyGen.create("signature.$identityIndex"))
editor.remove(keyGen.create("signatureIsHtml.$identityIndex"))
editor.remove(keyGen.create("$IDENTITY_DESCRIPTION_KEY.$identityIndex"))
editor.remove(keyGen.create("replyTo.$identityIndex"))
gotOne = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ private TextBody buildText(boolean isDraft, SimpleMessageFormat simpleMessageFor
if (useSignature) {
textBodyBuilder.setAppendSignature(true);
textBodyBuilder.setSignature(signature);
textBodyBuilder.setSignatureIsHtml(identity.getSignatureIsHtml());
textBodyBuilder.setSignatureBeforeQuotedText(isSignatureBeforeQuotedText);
} else {
textBodyBuilder.setAppendSignature(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import com.fsck.k9.K9;
import com.fsck.k9.message.html.HtmlConverter;
import com.fsck.k9.message.html.HtmlSignatureSanitizer;
import com.fsck.k9.mail.Body;
import com.fsck.k9.mail.internet.TextBody;
import com.fsck.k9.message.quote.InsertableHtmlContent;
Expand All @@ -20,6 +21,7 @@ class TextBodyBuilder {
private boolean mSignatureBeforeQuotedText = false;
private boolean mInsertSeparator = false;
private boolean mAppendSignature = true;
private boolean mSignatureIsHtml = false;

private String mMessageContent;
private String mSignature;
Expand Down Expand Up @@ -182,7 +184,10 @@ public TextBody buildTextPlain() {
private String getSignature() {
String signature = "";
if (!isEmpty(mSignature)) {
signature = "\r\n" + mSignature;
String plainSignature = mSignatureIsHtml
? HtmlConverter.htmlToText(mSignature)
: mSignature;
signature = "\r\n" + plainSignature;
}

return signature;
Expand All @@ -191,7 +196,9 @@ private String getSignature() {
private String getSignatureHtml() {
String signature = "";
if (!isEmpty(mSignature)) {
signature = HtmlConverter.textToHtmlFragment(mSignature);
signature = mSignatureIsHtml
? HtmlSignatureSanitizer.sanitize(mSignature)
: HtmlConverter.textToHtmlFragment(mSignature);
}
return signature;
}
Expand Down Expand Up @@ -244,6 +251,10 @@ public void setAppendSignature(boolean appendSignature) {
mAppendSignature = appendSignature;
}

public void setSignatureIsHtml(boolean signatureIsHtml) {
mSignatureIsHtml = signatureIsHtml;
}

private static boolean isEmpty(String s) {
return s == null || s.length() == 0;
}
Expand Down
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ class IdentitySettingsDescriptions {
new V(1, new BooleanSetting(true)),
new V(68, new BooleanSetting(false))
));
s.put("signatureIsHtml", Settings.versions(
new V(111, new BooleanSetting(false))
));
s.put("replyTo", Settings.versions(
new V(1, new OptionalEmailAddressSetting())
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class Settings {
*
* @see SettingsExporter
*/
public static final int VERSION = 110;
public static final int VERSION = 111;

static Map<String, Object> validate(int version, Map<String, TreeMap<Integer, SettingsDescription<?>>> settings,
Map<String, String> importedSettings, boolean useDefaultValues) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,14 @@ class IdentityHeaderBuilderTest : RobolectricTest() {
signatureUse: Boolean = false,
replyTo: String? = null,
): Identity {
return Identity(description, name, email, signature, signatureUse, replyTo)
return Identity(
description = description,
name = name,
email = email,
signature = signature,
signatureUse = signatureUse,
replyTo = replyTo,
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ private Identity createIdentity() {
TEST_IDENTITY_ADDRESS.getAddress(),
null,
false,
false,
null
);
}
Expand Down
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 &lt;b&gt; if we had
// gone through HtmlConverter.textToHtmlFragment().
assertThat(body).contains("<b>Thunderbird</b>")
assertThat(body).doesNotContain("&lt;b&gt;")
}

@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")
}
}
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")
}
}
Loading
Loading