Compare commits

...

11 Commits

Author SHA1 Message Date
Raphael Michel
3f14e01ddd Bump to 2025.9.4 2026-02-16 11:03:29 +01:00
Raphael Michel
e0c7dedc12 Fix placeholder injection with django templates 2026-02-13 13:35:49 +01:00
Raphael Michel
004e8e9895 SafeFormatter: Ignore conversion spec 2026-02-13 12:57:27 +01:00
Raphael Michel
a6d76a3453 Mark strings as formatted to prevent double-formatting 2026-02-13 12:57:27 +01:00
Kara Engelhardt
f1625dd8b1 SECURITY: Prevent placeholder injcetion in plaintext emails 2026-02-13 12:43:40 +01:00
Raphael Michel
72ddc08290 Bump version to 2025.9.3 2025-12-19 13:06:00 +01:00
Raphael Michel
14473b64ba [SECURITY] Prevent access to arbitrary cached files by UUID (CVE-2025-14881) 2025-12-19 13:05:46 +01:00
Raphael Michel
177f9e3c9d Bump to 2025.9.2 2025-11-27 13:22:11 +01:00
Raphael Michel
ed5b160a05 Hotfix linkified placeholders (#5663)
* Fix linkify placeholders

* Add URL test
2025-11-27 13:21:35 +01:00
Raphael Michel
b04afda883 Bump to 2025.9.1 2025-11-27 11:54:00 +01:00
Raphael Michel
fbfae9ed19 [SECURITY] Prevent HTML injection through placeholders in emails
Co-authored-by: luelista <weller@pretix.eu>
2025-11-27 11:53:54 +01:00
17 changed files with 545 additions and 82 deletions

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2025.9.0"
__version__ = "2025.9.4"

View File

@@ -74,6 +74,11 @@ class ExportersMixin:
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
def download(self, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs['cfid'])
if not cf.allowed_for_session(self.request, "exporters-api"):
return Response(
{'status': 'failed', 'message': 'Unknown file ID or export failed'},
status=status.HTTP_410_GONE
)
if cf.file:
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore")
@@ -109,7 +114,8 @@ class ExportersMixin:
serializer = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
serializer.is_valid(raise_exception=True)
cf = CachedFile(web_download=False)
cf = CachedFile(web_download=True)
cf.bind_to_session(self.request, "exporters-api")
cf.date = now()
cf.expires = now() + timedelta(hours=24)
cf.save()

View File

@@ -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,8 +35,11 @@ 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.helpers.format import SafeFormatter, format_map
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 FormattedString, SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext
@@ -133,13 +137,26 @@ 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)
apply_format_map = not isinstance(plain_body, FormattedString)
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
)
if apply_format_map:
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,

View File

@@ -58,6 +58,37 @@ class CachedFile(models.Model):
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins
session_key = models.TextField(null=True, blank=True) # only allow download in this session
def session_key_for_request(self, request, salt=None):
from ...api.models import OAuthAccessToken, OAuthApplication
from .devices import Device
from .organizer import TeamAPIToken
if hasattr(request, "auth") and isinstance(request.auth, OAuthAccessToken):
k = f'app:{request.auth.application.pk}'
elif hasattr(request, "auth") and isinstance(request.auth, OAuthApplication):
k = f'app:{request.auth.pk}'
elif hasattr(request, "auth") and isinstance(request.auth, TeamAPIToken):
k = f'token:{request.auth.pk}'
elif hasattr(request, "auth") and isinstance(request.auth, Device):
k = f'device:{request.auth.pk}'
elif request.session.session_key:
k = request.session.session_key
else:
raise ValueError("No auth method found to bind to")
if salt:
k = f"{k}!{salt}"
return k
def allowed_for_session(self, request, salt=None):
return (
not self.session_key or
self.session_key_for_request(request, salt) == self.session_key
)
def bind_to_session(self, request, salt=None):
self.session_key = self.session_key_for_request(request, salt)
@receiver(post_delete, sender=CachedFile)
def cached_file_delete(sender, instance, **kwargs):

View File

