Fix placeholder injection with django templates

This commit is contained in:
Raphael Michel
2026-02-13 13:24:53 +01:00
parent 43c6c33bd8
commit ad752dc617
4 changed files with 51 additions and 35 deletions

View File

@@ -39,7 +39,7 @@ from pretix.base.templatetags.rich_text import (
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback, DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
markdown_compile_email, truelink_callback, markdown_compile_email, truelink_callback,
) )
from pretix.helpers.format import SafeFormatter, format_map from pretix.helpers.format import FormattedString, SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext get_available_placeholders, PlaceholderContext
@@ -141,6 +141,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
return markdown_compile_email(plaintext, context=context) return markdown_compile_email(plaintext, context=context)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
apply_format_map = not isinstance(plain_body, FormattedString)
body_md = self.compile_markdown(plain_body, context) body_md = self.compile_markdown(plain_body, context)
if context: if context:
linker = bleach.Linker( linker = bleach.Linker(
@@ -149,12 +150,13 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback], callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
parse_email=True parse_email=True
) )
body_md = format_map( if apply_format_map:
body_md, body_md = format_map(
context=context, body_md,
mode=SafeFormatter.MODE_RICH_TO_HTML, context=context,
linkifier=linker mode=SafeFormatter.MODE_RICH_TO_HTML,
) linkifier=linker
)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL, 'site_url': settings.SITE_URL,

View File

