Compare commits

...

7 Commits

Author SHA1 Message Date
Raphael Michel
ae8ba2528a Bump to 2025.10.2 2026-02-16 11:02:13 +01:00
Raphael Michel
2dbb7ebd2d Fix placeholder injection with django templates 2026-02-13 13:37:14 +01:00
Raphael Michel
5dac696cb8 SafeFormatter: Ignore conversion spec 2026-02-13 13:37:14 +01:00
Raphael Michel
474ca35616 Mark strings as formatted to prevent double-formatting 2026-02-13 13:37:14 +01:00
Kara Engelhardt
ff351f2856 SECURITY: Prevent placeholder injcetion in plaintext emails 2026-02-13 13:37:14 +01:00
Raphael Michel
5d87f9a26f Bump to 2025.10.1 2025-12-19 13:06:58 +01:00
Raphael Michel
4b5651862c [SECURITY] Prevent access to arbitrary cached files by UUID (CVE-2025-14881) 2025-12-19 13:06:48 +01:00
14 changed files with 326 additions and 58 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 # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2025.10.0" __version__ = "2025.10.2"

View File

@@ -74,6 +74,11 @@ class ExportersMixin:
@action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)') @action(detail=True, methods=['GET'], url_name='download', url_path='download/(?P<asyncid>[^/]+)/(?P<cfid>[^/]+)')
def download(self, *args, **kwargs): def download(self, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs['cfid']) 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: if cf.file:
resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type) resp = ChunkBasedFileResponse(cf.file.file, content_type=cf.type)
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename).encode("ascii", "ignore") 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 = JobRunSerializer(exporter=instance, data=self.request.data, **self.get_serializer_kwargs())
serializer.is_valid(raise_exception=True) 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.date = now()
cf.expires = now() + timedelta(hours=24) cf.expires = now() + timedelta(hours=24)
cf.save() cf.save()

View File

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

View File