@@ -87,7 +87,7 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map
from ...helpers.format import FormattedString, format_map
from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var
from ._transactions import (
@@ -1176,7 +1176,8 @@ class Order(LockModel, LoggedModel):
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
@@ -2860,7 +2861,8 @@ class OrderPosition(AbstractPosition):
recipient = self.attendee_email
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,

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.signals import email_filter, global_email_filter
from pretix.celery_app import app
from pretix.helpers.format import SafeFormatter, format_map
from pretix.helpers.format import (
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
)
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals
@@ -200,6 +202,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
if email == INVALID_ADDRESS:
return
if isinstance(template, FormattedString):
raise TypeError("Cannot pass an already formatted body template")
if no_order_links and not plain_text_only:
raise ValueError('If you set no_order_links, you also need to set plain_text_only.')
@@ -222,8 +227,9 @@ 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)
subject = str(subject).format_map(TolerantDict(context))
content_plain = render_mail(template, context, placeholder_mode=None)
if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
sender = (
sender or
(event.settings.get('mail_from') if event else None) or
@@ -255,6 +261,10 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
else:
timezone = ZoneInfo(settings.TIME_ZONE)
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.settings.mail_bcc:
for bcc_mail in settings_holder.settings.mail_bcc.split(','):
@@ -270,7 +280,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
signature = str(settings_holder.settings.get('mail_text_signature'))
if signature:
signature = signature.format(event=event.name if event else '')
signature = format_map(signature, {"event": event.name if event else ''})
body_plain += signature
body_plain += "\r\n\r\n-- \r\n"
@@ -288,7 +298,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
body_plain += _(
"You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri(
orderurl=build_absolute_uri(
order.event, 'presale:event.order.position', kwargs={
'order': order.code,
'secret': position.web_secret,
@@ -304,7 +314,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
body_plain += _(
"You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri(
orderurl=build_absolute_uri(
order.event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
@@ -336,8 +346,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
logger.exception('Could not render HTML body')
body_html = None
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email),
cc=cc,
@@ -751,14 +759,19 @@ 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)
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

View File

@@ -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

View File

@@ -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()
@@ -321,27 +324,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
)

View File

@@ -36,9 +36,8 @@ class DownloadView(TemplateView):
def object(self) -> CachedFile:
try:
o = get_object_or_404(CachedFile, id=self.kwargs['id'], web_download=True)
if o.session_key:
if o.session_key != self.request.session.session_key:
raise Http404()
if not o.allowed_for_session(self.request):
raise Http404()
return o
except (ValueError, ValidationError): # Invalid URLs
raise Http404()

View File

@@ -308,8 +308,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:
@@ -345,7 +345,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"):

View File

@@ -38,6 +38,7 @@ from datetime import timedelta
from django.conf import settings
from django.contrib import messages
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
@@ -85,6 +86,7 @@ class BaseImportView(TemplateView):
filename='import.csv',
type='text/csv',
)
cf.bind_to_session(request, "modelimport")
cf.file.save('import.csv', request.FILES['file'])
if self.request.POST.get("charset") in ENCODINGS:
@@ -137,7 +139,10 @@ class BaseProcessView(AsyncAction, FormView):
@cached_property
def file(self):
return get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
cf = get_object_or_404(CachedFile, pk=self.kwargs.get("file"), filename="import.csv")
if not cf.allowed_for_session(self.request, "modelimport"):
raise Http404()
return cf
@cached_property
def parsed(self):

View File

@@ -242,7 +242,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
cf = None
if request.POST.get("background", "").strip():
try:
cf = CachedFile.objects.get(id=request.POST.get("background"))
cf = CachedFile.objects.get(id=request.POST.get("background"), web_download=True)
except CachedFile.DoesNotExist:
pass

View File

@@ -38,7 +38,8 @@ from collections import OrderedDict
from zipfile import ZipFile
from django.contrib import messages
from django.shortcuts import get_object_or_404, redirect
from django.http import Http404
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import get_language, gettext_lazy as _
@@ -94,6 +95,8 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
cf = CachedFile.objects.get(pk=kwargs['file'])
except CachedFile.DoesNotExist:
raise ShredError(_("The download file could no longer be found on the server, please try to start again."))
if not cf.allowed_for_session(self.request):
raise Http404()
with ZipFile(cf.file.file, 'r') as zipfile:
indexdata = json.loads(zipfile.read('index.json').decode())
@@ -111,7 +114,7 @@ class ShredDownloadView(RecentAuthenticationRequiredMixin, EventPermissionRequir
ctx = super().get_context_data(**kwargs)
ctx['shredders'] = self.shredders
ctx['download_on_shred'] = any(shredder.require_download_confirmation for shredder in shredders)
ctx['file'] = get_object_or_404(CachedFile, pk=kwargs.get("file"))
ctx['file'] = cf
return ctx

View File

@@ -22,6 +22,9 @@
import logging
from string import Formatter
from django.core.exceptions import SuspiciousOperation
from django.utils.html import conditional_escape
logger = logging.getLogger(__name__)
@@ -35,19 +38,30 @@ class PlainHtmlAlternativeString:
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
class FormattedString(str):
"""
A str subclass that has been specifically marked as "already formatted" for email rendering
purposes to avoid duplicate formatting.
"""
__slots__ = ()
def __str__(self):
return self
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, 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
@@ -55,22 +69,39 @@ 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)
if self.linkifier:
value = self.linkifier.linkify(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 convert_field(self, value, conversion):
# Ignore any conversions
if conversion is None:
return value
else:
return str(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, linkifier=None) -> FormattedString:
if isinstance(template, FormattedString):
raise SuspiciousOperation("Calling format_map() on an already formatted string is likely unsafe.")
if not isinstance(template, str):
template = str(template)
return SafeFormatter(context, raise_on_missing, mode=mode).format(template)
return FormattedString(
SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
)

View File

@@ -32,26 +32,37 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import datetime
import os
import re
from decimal import Decimal
from email.mime.text import MIMEText
import pytest
from django.conf import settings
from django.core import mail as djmail
from django.utils.html import escape
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_scopes import scope
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.models import Event, Organizer, User
from pretix.base.email import get_email_context
from pretix.base.models import Event, InvoiceAddress, Order, Organizer, User
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()
@@ -59,6 +70,45 @@ def env():
yield event, user, o
@pytest.fixture
@scopes_disabled()
def item(env):
return env[0].items.create(name="Budget Ticket", default_price=23)
@pytest.fixture
@scopes_disabled()
def order(env, item):
event, _, _ = env
o = Order.objects.create(
code="FOO",
event=event,
email="dummy@dummy.test",
status=Order.STATUS_PENDING,
secret="k24fiuwvu8kxz3y1",
sales_channel=event.organizer.sales_channels.get(identifier="web"),
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.UTC),
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.UTC),
total=23,
locale="en",
)
o.positions.create(
order=o,
item=item,
variation=None,
price=Decimal("23"),
attendee_email="peter@example.org",
attendee_name_parts={"given_name": "Peter", "family_name": "Miller"},
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
pseudonymization_id="ABCDEFGHKL",
)
InvoiceAddress.objects.create(
order=o,
name_parts={"given_name": "Peter", "family_name": "Miller"},
)
return o
@pytest.mark.django_db
def test_send_mail_with_prefix(env):
djmail.outbox = []
@@ -158,8 +208,250 @@ 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> {currency}"
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]
# Known bug for now: These should not have HTML for the plain body, but we'll fix this safter the security release
assert escape('Event name: <strong>event & co. kg</strong> {currency}') in djmail.outbox[0].body
assert '<strong>IBAN</strong>: 123<br>\n<strong>BIC</strong>: 456' in djmail.outbox[0].body
assert '**Meta**: <em>Beep</em>' in djmail.outbox[0].body
assert escape('Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)') in djmail.outbox[0].body
# todo: assert '&lt;' 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])
assert '<strong>event' not 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>Meta</strong>: <em>Beep</em>' in html
assert 'Unevaluated placeholder: {currency}' in html
assert 'EUR' not in html
assert re.search(
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
)
@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})\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> {currency}"
event.save()
ctx = get_email_context(
event=event,
payment_info="**IBAN**: 123 \n**BIC**: 456",
)
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]
assert 'Event name: <strong>event & co. kg</strong> {currency}' 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> {currency}](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 '&lt;' not in djmail.outbox[0].body
assert '&amp;' not in djmail.outbox[0].body
html = _extract_html(djmail.outbox[0])
assert '<strong>event' not in html
assert 'Event name: &lt;strong&gt;event &amp; co. kg&lt;/strong&gt;' 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">'
r'&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}</a>',
html
)
assert re.search(
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
)
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
)
@pytest.mark.django_db
def test_nested_placeholder_inclusion_full_process(env, order):
# Test that it is not possible to sneak in a placeholder like {url_cancel} inside a user-controlled
# placeholder value like {invoice_company}
event, user, organizer = env
position = order.positions.get()
order.invoice_address.company = "{url_cancel} Corp"
order.invoice_address.save()
event.settings.mail_text_resend_link = LazyI18nString({"en": "Ticket for {invoice_company}"})
event.settings.mail_subject_resend_link_attendee = LazyI18nString({"en": "Ticket for {invoice_company}"})
djmail.outbox = []
position.resend_link()
assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == [position.attendee_email]
assert "Ticket for {url_cancel} Corp" == djmail.outbox[0].subject
assert "/cancel" not in djmail.outbox[0].body
assert "/order" not in djmail.outbox[0].body
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
for part in (html, plain):
assert "Ticket for {url_cancel} Corp" in part
assert "/order/" not in part
assert "/cancel" not in part
@pytest.mark.django_db
def test_nested_placeholder_inclusion_mail_service(env):
# test that it is not possible to have placeholders within the values of placeholders when
# the mail() function is called directly
template = LazyI18nString("Event name: {event}")
djmail.outbox = []
event, user, organizer = env
event.name = "event & {currency} co. kg"
event.slug = "event-co-ag-slug"
event.save()
mail(
"dummy@dummy.dummy",
"{event} Test subject",
template,
get_email_context(
event=event,
payment_info="**IBAN**: 123 \n**BIC**: 456 {event}",
),
event,
)
assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == [user.email]
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
for part in (html, plain, djmail.outbox[0].subject):
assert "event & {currency} co. kg" in part or "event &amp; {currency} co. kg" in part
assert "EUR" not in part
@pytest.mark.django_db
@pytest.mark.parametrize("tpl", [
"Event: {event.__class__}",
"Event: {{event.__class__}}",
"Event: {{{event.__class__}}}",
])
def test_variable_inclusion_from_string_full_process(env, tpl, order):
# Test that it is not possible to use placeholders that leak system information in templates
# when run through system processes
event, user, organizer = env
event.name = "event & co. kg"
event.save()
position = order.positions.get()
event.settings.mail_text_resend_link = LazyI18nString({"en": tpl})
event.settings.mail_subject_resend_link_attendee = LazyI18nString({"en": tpl})
position.resend_link()
assert len(djmail.outbox) == 1
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
for part in (html, plain, djmail.outbox[0].subject):
assert "{event.__class__}" in part
assert "LazyI18nString" not in part
@pytest.mark.django_db
@pytest.mark.parametrize("tpl", [
"Event: {event.__class__}",
"Event: {{event.__class__}}",
"Event: {{{event.__class__}}}",
])
def test_variable_inclusion_from_string_mail_service(env, tpl):
# Test that it is not possible to use placeholders that leak system information in templates
# when run through mail() directly
event, user, organizer = env
event.name = "event & co. kg"
event.save()
djmail.outbox = []
mail(
"dummy@dummy.dummy",
tpl,
LazyI18nString(tpl),
get_email_context(
event=event,
payment_info="**IBAN**: 123 \n**BIC**: 456\n" + tpl,
),
event,
)
assert len(djmail.outbox) == 1
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
for part in (html, plain, djmail.outbox[0].subject):
assert "{event.__class__}" in part
assert "LazyI18nString" not in part
@pytest.mark.django_db
def test_escaped_braces_mail_services(env):
# Test that braces can be escaped by doubling
template = LazyI18nString("Event name: -{{currency}}-")
djmail.outbox = []
event, user, organizer = env
event.name = "event & co. kg"
event.save()
mail(
"dummy@dummy.dummy",
"-{{currency}}- Test subject",
template,
get_email_context(
event=event,
payment_info="**IBAN**: 123 \n**BIC**: 456 {event}",
),
event,
)
assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == [user.email]
html, plain = _extract_html(djmail.outbox[0]), djmail.outbox[0].body
for part in (html, plain, djmail.outbox[0].subject):
assert "EUR" not in part
assert "-{currency}-" in part

View File

@@ -29,6 +29,8 @@ def test_format_map():
assert format_map("Foo {baz}", {"bar": 3}) == "Foo {baz}"
assert format_map("Foo {bar.__module__}", {"bar": 3}) == "Foo {bar.__module__}"
assert format_map("Foo {bar!s}", {"bar": 3}) == "Foo 3"
assert format_map("Foo {bar!r}", {"bar": '3'}) == "Foo 3"
assert format_map("Foo {bar!a}", {"bar": '3'}) == "Foo 3"
assert format_map("Foo {bar:<20}", {"bar": 3}) == "Foo 3"
@@ -40,6 +42,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>"

View File

@@ -1,4 +1,13 @@
{% load i18n %}
This is a test file for sending mails.
Event name: {{ event }}
Unevaluated placeholder: {currency}
{% 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}})