mirror of
https://github.com/NixOS/nixpkgs.git
synced 2026-10-02 21:10:08 +00:00
pretix: backport security update from 2025.9.1 (#465620)
This commit is contained in:
721
pkgs/by-name/pr/pretix/CVE-2025-13742.patch
Normal file
721
pkgs/by-name/pr/pretix/CVE-2025-13742.patch
Normal file
@@ -0,0 +1,721 @@
|
||||
From 0ef932feb62ddfcbc45a910bd8c62bc663dd1ee6 Mon Sep 17 00:00:00 2001
|
||||
From: Raphael Michel <michel@rami.io>
|
||||
Date: Mon, 24 Nov 2025 00:04:24 +0100
|
||||
Subject: [PATCH 1/2] [SECURITY] Prevent HTML injection through placeholders in
|
||||
emails
|
||||
|
||||
Co-authored-by: luelista <weller@pretix.eu>
|
||||
(cherry picked from commit fbfae9ed19fdf6c64c98b6803c9fe81bee7dab05)
|
||||
---
|
||||
src/pretix/base/email.py | 6 +-
|
||||
src/pretix/base/services/mail.py | 9 ++-
|
||||
src/pretix/base/services/placeholders.py | 70 +++++++++++-----
|
||||
src/pretix/base/templatetags/rich_text.py | 48 +++++++----
|
||||
src/pretix/control/forms/vouchers.py | 6 +-
|
||||
src/pretix/helpers/format.py | 29 ++++---
|
||||
src/tests/base/test_mail.py | 97 ++++++++++++++++++++++-
|
||||
src/tests/helpers/test_format.py | 1 -
|
||||
src/tests/templates/mailtest.txt | 9 +++
|
||||
9 files changed, 219 insertions(+), 56 deletions(-)
|
||||
|
||||
diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py
|
||||
index b17f5b266..b887e11d4 100644
|
||||
--- a/src/pretix/base/email.py
|
||||
+++ b/src/pretix/base/email.py
|
||||
@@ -133,11 +133,11 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def template_name(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
- def compile_markdown(self, plaintext):
|
||||
- return markdown_compile_email(plaintext)
|
||||
+ def compile_markdown(self, plaintext, context=None):
|
||||
+ return markdown_compile_email(plaintext, context=context)
|
||||
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
||||
- body_md = self.compile_markdown(plain_body)
|
||||
+ body_md = self.compile_markdown(plain_body, context)
|
||||
if context:
|
||||
body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
|
||||
htmlctx = {
|
||||
diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py
|
||||
index 29f681bd4..b91d14e2f 100644
|
||||
--- a/src/pretix/base/services/mail.py
|
||||
+++ b/src/pretix/base/services/mail.py
|
||||
@@ -215,7 +215,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
'invoice_company': ''
|
||||
})
|
||||
renderer = ClassicMailRenderer(None, organizer)
|
||||
- content_plain = body_plain = render_mail(template, context)
|
||||
+ body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN)
|
||||
subject = str(subject).format_map(TolerantDict(context))
|
||||
sender = (
|
||||
sender or
|
||||
@@ -309,6 +309,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
|
||||
|
||||
with override(timezone):
|
||||
try:
|
||||
+ content_plain = render_mail(template, context, placeholder_mode=None)
|
||||
if plain_text_only:
|
||||
body_html = None
|
||||
elif 'context' in inspect.signature(renderer.render).parameters:
|
||||
@@ -659,11 +660,11 @@ def mail_send(*args, **kwargs):
|
||||
mail_send_task.apply_async(args=args, kwargs=kwargs)
|
||||
|
||||
|
||||
-def render_mail(template, context):
|
||||
+def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN):
|
||||
if isinstance(template, LazyI18nString):
|
||||
body = str(template)
|
||||
- if context:
|
||||
- body = format_map(body, context, mode=SafeFormatter.MODE_IGNORE_RICH)
|
||||
+ if context and placeholder_mode:
|
||||
+ body = format_map(body, context, mode=placeholder_mode)
|
||||
else:
|
||||
tpl = get_template(template)
|
||||
body = tpl.render(context)
|
||||
diff --git a/src/pretix/base/services/placeholders.py b/src/pretix/base/services/placeholders.py
|
||||
index 53fc2918f..feee9edd5 100644
|
||||
--- a/src/pretix/base/services/placeholders.py
|
||||
+++ b/src/pretix/base/services/placeholders.py
|
||||
@@ -26,7 +26,7 @@ from decimal import Decimal
|
||||
|
||||
from django.dispatch import receiver
|
||||
from django.utils.formats import date_format
|
||||
-from django.utils.html import escape
|
||||
+from django.utils.html import escape, mark_safe
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -123,6 +123,10 @@ class BaseRichTextPlaceholder(BaseTextPlaceholder):
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
+ @property
|
||||
+ def allowed_in_plain_content(self):
|
||||
+ return False
|
||||
+
|
||||
@property
|
||||
def required_context(self):
|
||||
return self._args
|
||||
@@ -194,6 +198,33 @@ class SimpleButtonPlaceholder(BaseRichTextPlaceholder):
|
||||
return f'{text}: {url}'
|
||||
|
||||
|
||||
+class MarkdownTextPlaceholder(BaseRichTextPlaceholder):
|
||||
+ def __init__(self, identifier, args, func, sample, inline):
|
||||
+ super().__init__(identifier, args)
|
||||
+ self._func = func
|
||||
+ self._sample = sample
|
||||
+ self._snippet = inline
|
||||
+
|
||||
+ @property
|
||||
+ def allowed_in_plain_content(self):
|
||||
+ return self._snippet
|
||||
+
|
||||
+ def render_plain(self, **context):
|
||||
+ return self._func(**{k: context[k] for k in self._args})
|
||||
+
|
||||
+ def render_html(self, **context):
|
||||
+ return mark_safe(markdown_compile_email(self.render_plain(**context), snippet=self._snippet))
|
||||
+
|
||||
+ def render_sample_plain(self, event):
|
||||
+ if callable(self._sample):
|
||||
+ return self._sample(event)
|
||||
+ else:
|
||||
+ return self._sample
|
||||
+
|
||||
+ def render_sample_html(self, event):
|
||||
+ return mark_safe(markdown_compile_email(self.render_sample_plain(event), snippet=self._snippet))
|
||||
+
|
||||
+
|
||||
class PlaceholderContext(SafeFormatter):
|
||||
"""
|
||||
Holds the contextual arguments and corresponding list of available placeholders for formatting
|
||||
@@ -574,7 +605,7 @@ def base_placeholders(sender, **kwargs):
|
||||
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
|
||||
_('Sample Corporation')
|
||||
),
|
||||
- SimpleFunctionalTextPlaceholder(
|
||||
+ MarkdownTextPlaceholder(
|
||||
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
|
||||
'* {} - {}'.format(
|
||||
order.full_code,
|
||||
@@ -604,6 +635,7 @@ def base_placeholders(sender, **kwargs):
|
||||
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd', 'hash': 'stuvwxy2z'}
|
||||
]
|
||||
),
|
||||
+ inline=False,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
|
||||
@@ -618,12 +650,13 @@ def base_placeholders(sender, **kwargs):
|
||||
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
|
||||
'68CYU2H6ZTP3WLK5'
|
||||
),
|
||||
- SimpleFunctionalTextPlaceholder(
|
||||
+ MarkdownTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_list', ['voucher_list'], lambda voucher_list: ' \n'.join(voucher_list),
|
||||
- ' 68CYU2H6ZTP3WLK5\n 7MB94KKPVEPSMVF2'
|
||||
+ '68CYU2H6ZTP3WLK5 \n7MB94KKPVEPSMVF2',
|
||||
+ inline=False,
|
||||
),
|
||||
- SimpleFunctionalTextPlaceholder(
|
||||
+ MarkdownTextPlaceholder(
|
||||
# join vouchers with two spaces at end of line so markdown-parser inserts a <br>
|
||||
'voucher_url_list', ['event', 'voucher_list'],
|
||||
lambda event, voucher_list: ' \n'.join([
|
||||
@@ -638,6 +671,7 @@ def base_placeholders(sender, **kwargs):
|
||||
) + '?voucher=' + c
|
||||
for c in ['68CYU2H6ZTP3WLK5', '7MB94KKPVEPSMVF2']
|
||||
]),
|
||||
+ inline=False,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'url', ['event', 'voucher_list'], lambda event, voucher_list: build_absolute_uri(event, 'presale:event.index', kwargs={
|
||||
@@ -656,13 +690,13 @@ def base_placeholders(sender, **kwargs):
|
||||
'comment', ['comment'], lambda comment: comment,
|
||||
_('An individual text with a reason can be inserted here.'),
|
||||
),
|
||||
- SimpleFunctionalTextPlaceholder(
|
||||
+ MarkdownTextPlaceholder(
|
||||
'payment_info', ['order', 'payments'], _placeholder_payments,
|
||||
- _('The amount has been charged to your card.'),
|
||||
+ _('The amount has been charged to your card.'), inline=False,
|
||||
),
|
||||
- SimpleFunctionalTextPlaceholder(
|
||||
+ MarkdownTextPlaceholder(
|
||||
'payment_info', ['payment_info'], lambda payment_info: payment_info,
|
||||
- _('Please transfer money to this bank account: 9999-9999-9999-9999'),
|
||||
+ _('Please transfer money to this bank account: 9999-9999-9999-9999'), inline=False,
|
||||
),
|
||||
SimpleFunctionalTextPlaceholder(
|
||||
'attendee_name', ['position'], lambda position: position.attendee_name,
|
||||
@@ -719,13 +753,13 @@ def base_placeholders(sender, **kwargs):
|
||||
))
|
||||
|
||||
for k, v in sender.meta_data.items():
|
||||
- ph.append(SimpleFunctionalTextPlaceholder(
|
||||
+ ph.append(MarkdownTextPlaceholder(
|
||||
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
|
||||
- v
|
||||
+ v, inline=True,
|
||||
))
|
||||
- ph.append(SimpleFunctionalTextPlaceholder(
|
||||
+ ph.append(MarkdownTextPlaceholder(
|
||||
'meta_%s' % k, ['event_or_subevent'], lambda event_or_subevent, k=k: event_or_subevent.meta_data[k],
|
||||
- v
|
||||
+ v, inline=True,
|
||||
))
|
||||
|
||||
return ph
|
||||
@@ -753,7 +787,7 @@ def get_available_placeholders(event, base_parameters, rich=False):
|
||||
if not isinstance(val, (list, tuple)):
|
||||
val = [val]
|
||||
for v in val:
|
||||
- if isinstance(v, BaseRichTextPlaceholder) and not rich:
|
||||
+ if isinstance(v, BaseRichTextPlaceholder) and not rich and not v.allowed_in_plain_content:
|
||||
continue
|
||||
if all(rp in base_parameters for rp in v.required_context):
|
||||
params[v.identifier] = v
|
||||
@@ -775,13 +809,13 @@ def get_sample_context(event, context_parameters, rich=True):
|
||||
)
|
||||
)
|
||||
elif str(sample).strip().startswith('* ') or str(sample).startswith(' '):
|
||||
- context_dict[k] = '<div class="placeholder" title="{}">{}</div>'.format(
|
||||
+ context_dict[k] = mark_safe('<div class="placeholder" title="{}">{}</div>'.format(
|
||||
lbl,
|
||||
markdown_compile_email(str(sample))
|
||||
- )
|
||||
+ ))
|
||||
else:
|
||||
- context_dict[k] = '<span class="placeholder" title="{}">{}</span>'.format(
|
||||
+ context_dict[k] = mark_safe('<span class="placeholder" title="{}">{}</span>'.format(
|
||||
lbl,
|
||||
escape(sample)
|
||||
- )
|
||||
+ ))
|
||||
return context_dict
|
||||
diff --git a/src/pretix/base/templatetags/rich_text.py b/src/pretix/base/templatetags/rich_text.py
|
||||
index d77d9ce9c..29f1e651b 100644
|
||||
--- a/src/pretix/base/templatetags/rich_text.py
|
||||
+++ b/src/pretix/base/templatetags/rich_text.py
|
||||
@@ -44,6 +44,7 @@ from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
+from django.utils.html import escape
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.safestring import mark_safe
|
||||
from markdown import Extension
|
||||
@@ -52,6 +53,8 @@ from markdown.postprocessors import Postprocessor
|
||||
from markdown.treeprocessors import UnescapeTreeprocessor
|
||||
from tlds import tld_set
|
||||
|
||||
+from pretix.helpers.format import SafeFormatter, format_map
|
||||
+
|
||||
register = template.Library()
|
||||
|
||||
ALLOWED_TAGS_SNIPPET = {
|
||||
@@ -294,27 +297,44 @@ class LinkifyAndCleanExtension(Extension):
|
||||
)
|
||||
|
||||
|
||||
-def markdown_compile_email(source, allowed_tags=ALLOWED_TAGS, allowed_attributes=ALLOWED_ATTRIBUTES):
|
||||
+def markdown_compile_email(source, allowed_tags=None, allowed_attributes=ALLOWED_ATTRIBUTES, snippet=False, context=None):
|
||||
+ if allowed_tags is None:
|
||||
+ allowed_tags = ALLOWED_TAGS_SNIPPET if snippet else ALLOWED_TAGS
|
||||
+
|
||||
+ context_callbacks = []
|
||||
+ if context:
|
||||
+ # This is a workaround to fix placeholders in URL targets
|
||||
+ def context_callback(attrs, new=False):
|
||||
+ if (None, "href") in attrs and "{" in attrs[None, "href"]:
|
||||
+ # Do not use MODE_RICH_TO_HTML to avoid recursive linkification
|
||||
+ attrs[None, "href"] = escape(format_map(attrs[None, "href"], context=context, mode=SafeFormatter.MODE_RICH_TO_PLAIN))
|
||||
+ return attrs
|
||||
+
|
||||
+ context_callbacks.append(context_callback)
|
||||
+
|
||||
linker = bleach.Linker(
|
||||
url_re=URL_RE,
|
||||
email_re=EMAIL_RE,
|
||||
- callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||
+ callbacks=context_callbacks + DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||
parse_email=True
|
||||
)
|
||||
+ exts = [
|
||||
+ 'markdown.extensions.sane_lists',
|
||||
+ 'markdown.extensions.tables',
|
||||
+ EmailNl2BrExtension(),
|
||||
+ LinkifyAndCleanExtension(
|
||||
+ linker,
|
||||
+ tags=set(allowed_tags),
|
||||
+ attributes=allowed_attributes,
|
||||
+ protocols=ALLOWED_PROTOCOLS,
|
||||
+ strip=snippet,
|
||||
+ )
|
||||
+ ]
|
||||
+ if snippet:
|
||||
+ exts.append(SnippetExtension())
|
||||
return markdown.markdown(
|
||||
source,
|
||||
- extensions=[
|
||||
- 'markdown.extensions.sane_lists',
|
||||
- 'markdown.extensions.tables',
|
||||
- EmailNl2BrExtension(),
|
||||
- LinkifyAndCleanExtension(
|
||||
- linker,
|
||||
- tags=set(allowed_tags),
|
||||
- attributes=allowed_attributes,
|
||||
- protocols=ALLOWED_PROTOCOLS,
|
||||
- strip=False,
|
||||
- )
|
||||
- ]
|
||||
+ extensions=exts
|
||||
)
|
||||
|
||||
|
||||
diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py
|
||||
index 3b7498594..ff0ee02a4 100644
|
||||
--- a/src/pretix/control/forms/vouchers.py
|
||||
+++ b/src/pretix/control/forms/vouchers.py
|
||||
@@ -299,8 +299,8 @@ class VoucherBulkForm(VoucherForm):
|
||||
)
|
||||
Recipient = namedtuple('Recipient', 'email number name tag')
|
||||
|
||||
- def _set_field_placeholders(self, fn, base_parameters):
|
||||
- placeholders = get_available_placeholders(self.instance.event, base_parameters)
|
||||
+ def _set_field_placeholders(self, fn, base_parameters, rich=False):
|
||||
+ placeholders = get_available_placeholders(self.instance.event, base_parameters, rich=rich)
|
||||
ht = format_placeholders_help_text(placeholders, self.instance.event)
|
||||
|
||||
if self.fields[fn].help_text:
|
||||
@@ -336,7 +336,7 @@ class VoucherBulkForm(VoucherForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._set_field_placeholders('send_subject', ['event', 'name'])
|
||||
- self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'])
|
||||
+ self._set_field_placeholders('send_message', ['event', 'voucher_list', 'name'], rich=True)
|
||||
|
||||
with language(self.instance.event.settings.locale, self.instance.event.settings.region):
|
||||
for f in ("send_subject", "send_message"):
|
||||
diff --git a/src/pretix/helpers/format.py b/src/pretix/helpers/format.py
|
||||
index 3ed2eac36..b03e8f9cf 100644
|
||||
--- a/src/pretix/helpers/format.py
|
||||
+++ b/src/pretix/helpers/format.py
|
||||
@@ -22,6 +22,8 @@
|
||||
import logging
|
||||
from string import Formatter
|
||||
|
||||
+from django.utils.html import conditional_escape
|
||||
+
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -40,11 +42,10 @@ class SafeFormatter(Formatter):
|
||||
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
|
||||
(b) does not allow any unwanted shenanigans like attribute access or format specifiers.
|
||||
"""
|
||||
- MODE_IGNORE_RICH = 0
|
||||
MODE_RICH_TO_PLAIN = 1
|
||||
MODE_RICH_TO_HTML = 2
|
||||
|
||||
- def __init__(self, context, raise_on_missing=False, mode=MODE_IGNORE_RICH):
|
||||
+ def __init__(self, context, raise_on_missing=False, mode=MODE_RICH_TO_PLAIN):
|
||||
self.context = context
|
||||
self.raise_on_missing = raise_on_missing
|
||||
self.mode = mode
|
||||
@@ -55,22 +56,26 @@ class SafeFormatter(Formatter):
|
||||
def get_value(self, key, args, kwargs):
|
||||
if not self.raise_on_missing and key not in self.context:
|
||||
return '{' + str(key) + '}'
|
||||
- r = self.context[key]
|
||||
- if isinstance(r, PlainHtmlAlternativeString):
|
||||
- if self.mode == self.MODE_IGNORE_RICH:
|
||||
- return '{' + str(key) + '}'
|
||||
- elif self.mode == self.MODE_RICH_TO_PLAIN:
|
||||
- return r.plain
|
||||
+ return self.context[key]
|
||||
+
|
||||
+ def _prepare_value(self, value):
|
||||
+ if isinstance(value, PlainHtmlAlternativeString):
|
||||
+ if self.mode == self.MODE_RICH_TO_PLAIN:
|
||||
+ return value.plain
|
||||
elif self.mode == self.MODE_RICH_TO_HTML:
|
||||
- return r.html
|
||||
- return r
|
||||
+ return value.html
|
||||
+ else:
|
||||
+ value = str(value)
|
||||
+ if self.mode == self.MODE_RICH_TO_HTML:
|
||||
+ value = conditional_escape(value)
|
||||
+ return value
|
||||
|
||||
def format_field(self, value, format_spec):
|
||||
# Ignore format_spec
|
||||
- return super().format_field(value, '')
|
||||
+ return super().format_field(self._prepare_value(value), '')
|
||||
|
||||
|
||||
-def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_IGNORE_RICH):
|
||||
+def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN):
|
||||
if not isinstance(template, str):
|
||||
template = str(template)
|
||||
return SafeFormatter(context, raise_on_missing, mode=mode).format(template)
|
||||
diff --git a/src/tests/base/test_mail.py b/src/tests/base/test_mail.py
|
||||
index a8e4c338a..cfd284f44 100644
|
||||
--- a/src/tests/base/test_mail.py
|
||||
+++ b/src/tests/base/test_mail.py
|
||||
@@ -33,6 +33,8 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
import os
|
||||
+import re
|
||||
+from email.mime.text import MIMEText
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
@@ -40,7 +42,9 @@ from django.core import mail as djmail
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django_scopes import scope
|
||||
+from i18nfield.strings import LazyI18nString
|
||||
|
||||
+from pretix.base.email import get_email_context
|
||||
from pretix.base.models import Event, Organizer, User
|
||||
from pretix.base.services.mail import mail
|
||||
|
||||
@@ -48,10 +52,14 @@ from pretix.base.services.mail import mail
|
||||
@pytest.fixture
|
||||
def env():
|
||||
o = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||
+ prop1 = o.meta_properties.get_or_create(name="Test")[0]
|
||||
+ prop2 = o.meta_properties.get_or_create(name="Website")[0]
|
||||
event = Event.objects.create(
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now()
|
||||
)
|
||||
+ event.meta_values.update_or_create(property=prop1, defaults={'value': "*Beep*"})
|
||||
+ event.meta_values.update_or_create(property=prop2, defaults={'value': "https://example.com"})
|
||||
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
|
||||
user.email = 'dummy@dummy.dummy'
|
||||
user.save()
|
||||
@@ -158,8 +166,95 @@ def test_send_mail_with_user_locale(env):
|
||||
def test_sendmail_placeholder(env):
|
||||
djmail.outbox = []
|
||||
event, user, organizer = env
|
||||
- mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', {"event": event}, event)
|
||||
+ mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', {"event": event.name}, event)
|
||||
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == [user.email]
|
||||
assert djmail.outbox[0].subject == 'Dummy Test subject'
|
||||
+
|
||||
+
|
||||
+def _extract_html(mail):
|
||||
+ for content, mimetype in mail.alternatives:
|
||||
+ if "multipart/related" in mimetype:
|
||||
+ for sp in content._payload:
|
||||
+ if isinstance(sp, MIMEText):
|
||||
+ return sp._payload
|
||||
+ break
|
||||
+ elif "text/html" in mimetype:
|
||||
+ return content
|
||||
+
|
||||
+
|
||||
+@pytest.mark.django_db
|
||||
+def test_placeholder_html_rendering_from_template(env):
|
||||
+ djmail.outbox = []
|
||||
+ event, user, organizer = env
|
||||
+ event.name = "<strong>event & co. kg</strong>"
|
||||
+ event.save()
|
||||
+ mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', get_email_context(
|
||||
+ event=event,
|
||||
+ payment_info="**IBAN**: 123 \n**BIC**: 456",
|
||||
+ ), event)
|
||||
+
|
||||
+ assert len(djmail.outbox) == 1
|
||||
+ assert djmail.outbox[0].to == [user.email]
|
||||
+ assert 'Event name: <strong>event & co. kg</strong>' 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 'Event website: [<strong>event & co. kg</strong>](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 '<' not in djmail.outbox[0].body
|
||||
+ assert '&' not in djmail.outbox[0].body
|
||||
+ html = _extract_html(djmail.outbox[0])
|
||||
+
|
||||
+ assert '<strong>event' not in html
|
||||
+ assert 'Event name: <strong>event & co. kg</strong>' 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 re.search(
|
||||
+ r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
||||
+ html
|
||||
+ )
|
||||
+ assert re.search(
|
||||
+ r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
||||
+ html
|
||||
+ )
|
||||
+
|
||||
+
|
||||
+@pytest.mark.django_db
|
||||
+def test_placeholder_html_rendering_from_string(env):
|
||||
+ template = LazyI18nString({
|
||||
+ "en": "Event name: {event}\n\nPayment info:\n{payment_info}\n\n**Meta**: {meta_Test}\n\n"
|
||||
+ "Event website: [{event}](https://example.org/{event_slug})\n\n"
|
||||
+ "Other website: [{event}]({meta_Website})"
|
||||
+ })
|
||||
+ djmail.outbox = []
|
||||
+ event, user, organizer = env
|
||||
+ event.name = "<strong>event & co. kg</strong>"
|
||||
+ event.save()
|
||||
+ mail('dummy@dummy.dummy', '{event} Test subject', template, get_email_context(
|
||||
+ event=event,
|
||||
+ payment_info="**IBAN**: 123 \n**BIC**: 456",
|
||||
+ ), event)
|
||||
+
|
||||
+ assert len(djmail.outbox) == 1
|
||||
+ assert djmail.outbox[0].to == [user.email]
|
||||
+ assert 'Event name: <strong>event & co. kg</strong>' in djmail.outbox[0].body
|
||||
+ assert 'Event website: [<strong>event & co. kg</strong>](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 '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body
|
||||
+ assert '**Meta**: *Beep*' in djmail.outbox[0].body
|
||||
+ assert '<' not in djmail.outbox[0].body
|
||||
+ assert '&' not in djmail.outbox[0].body
|
||||
+ html = _extract_html(djmail.outbox[0])
|
||||
+ assert '<strong>event' not in html
|
||||
+ assert 'Event name: <strong>event & co. kg</strong>' 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 re.search(
|
||||
+ r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
||||
+ html
|
||||
+ )
|
||||
+ assert re.search(
|
||||
+ r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
||||
+ html
|
||||
+ )
|
||||
diff --git a/src/tests/helpers/test_format.py b/src/tests/helpers/test_format.py
|
||||
index 6165fe622..df062215b 100644
|
||||
--- a/src/tests/helpers/test_format.py
|
||||
+++ b/src/tests/helpers/test_format.py
|
||||
@@ -40,6 +40,5 @@ def test_format_alternatives():
|
||||
)
|
||||
}
|
||||
|
||||
- assert format_map("Foo {bar}", ctx, mode=SafeFormatter.MODE_IGNORE_RICH) == "Foo {bar}"
|
||||
assert format_map("Foo {bar}", ctx, mode=SafeFormatter.MODE_RICH_TO_PLAIN) == "Foo plain text"
|
||||
assert format_map("Foo {bar}", ctx, mode=SafeFormatter.MODE_RICH_TO_HTML) == "Foo <span>HTML version</span>"
|
||||
diff --git a/src/tests/templates/mailtest.txt b/src/tests/templates/mailtest.txt
|
||||
index 71139e00c..1bc1487d3 100644
|
||||
--- a/src/tests/templates/mailtest.txt
|
||||
+++ b/src/tests/templates/mailtest.txt
|
||||
@@ -1,4 +1,13 @@
|
||||
{% load i18n %}
|
||||
This is a test file for sending mails.
|
||||
+Event name: {event}
|
||||
{% get_current_language as LANGUAGE_CODE %}
|
||||
The language code used for rendering this email is {{ LANGUAGE_CODE }}.
|
||||
+
|
||||
+Payment info:
|
||||
+{payment_info}
|
||||
+
|
||||
+**Meta**: {meta_Test}
|
||||
+
|
||||
+Event website: [{event}](https://example.org/{event_slug})
|
||||
+Other website: [{event}]({meta_Website})
|
||||
\ No newline at end of file
|
||||
--
|
||||
2.51.0
|
||||
|
||||
From f982f4b9d7fb2fae94227713c9addadd8b70cf20 Mon Sep 17 00:00:00 2001
|
||||
From: Raphael Michel <michel@pretix.eu>
|
||||
Date: Thu, 27 Nov 2025 13:20:13 +0100
|
||||
Subject: [PATCH 2/2] Hotfix linkified placeholders (#5663)
|
||||
|
||||
* Fix linkify placeholders
|
||||
|
||||
* Add URL test
|
||||
|
||||
(cherry picked from commit ed5b160a05c6e2128e6b83ebbbfea9974bfc6179)
|
||||
---
|
||||
src/pretix/base/email.py | 19 +++++++++++++++++--
|
||||
src/pretix/helpers/format.py | 9 ++++++---
|
||||
src/tests/base/test_mail.py | 20 +++++++++++++++++---
|
||||
3 files changed, 40 insertions(+), 8 deletions(-)
|
||||
|
||||
diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py
|
||||
index b887e11d4..15a07c1e9 100644
|
||||
--- a/src/pretix/base/email.py
|
||||
+++ b/src/pretix/base/email.py
|
||||
@@ -24,6 +24,7 @@ from itertools import groupby
|
||||
from smtplib import SMTPResponseException
|
||||
from typing import TypeVar
|
||||
|
||||
+import bleach
|
||||
import css_inline
|
||||
from django.conf import settings
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
@@ -34,7 +35,10 @@ from django.utils.translation import get_language, gettext_lazy as _
|
||||
|
||||
from pretix.base.models import Event
|
||||
from pretix.base.signals import register_html_mail_renderers
|
||||
-from pretix.base.templatetags.rich_text import markdown_compile_email
|
||||
+from pretix.base.templatetags.rich_text import (
|
||||
+ DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
|
||||
+ markdown_compile_email, truelink_callback,
|
||||
+)
|
||||
from pretix.helpers.format import SafeFormatter, format_map
|
||||
|
||||
from pretix.base.services.placeholders import ( # noqa
|
||||
@@ -139,7 +143,18 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
|
||||
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
|
||||
body_md = self.compile_markdown(plain_body, context)
|
||||
if context:
|
||||
- body_md = format_map(body_md, context=context, mode=SafeFormatter.MODE_RICH_TO_HTML)
|
||||
+ linker = bleach.Linker(
|
||||
+ url_re=URL_RE,
|
||||
+ email_re=EMAIL_RE,
|
||||
+ callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
|
||||
+ parse_email=True
|
||||
+ )
|
||||
+ body_md = format_map(
|
||||
+ body_md,
|
||||
+ context=context,
|
||||
+ mode=SafeFormatter.MODE_RICH_TO_HTML,
|
||||
+ linkifier=linker
|
||||
+ )
|
||||
htmlctx = {
|
||||
'site': settings.PRETIX_INSTANCE_NAME,
|
||||
'site_url': settings.SITE_URL,
|
||||
diff --git a/src/pretix/helpers/format.py b/src/pretix/helpers/format.py
|
||||
index b03e8f9cf..5131eec75 100644
|
||||
--- a/src/pretix/helpers/format.py
|
||||
+++ b/src/pretix/helpers/format.py
|
||||
@@ -45,10 +45,11 @@ class SafeFormatter(Formatter):
|
||||
MODE_RICH_TO_PLAIN = 1
|
||||
MODE_RICH_TO_HTML = 2
|
||||
|
||||
- def __init__(self, context, raise_on_missing=False, mode=MODE_RICH_TO_PLAIN):
|
||||
+ def __init__(self, context, raise_on_missing=False, mode=MODE_RICH_TO_PLAIN, linkifier=None):
|
||||
self.context = context
|
||||
self.raise_on_missing = raise_on_missing
|
||||
self.mode = mode
|
||||
+ self.linkifier = linkifier
|
||||
|
||||
def get_field(self, field_name, args, kwargs):
|
||||
return self.get_value(field_name, args, kwargs), field_name
|
||||
@@ -68,6 +69,8 @@ class SafeFormatter(Formatter):
|
||||
value = str(value)
|
||||
if self.mode == self.MODE_RICH_TO_HTML:
|
||||
value = conditional_escape(value)
|
||||
+ if self.linkifier:
|
||||
+ value = self.linkifier.linkify(value)
|
||||
return value
|
||||
|
||||
def format_field(self, value, format_spec):
|
||||
@@ -75,7 +78,7 @@ class SafeFormatter(Formatter):
|
||||
return super().format_field(self._prepare_value(value), '')
|
||||
|
||||
|
||||
-def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN):
|
||||
+def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None):
|
||||
if not isinstance(template, str):
|
||||
template = str(template)
|
||||
- return SafeFormatter(context, raise_on_missing, mode=mode).format(template)
|
||||
+ return SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
|
||||
diff --git a/src/tests/base/test_mail.py b/src/tests/base/test_mail.py
|
||||
index cfd284f44..ca8923696 100644
|
||||
--- a/src/tests/base/test_mail.py
|
||||
+++ b/src/tests/base/test_mail.py
|
||||
@@ -225,16 +225,20 @@ def test_placeholder_html_rendering_from_string(env):
|
||||
template = LazyI18nString({
|
||||
"en": "Event name: {event}\n\nPayment info:\n{payment_info}\n\n**Meta**: {meta_Test}\n\n"
|
||||
"Event website: [{event}](https://example.org/{event_slug})\n\n"
|
||||
- "Other website: [{event}]({meta_Website})"
|
||||
+ "Other website: [{event}]({meta_Website})\n\n"
|
||||
+ "URL: {url}\n\n"
|
||||
+ "URL with text: <a href=\"{url}\">Test</a>"
|
||||
})
|
||||
djmail.outbox = []
|
||||
event, user, organizer = env
|
||||
event.name = "<strong>event & co. kg</strong>"
|
||||
event.save()
|
||||
- mail('dummy@dummy.dummy', '{event} Test subject', template, get_email_context(
|
||||
+ ctx = get_email_context(
|
||||
event=event,
|
||||
payment_info="**IBAN**: 123 \n**BIC**: 456",
|
||||
- ), event)
|
||||
+ )
|
||||
+ ctx["url"] = "https://google.com"
|
||||
+ mail('dummy@dummy.dummy', '{event} Test subject', template, ctx, event)
|
||||
|
||||
assert len(djmail.outbox) == 1
|
||||
assert djmail.outbox[0].to == [user.email]
|
||||
@@ -243,6 +247,8 @@ def test_placeholder_html_rendering_from_string(env):
|
||||
assert 'Other website: [<strong>event & co. kg</strong>](https://example.com)' 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 'URL: https://google.com' in djmail.outbox[0].body
|
||||
+ assert 'URL with text: <a href="https://google.com">Test</a>' in djmail.outbox[0].body
|
||||
assert '<' not in djmail.outbox[0].body
|
||||
assert '&' not in djmail.outbox[0].body
|
||||
html = _extract_html(djmail.outbox[0])
|
||||
@@ -258,3 +264,11 @@ def test_placeholder_html_rendering_from_string(env):
|
||||
r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank"><strong>event & co. kg</strong></a>',
|
||||
html
|
||||
)
|
||||
+ assert re.search(
|
||||
+ r'URL: <a href="https://google.com" rel="noopener" style="[^"]+" target="_blank">https://google.com</a>',
|
||||
+ html
|
||||
+ )
|
||||
+ assert re.search(
|
||||
+ r'URL with text: <a href="https://google.com" rel="noopener" style="[^"]+" target="_blank">Test</a>',
|
||||
+ html
|
||||
+ )
|
||||
--
|
||||
2.51.0
|
||||
|
||||
@@ -78,6 +78,9 @@ python.pkgs.buildPythonApplication rec {
|
||||
# Discover pretix.plugin entrypoints during build and add them into
|
||||
# INSTALLED_APPS, so that their static files are collected.
|
||||
./plugin-build.patch
|
||||
|
||||
# https://pretix.eu/about/en/blog/20251127-release-2025-9-2/
|
||||
./CVE-2025-13742.patch
|
||||
];
|
||||
|
||||
pythonRelaxDeps = [
|
||||
|
||||
Reference in New Issue
Block a user