@@ -58,6 +58,37 @@ class CachedFile(models.Model):
web_download = models.BooleanField(default=True) # allow web download, True for backwards compatibility in plugins 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 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) @receiver(post_delete, sender=CachedFile)
def cached_file_delete(sender, instance, **kwargs): 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 import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField 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 ...helpers.names import build_name
from ...testutils.middleware import debugflags_var from ...testutils.middleware import debugflags_var
from ._transactions import ( from ._transactions import (
@@ -1181,7 +1181,8 @@ class Order(LockModel, LoggedModel):
try: try:
email_content = render_mail(template, context) email_content = render_mail(template, context)
subject = format_map(subject, context) if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
mail( mail(
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender, self.event, self.locale, self, headers=headers, sender=sender,
@@ -2926,7 +2927,8 @@ class OrderPosition(AbstractPosition):
recipient = self.attendee_email recipient = self.attendee_email
try: try:
email_content = render_mail(template, context) email_content = render_mail(template, context)
subject = format_map(subject, context) if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
mail( mail(
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender, 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.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.format import SafeFormatter, format_map from pretix.helpers.format import (
FormattedString, PlainHtmlAlternativeString, SafeFormatter, format_map,
)
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals from pretix.presale.ical import get_private_icals
@@ -200,6 +202,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
if email == INVALID_ADDRESS: if email == INVALID_ADDRESS:
return return
if isinstance(template, FormattedString):
raise TypeError("Cannot pass an already formatted body template")
if no_order_links and not plain_text_only: 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.') 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': '' 'invoice_company': ''
}) })
renderer = ClassicMailRenderer(None, organizer) renderer = ClassicMailRenderer(None, organizer)
body_plain = render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_PLAIN) content_plain = render_mail(template, context, placeholder_mode=None)
subject = str(subject).format_map(TolerantDict(context)) if not isinstance(subject, FormattedString):
subject = format_map(subject, context)
sender = ( sender = (
sender or sender or
(event.settings.get('mail_from') if event else None) 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: else:
timezone = ZoneInfo(settings.TIME_ZONE) 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:
if settings_holder.settings.mail_bcc: if settings_holder.settings.mail_bcc:
for bcc_mail in settings_holder.settings.mail_bcc.split(','): for bcc_mail in settings_holder.settings.mail_bcc.split(','):
@@ -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')) signature = str(settings_holder.settings.get('mail_text_signature'))
if 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 += signature
body_plain += "\r\n\r\n-- \r\n" 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 += _( body_plain += _(
"You can view your order details at the following URL:\n{orderurl}." "You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format( ).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri( orderurl=build_absolute_uri(
order.event, 'presale:event.order.position', kwargs={ order.event, 'presale:event.order.position', kwargs={
'order': order.code, 'order': order.code,
'secret': position.web_secret, 'secret': position.web_secret,
@@ -304,7 +314,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
body_plain += _( body_plain += _(
"You can view your order details at the following URL:\n{orderurl}." "You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format( ).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri( orderurl=build_absolute_uri(
order.event, 'presale:event.order.open', kwargs={ order.event, 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret, 'secret': order.secret,
@@ -316,7 +326,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
with override(timezone): with override(timezone):
try: try:
content_plain = render_mail(template, context, placeholder_mode=None)
if plain_text_only: if plain_text_only:
body_html = None body_html = None
elif 'context' in inspect.signature(renderer.render).parameters: elif 'context' in inspect.signature(renderer.render).parameters:
@@ -337,8 +346,6 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
logger.exception('Could not render HTML body') logger.exception('Could not render HTML body')
body_html = None body_html = None
body_plain = format_map(body_plain, context, mode=SafeFormatter.MODE_RICH_TO_PLAIN)
send_task = mail_send_task.si( send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email), to=[email] if isinstance(email, str) else list(email),
cc=cc, cc=cc,
@@ -759,7 +766,12 @@ def render_mail(template, context, placeholder_mode=SafeFormatter.MODE_RICH_TO_P
body = format_map(body, context, mode=placeholder_mode) body = format_map(body, context, mode=placeholder_mode)
else: else:
tpl = get_template(template) tpl = get_template(template)
body = tpl.render(context) context = {
# Known bug, should behave differently for plain and HTML but we'll fix after security release
k: v.html if isinstance(v, PlainHtmlAlternativeString) else v
for k, v in context.items()
}
body = FormattedString(tpl.render(context))
return body return body

View File

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

View File

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

View File

@@ -247,7 +247,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
cf = None cf = None
if request.POST.get("background", "").strip(): if request.POST.get("background", "").strip():
try: 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: except CachedFile.DoesNotExist:
pass pass

View File

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

View File

@@ -22,6 +22,7 @@
import logging import logging
from string import Formatter from string import Formatter
from django.core.exceptions import SuspiciousOperation
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,6 +38,17 @@ class PlainHtmlAlternativeString:
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')" 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): class SafeFormatter(Formatter):
""" """
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
@@ -77,8 +89,19 @@ class SafeFormatter(Formatter):
# Ignore format_spec # Ignore format_spec
return super().format_field(self._prepare_value(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_RICH_TO_PLAIN, linkifier=None):
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): if not isinstance(template, str):
template = str(template) template = str(template)
return SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template) return FormattedString(
SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
)

View File

@@ -32,20 +32,23 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # License for the specific language governing permissions and limitations under the License.
import datetime
import os import os
import re import re
from decimal import Decimal
from email.mime.text import MIMEText from email.mime.text import MIMEText
import pytest import pytest
from django.conf import settings from django.conf import settings
from django.core import mail as djmail from django.core import mail as djmail
from django.utils.html import escape
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import scope from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.models import Event, Organizer, User from pretix.base.models import Event, InvoiceAddress, Order, Organizer, User
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
@@ -67,6 +70,45 @@ def env():
yield event, user, o 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 @pytest.mark.django_db
def test_send_mail_with_prefix(env): def test_send_mail_with_prefix(env):
djmail.outbox = [] djmail.outbox = []
@@ -188,7 +230,7 @@ def _extract_html(mail):
def test_placeholder_html_rendering_from_template(env): def test_placeholder_html_rendering_from_template(env):
djmail.outbox = [] djmail.outbox = []
event, user, organizer = env event, user, organizer = env
event.name = "<strong>event & co. kg</strong>" event.name = "<strong>event & co. kg</strong> {currency}"
event.save() event.save()
mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', get_email_context( mail('dummy@dummy.dummy', '{event} Test subject', 'mailtest.txt', get_email_context(
event=event, event=event,
@@ -197,25 +239,26 @@ def test_placeholder_html_rendering_from_template(env):
assert len(djmail.outbox) == 1 assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == [user.email] assert djmail.outbox[0].to == [user.email]
assert 'Event name: <strong>event & co. kg</strong>' in djmail.outbox[0].body # Known bug for now: These should not have HTML for the plain body, but we'll fix this safter the security release
assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body assert escape('Event name: <strong>event & co. kg</strong> {currency}') in djmail.outbox[0].body
assert '**Meta**: *Beep*' in djmail.outbox[0].body assert '<strong>IBAN</strong>: 123<br>\n<strong>BIC</strong>: 456' in djmail.outbox[0].body
assert 'Event website: [<strong>event & co. kg</strong>](https://example.org/dummy)' in djmail.outbox[0].body assert '**Meta**: <em>Beep</em>' in djmail.outbox[0].body
assert 'Other website: [<strong>event & co. kg</strong>](https://example.com)' in djmail.outbox[0].body assert escape('Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)') in djmail.outbox[0].body
assert '&lt;' not in djmail.outbox[0].body # todo: assert '&lt;' not in djmail.outbox[0].body
assert '&amp;' not in djmail.outbox[0].body # todo: assert '&amp;' not in djmail.outbox[0].body
assert 'Unevaluated placeholder: {currency}' in djmail.outbox[0].body
assert 'EUR' not in djmail.outbox[0].body
html = _extract_html(djmail.outbox[0]) html = _extract_html(djmail.outbox[0])
assert '<strong>event' not in html assert '<strong>event' not in html
assert 'Event name: &lt;strong&gt;event &amp; co. kg&lt;/strong&gt;' in html assert 'Event name: &lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}' in html
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
assert '<strong>Meta</strong>: <em>Beep</em>' in html assert '<strong>Meta</strong>: <em>Beep</em>' in html
assert 'Unevaluated placeholder: {currency}' in html
assert 'EUR' not in html
assert re.search( assert re.search(
r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">&lt;strong&gt;event &amp; co. kg&lt;/strong&gt;</a>', r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">'
html r'&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}</a>',
)
assert re.search(
r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank">&lt;strong&gt;event &amp; co. kg&lt;/strong&gt;</a>',
html html
) )
@@ -231,7 +274,7 @@ def test_placeholder_html_rendering_from_string(env):
}) })
djmail.outbox = [] djmail.outbox = []
event, user, organizer = env event, user, organizer = env
event.name = "<strong>event & co. kg</strong>" event.name = "<strong>event & co. kg</strong> {currency}"
event.save() event.save()
ctx = get_email_context( ctx = get_email_context(
event=event, event=event,
@@ -242,9 +285,9 @@ def test_placeholder_html_rendering_from_string(env):
assert len(djmail.outbox) == 1 assert len(djmail.outbox) == 1
assert djmail.outbox[0].to == [user.email] assert djmail.outbox[0].to == [user.email]
assert 'Event name: <strong>event & co. kg</strong>' in djmail.outbox[0].body assert 'Event name: <strong>event & co. kg</strong> {currency}' in djmail.outbox[0].body
assert 'Event website: [<strong>event & co. kg</strong>](https://example.org/dummy)' in djmail.outbox[0].body assert 'Event website: [<strong>event & co. kg</strong> {currency}](https://example.org/dummy)' in djmail.outbox[0].body
assert 'Other website: [<strong>event & co. kg</strong>](https://example.com)' in djmail.outbox[0].body assert 'Other website: [<strong>event & co. kg</strong> {currency}](https://example.com)' in djmail.outbox[0].body
assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body assert '**IBAN**: 123 \n**BIC**: 456' in djmail.outbox[0].body
assert '**Meta**: *Beep*' in djmail.outbox[0].body assert '**Meta**: *Beep*' in djmail.outbox[0].body
assert 'URL: https://google.com' in djmail.outbox[0].body assert 'URL: https://google.com' in djmail.outbox[0].body
@@ -257,11 +300,13 @@ def test_placeholder_html_rendering_from_string(env):
assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html assert '<strong>IBAN</strong>: 123<br/>\n<strong>BIC</strong>: 456' in html
assert '<strong>Meta</strong>: <em>Beep</em>' in html assert '<strong>Meta</strong>: <em>Beep</em>' in html
assert re.search( assert re.search(
r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">&lt;strong&gt;event &amp; co. kg&lt;/strong&gt;</a>', r'Event website: <a href="https://example.org/dummy" rel="noopener" style="[^"]+" target="_blank">'
r'&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}</a>',
html html
) )
assert re.search( assert re.search(
r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank">&lt;strong&gt;event &amp; co. kg&lt;/strong&gt;</a>', r'Other website: <a href="https://example.com" rel="noopener" style="[^"]+" target="_blank">'
r'&lt;strong&gt;event &amp; co. kg&lt;/strong&gt; {currency}</a>',
html html
) )
assert re.search( assert re.search(
@@ -272,3 +317,141 @@ def test_placeholder_html_rendering_from_string(env):
r'URL with text: <a href="https://google.com" rel="noopener" style="[^"]+" target="_blank">Test</a>', r'URL with text: <a href="https://google.com" rel="noopener" style="[^"]+" target="_blank">Test</a>',
html 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 {baz}", {"bar": 3}) == "Foo {baz}"
assert format_map("Foo {bar.__module__}", {"bar": 3}) == "Foo {bar.__module__}" 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!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" assert format_map("Foo {bar:<20}", {"bar": 3}) == "Foo 3"

View File

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