diff --git a/pkgs/by-name/pr/pretix/CVE-2025-13742.patch b/pkgs/by-name/pr/pretix/CVE-2025-13742.patch new file mode 100644 index 000000000000..fa31c5be1564 --- /dev/null +++ b/pkgs/by-name/pr/pretix/CVE-2025-13742.patch @@ -0,0 +1,721 @@ +From 0ef932feb62ddfcbc45a910bd8c62bc663dd1ee6 Mon Sep 17 00:00:00 2001 +From: Raphael Michel +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 +(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
+ '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
+ '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] = '
{}
'.format( ++ context_dict[k] = mark_safe('
{}
'.format( + lbl, + markdown_compile_email(str(sample)) +- ) ++ )) + else: +- context_dict[k] = '{}'.format( ++ context_dict[k] = mark_safe('{}'.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 = "event & co. kg" ++ 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: event & co. kg' 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: [event & co. kg](https://example.org/dummy)' in djmail.outbox[0].body ++ assert 'Other website: [event & co. kg](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 'event' not in html ++ assert 'Event name: <strong>event & co. kg</strong>' in html ++ assert 'IBAN: 123
\nBIC: 456' in html ++ assert 'Meta: Beep' in html ++ assert re.search( ++ r'Event website: <strong>event & co. kg</strong>', ++ html ++ ) ++ assert re.search( ++ r'Other website: <strong>event & co. kg</strong>', ++ 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 = "event & co. kg" ++ 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: event & co. kg' in djmail.outbox[0].body ++ assert 'Event website: [event & co. kg](https://example.org/dummy)' in djmail.outbox[0].body ++ assert 'Other website: [event & co. kg](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 'event' not in html ++ assert 'Event name: <strong>event & co. kg</strong>' in html ++ assert 'IBAN: 123
\nBIC: 456' in html ++ assert 'Meta: Beep' in html ++ assert re.search( ++ r'Event website: <strong>event & co. kg</strong>', ++ html ++ ) ++ assert re.search( ++ r'Other website: <strong>event & co. kg</strong>', ++ 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 HTML version" +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 +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: Test" + }) + djmail.outbox = [] + event, user, organizer = env + event.name = "event & co. kg" + 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: [event & co. kg](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: Test' 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: <strong>event & co. kg</strong>', + html + ) ++ assert re.search( ++ r'URL: https://google.com', ++ html ++ ) ++ assert re.search( ++ r'URL with text: Test', ++ html ++ ) +-- +2.51.0 + diff --git a/pkgs/by-name/pr/pretix/package.nix b/pkgs/by-name/pr/pretix/package.nix index 9010cafc9526..73a3baba9f96 100644 --- a/pkgs/by-name/pr/pretix/package.nix +++ b/pkgs/by-name/pr/pretix/package.nix @@ -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 = [