Fix placeholder injection with django templates

This commit is contained in:
Raphael Michel
2026-02-13 13:24:53 +01:00
parent 5dac696cb8
commit 2dbb7ebd2d
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

@@ -76,7 +76,9 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app from pretix.celery_app import app
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
@@ -259,7 +261,10 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
else: else:
timezone = ZoneInfo(settings.TIME_ZONE) timezone = ZoneInfo(settings.TIME_ZONE)
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
if settings_holder: if settings_holder:
if settings_holder.settings.mail_bcc: if settings_holder.settings.mail_bcc:
for bcc_mail in settings_holder.settings.mail_bcc.split(','): for bcc_mail in settings_holder.settings.mail_bcc.split(','):
@@ -761,7 +766,12 @@ def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_P
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

@@ -41,6 +41,7 @@ from email.mime.text import MIMEText
import pytest 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.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
@@ -229,7 +230,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,
@@ -238,25 +239,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
) )
@@ -272,7 +274,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,
@@ -283,9 +285,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
@@ -298,11 +300,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})