Skip to content

Commit bb08e1c

Browse files
committed
fix: properly implement sanitization in Var
1 parent 7f3af23 commit bb08e1c

File tree

7 files changed

+85
-56
lines changed

7 files changed

+85
-56
lines changed

backend/data/src/main/kotlin/io/tolgee/email/EmailMessageSource.kt

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ import java.util.regex.Pattern
3030
* Cannot be written as a subclass of ICUReloadableResourceBundleMessageSource as its `getMessage` methods are final.
3131
*/
3232
class EmailMessageSource(
33-
private val provider: ICUMessageSource
33+
private val provider: ICUMessageSource,
34+
private val emailTemplateUtils: EmailTemplateUtils
3435
) : ICUMessageSource {
3536
private var counter = 0L
3637

@@ -103,14 +104,7 @@ class EmailMessageSource(
103104
private fun makeRef(): String = "intl-ref-${count()}"
104105

105106
private fun String.postProcessMessage(code: String): String {
106-
var str = this.replace("\n", "<br/>")
107-
// Prevent Thymeleaf injection (for the second pass)
108-
.replace("{", "&#123;")
109-
.replace("[", "&#91;")
110-
.replace("$", "&#36;")
111-
.replace("*", "&#42;")
112-
.replace("#", "&#35;")
113-
.replace("~", "&#126;")
107+
var str = emailTemplateUtils.escape(this).replace("\n", "<br/>")
114108

115109
// Dumb heuristic to skip XML parsing for trivial cases, to improve performance.
116110
if (contains('<') && contains('>')) {
@@ -123,7 +117,9 @@ class EmailMessageSource(
123117
stack.add(m.group(2))
124118

125119
val ref = makeRef()
126-
val replacement = "<div th:ref=\"$ref\" th:replace=\"~{::intl-${stack.joinToString("--")}(~{:: $ref/content})}\"><content th:remove=\"tag\">"
120+
val replacement = "<div th:ref=\"$ref\" th:replace=\"~{::intl-${stack.joinToString(
121+
"--"
122+
)}(~{:: $ref/content})}\"><content th:remove=\"tag\">"
127123
str = str.replaceRange(delta + m.start(), delta + m.end(), replacement)
128124
delta += replacement.length - (m.end() - m.start())
129125
} else {

backend/data/src/main/kotlin/io/tolgee/email/EmailService.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@ package io.tolgee.email
1919
import io.tolgee.configuration.tolgee.SmtpProperties
2020
import io.tolgee.dtos.misc.EmailAttachment
2121
import org.springframework.beans.factory.annotation.Qualifier
22+
import org.springframework.context.ApplicationContext
2223
import org.springframework.mail.javamail.JavaMailSender
2324
import org.springframework.mail.javamail.MimeMessageHelper
2425
import org.springframework.scheduling.annotation.Async
2526
import org.springframework.stereotype.Service
2627
import org.thymeleaf.TemplateEngine
2728
import org.thymeleaf.context.Context
29+
import org.thymeleaf.spring6.expression.ThymeleafEvaluationContext
2830
import java.util.*
2931

3032
@Service
3133
class EmailService(
34+
private val applicationContext: ApplicationContext,
3235
private val smtpProperties: SmtpProperties,
3336
private val mailSender: JavaMailSender,
3437
private val emailGlobalVariablesProvider: EmailGlobalVariablesProvider,
@@ -50,15 +53,17 @@ class EmailService(
5053
properties: Map<String, Any> = mapOf(),
5154
attachments: List<EmailAttachment> = listOf(),
5255
) {
53-
val context = Context(locale, properties)
5456
val globalVariables = emailGlobalVariablesProvider()
57+
val context = Context(locale, properties)
5558
context.setVariables(globalVariables)
5659

60+
// Required because we're outside of Spring MVC here
61+
// Otherwise, bean resolution does not work for some reason
62+
val tec = ThymeleafEvaluationContext(applicationContext, null)
63+
context.setVariable(ThymeleafEvaluationContext.THYMELEAF_EVALUATION_CONTEXT_CONTEXT_VARIABLE_NAME, tec)
64+
5765
// Do two passes, so Thymeleaf expressions rendered by messages can get processed
58-
context.setVariable("isSecondPass", false)
5966
val firstPass = templateEngine.process(template, context)
60-
61-
context.setVariable("isSecondPass", true)
6267
val html = templateEngine.process(firstPass, context)
6368

6469
val subject = extractEmailTitle(html)

backend/data/src/main/kotlin/io/tolgee/email/EmailTemplateConfig.kt

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -31,36 +31,41 @@ import java.util.*
3131

3232
@Configuration
3333
class EmailTemplateConfig {
34-
@Bean("emailTemplateResolver")
35-
fun templateResolver(): ClassLoaderTemplateResolver {
36-
val templateResolver = ClassLoaderTemplateResolver()
37-
templateResolver.characterEncoding = "UTF-8"
38-
templateResolver.prefix = "/email-templates/"
39-
templateResolver.suffix = ".html"
40-
return templateResolver
41-
}
34+
@Bean("emailTemplateResolver")
35+
fun templateResolver(): ClassLoaderTemplateResolver {
36+
val templateResolver = ClassLoaderTemplateResolver()
37+
templateResolver.characterEncoding = "UTF-8"
38+
templateResolver.prefix = "/email-templates/"
39+
templateResolver.suffix = ".html"
40+
return templateResolver
41+
}
4242

43-
@Bean("emailMessageSource")
44-
fun messageSource(): ICUMessageSource {
45-
val messageSource = ICUReloadableResourceBundleMessageSource()
46-
messageSource.setBasenames("email-i18n/messages", "email-i18n-test/messages")
47-
messageSource.setDefaultEncoding("UTF-8")
48-
messageSource.setDefaultLocale(Locale.ENGLISH)
49-
return EmailMessageSource(messageSource)
50-
}
43+
@Bean("emailMessageSource")
44+
fun messageSource(emailTemplateUtils: EmailTemplateUtils): ICUMessageSource {
45+
val messageSource = ICUReloadableResourceBundleMessageSource()
46+
messageSource.setBasenames("email-i18n/messages", "email-i18n-test/messages")
47+
messageSource.setDefaultEncoding("UTF-8")
48+
messageSource.setDefaultLocale(Locale.ENGLISH)
49+
return EmailMessageSource(messageSource, emailTemplateUtils)
50+
}
5151

52-
@Bean("emailTemplateEngine")
53-
fun templateEngine(
54-
@Qualifier("emailTemplateResolver") templateResolver: ITemplateResolver,
55-
@Qualifier("emailMessageSource") messageSource: MessageSource,
56-
): TemplateEngine {
52+
@Bean("emailTemplateEngine")
53+
fun templateEngine(
54+
@Qualifier("emailTemplateResolver") templateResolver: ITemplateResolver,
55+
@Qualifier("emailMessageSource") messageSource: MessageSource,
56+
): TemplateEngine {
5757
val stringTemplateResolver = StringTemplateResolver()
5858
stringTemplateResolver.resolvablePatternSpec.addPattern("<!DOCTYPE*")
5959

60-
val templateEngine = SpringTemplateEngine()
61-
templateEngine.enableSpringELCompiler = true
62-
templateEngine.templateResolvers = setOf(stringTemplateResolver, templateResolver)
63-
templateEngine.setTemplateEngineMessageSource(messageSource)
64-
return templateEngine
65-
}
60+
val templateEngine = SpringTemplateEngine()
61+
templateEngine.enableSpringELCompiler = true
62+
templateEngine.templateResolvers = setOf(stringTemplateResolver, templateResolver)
63+
templateEngine.setTemplateEngineMessageSource(messageSource)
64+
return templateEngine
65+
}
66+
67+
@Bean("emailTemplateUtils")
68+
fun emailTemplateUtils(): EmailTemplateUtils {
69+
return EmailTemplateUtils()
70+
}
6671
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Copyright (C) 2025 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.tolgee.email
18+
19+
class EmailTemplateUtils {
20+
fun escape(str: String): String {
21+
// Prevent Thymeleaf injection (for the second pass)
22+
return str.replace("{", "&#123;")
23+
.replace("[", "&#91;")
24+
.replace("$", "&#36;")
25+
.replace("*", "&#42;")
26+
.replace("#", "&#35;")
27+
.replace("~", "&#126;")
28+
}
29+
}

backend/data/src/test/kotlin/io/tolgee/email/EmailServiceTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,11 +118,11 @@ class EmailServiceTest {
118118

119119
val email = emailCaptor.value
120120
email.assertContents()
121-
.contains("Plain test: <span>Name #1</span>")
121+
.contains("Plain test: <span>Name &#35;1</span>")
122122
.contains("<span>ICU test: Name &#35;1</span>")
123-
.contains("Plain test: <span>Name #2</span>")
123+
.contains("Plain test: <span>Name &#35;2</span>")
124124
.contains("<span>ICU test: Name &#35;2</span>")
125-
.contains("Plain test: <span>Name #3</span>")
125+
.contains("Plain test: <span>Name &#35;3</span>")
126126
.contains("<span>ICU test: Name &#35;3</span>")
127127
}
128128

email/components/Var.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,13 @@ type Props = {
2323
};
2424

2525
export default function Var({ variable, demoValue, injectHtml }: Props) {
26-
const attr = injectHtml ? 'th:utext' : 'th:text';
27-
28-
if (process.env.NODE_ENV === 'production') {
29-
// This is rendered using the same two-pass trick used in translate.ts
30-
// It's done to prevent this from being a potential vector of injection
31-
return React.createElement('th:block', {
32-
'th:utext': `'<span ${attr}="\${${variable}}"></span>'`,
33-
});
34-
}
35-
3626
return React.createElement(
3727
'span',
38-
{ [injectHtml ? 'th:utext' : 'th:text']: `\${${variable}}` },
28+
{
29+
'th:utext': injectHtml
30+
? `\${${variable}}`
31+
: `\${@emailTemplateUtils.escape(#strings.escapeXml(${variable}))}`,
32+
},
3933
demoValue
4034
);
4135
}

email/components/translate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ function processMessageElements(
122122
React.cloneElement(
123123
demoParams[node.value](
124124
React.createElement('th:block', {
125-
'th:utext': '\'<div th:replace="${_children}"></div>\'',
125+
'th:utext': '\'<div th:replace="${_children} ?: ~{}"></div>\'',
126126
})
127127
),
128128
{ key: templateId }

0 commit comments

Comments
 (0)