@@ -81,7 +81,9 @@ from pretix.base.signals import (
) )
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.format import FormattedString, SafeFormatter, format_map from pretix.helpers.format import (
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
)
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals from pretix.presale.ical import get_private_icals
@@ -266,7 +268,10 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
signature = "" signature = ""
# Build full plain-text body # Build full plain-text body
body_plain = format_map(content_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN) if not isinstance(content_plain, FormattedString):
body_plain = format_map(content_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
else:
body_plain = content_plain
body_plain = _wrap_plain_body(body_plain, signature, event, order, position, no_order_links) body_plain = _wrap_plain_body(body_plain, signature, event, order, position, no_order_links)
# Build subject # Build subject
@@ -793,7 +798,12 @@ def render_mail(template, context, placeholder_mode: Optional[int]=SafeFormatter
body = format_map(body, context, mode=placeholder_mode) body = format_map(body, context, mode=placeholder_mode)
else: else:
tpl = get_template(template) tpl = get_template(template)
body = tpl.render(context) context = {
# Known bug, should behave differently for plain and HTML but we'll fix after security release
k: v.html if isinstance(v, PlainHtmlAlternativeString) else v
for k, v in context.items()
}
body = FormattedString(tpl.render(context))
return body return body

View File

@@ -42,6 +42,7 @@ import pytest
from django.conf import settings from django.conf import settings
from django.core import mail as djmail from django.core import mail as djmail
from django.test import override_settings from django.test import override_settings
from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
@@ -322,7 +323,7 @@ def _extract_html(mail):
def test_placeholder_html_rendering_from_template(env): def test_placeholder_html_rendering_from_template(env):
djmail.outbox = [] djmail.outbox = []
event, user, organizer = env event, user, organizer = env
event.name = "<strong>event & co. kg</strong>" event.name = "<strong>event & co. kg</strong> {currency}"
event.save() event.save()
mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', get_email_context( mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', get_email_context(
event=event, event=event,
@@ -331,25 +332,26 @@ def test_placeholder_html_rendering_from_template(env):
assert len(djmail.outbox) == 1 assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == [user.email] assert djmail.outbox[0].to == [user.email]
assert 'Event name: <strong>event & co. kg</strong>' in djmail.outbox[0].body # Known bug for now: These should not have HTML for the plain body, but we'll fix this safter the security release
assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body assert escape('Event name: <strong>event & co. kg</strong> {currency}') in djmail.outbox[0].body
assert '**Meta**: *Beep*' in djmail.outbox[0].body assert '<strong>IBAN</strong>: 123<br>\n<strong>BIC</strong>: 456' in djmail.outbox[0].body
assert 'Event website: [<strong>event & co. kg</strong>](https://example.org/dummy)' in djmail.outbox[0].body assert '**Meta**: <em>Beep</em>' in djmail.outbox[0].body
assert 'Other website: [<strong>event & co. kg</strong>](https://example.com)' in djmail.outbox[0].body assert escape('Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)') in djmail.outbox[0].body
assert '&lt;' not in djmail.outbox[0].body # todo: assert '&lt;' not in djmail.outbox[0].body
assert '&amp;' not in djmail.outbox[0].body # todo: assert '&amp;' not in djmail.outbox[0].body
assert 'Unevaluated placeholder: {currency}' in djmail.outbox[0].body
assert 'EUR' not in djmail.outbox[0].body
html = _extract_html(djmail.outbox[0]) html = _extract_html(djmail.outbox[0])
assert '<strong>event' not in html assert '<strong>event' not in html
assert 'Event name: &lt;strong&gt;event &amp; co. kg&lt;/strong&gt;' in html assert 'Event name: &lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}' in html
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
assert '<strong>Meta</strong>: <em>Beep</em>' in html assert '<strong>Meta</strong>: <em>Beep</em>' in html
assert 'Unevaluated placeholder: {currency}' in html
assert 'EUR' not in html
assert re.search( assert re.search(
r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">&lt;strong&gt;event &amp; co. kg&lt;/strong&gt;</a>', r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">'
html r'&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}</a>',
)
assert re.search(
r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank">&lt;strong&gt;event &amp; co. kg&lt;/strong&gt;</a>',
html html
) )
@@ -367,7 +369,7 @@ def test_placeholder_html_rendering_from_string(env):
}) })
djmail.outbox = [] djmail.outbox = []
event, user, organizer = env event, user, organizer = env
event.name = "<strong>event & co. kg</strong>" event.name = "<strong>event & co. kg</strong> {currency}"
event.save() event.save()
ctx = get_email_context( ctx = get_email_context(
event=event, event=event,
@@ -378,9 +380,9 @@ def test_placeholder_html_rendering_from_string(env):
assert len(djmail.outbox) == 1 assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == [user.email] assert djmail.outbox[0].to == [user.email]
assert 'Event name: <strong>event & co. kg</strong>' in djmail.outbox[0].body assert 'Event name: <strong>event & co. kg</strong> {currency}' in djmail.outbox[0].body
assert 'Event website: [<strong>event & co. kg</strong>](https://example.org/dummy)' in djmail.outbox[0].body assert 'Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)' in djmail.outbox[0].body
assert 'Other website: [<strong>event & co. kg</strong>](https://example.com)' in djmail.outbox[0].body assert 'Other website: [<strong>event & co. kg</strong> {currency}](https://example.com)' in djmail.outbox[0].body
assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body
assert '**Meta**: *Beep*' in djmail.outbox[0].body assert '**Meta**: *Beep*' in djmail.outbox[0].body
assert 'URL: https://google.com' in djmail.outbox[0].body assert 'URL: https://google.com' in djmail.outbox[0].body
@@ -395,11 +397,13 @@ def test_placeholder_html_rendering_from_string(env):
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
assert '<strong>Meta</strong>: <em>Beep</em>' in html assert '<strong>Meta</strong>: <em>Beep</em>' in html
assert re.search( assert re.search(
r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">&lt;strong&gt;event &amp; co. kg&lt;/strong&gt;</a>', r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">'
r'&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}</a>',
html html
) )
assert re.search( assert re.search(
r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank">&lt;strong&gt;event &amp; co. kg&lt;/strong&gt;</a>', r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank">'
r'&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}</a>',
html html
) )
assert re.search( assert re.search(

View File

@@ -1,13 +1,13 @@
{% load i18n %} {% load i18n %}
This is a test file for sending mails. This is a test file for sending mails.
Event name: {event} Event name: {{ event }}
Unevaluated placeholder: {currency}
{% get_current_language as LANGUAGE_CODE %} {% get_current_language as LANGUAGE_CODE %}
The language code used for rendering this email is {{ LANGUAGE_CODE }}. The language code used for rendering this email is {{ LANGUAGE_CODE }}.
Payment info: Payment info:
{payment_info} {{ payment_info }}
**Meta**: {meta_Test} **Meta**: {{ meta_Test }}
Event website: [{event}](https://example.org/{event_slug}) Event website: [{{event}}](https://example.org/{{event_slug}})
Other website: [{event}]({meta_Website})