Compare commits

...

5 Commits

Author SHA1 Message Date
Raphael Michel
91498f17b4 Bump to 4.7.1 2022-02-28 16:07:47 +01:00
Raphael Michel
8920f2ec31 Pin django-countries to 7.2 2022-02-28 16:07:13 +01:00
Raphael Michel
80eb6826b3 [SECURITY] Fix stored XSS in help texts 2022-02-28 16:07:13 +01:00
Raphael Michel
5922403d40 [SECURITY] Fix stored XSS in question errors 2022-02-28 16:07:13 +01:00
Raphael Michel
a8d6aec22a [SECURITY] Prevent untrusted values from creating Excel formulas 2022-02-28 16:07:13 +01:00
9 changed files with 171 additions and 30 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__ = "4.7.0" __version__ = "4.7.1"

View File

@@ -33,7 +33,6 @@
# 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 io import io
import re
import tempfile import tempfile
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from decimal import Decimal from decimal import Decimal
@@ -46,26 +45,13 @@ from django.conf import settings
from django.db.models import QuerySet from django.db.models import QuerySet
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.translation import gettext, gettext_lazy as _ from django.utils.translation import gettext, gettext_lazy as _
from openpyxl import Workbook
from openpyxl.cell.cell import ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, Cell
from pretix.base.models import Event from pretix.base.models import Event
from pretix.helpers.safe_openpyxl import ( # NOQA: backwards compatibility for plugins using excel_safe
SafeWorkbook, remove_invalid_excel_chars as excel_safe,
)
__ = excel_safe # just so the compatbility import above is "used" and doesn't get removed by linter
def excel_safe(val):
if isinstance(val, Cell):
return val
if not isinstance(val, KNOWN_TYPES):
val = str(val)
if isinstance(val, bytes):
val = val.decode("utf-8", errors="ignore")
if isinstance(val, str):
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
return val
class BaseExporter: class BaseExporter:
@@ -228,7 +214,7 @@ class ListExporter(BaseExporter):
pass pass
def _render_xlsx(self, form_data, output_file=None): def _render_xlsx(self, form_data, output_file=None):
wb = Workbook(write_only=True) wb = SafeWorkbook(write_only=True)
ws = wb.create_sheet() ws = wb.create_sheet()
self.prepare_xlsx_sheet(ws) self.prepare_xlsx_sheet(ws)
try: try:
@@ -242,7 +228,7 @@ class ListExporter(BaseExporter):
total = line.total total = line.total
continue continue
ws.append([ ws.append([
excel_safe(val) for val in line val for val in line
]) ])
if total: if total:
counter += 1 counter += 1
@@ -347,7 +333,7 @@ class MultiSheetListExporter(ListExporter):
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8") return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data, output_file=None): def _render_xlsx(self, form_data, output_file=None):
wb = Workbook(write_only=True) wb = SafeWorkbook(write_only=True)
n_sheets = len(self.sheets) n_sheets = len(self.sheets)
for i_sheet, (s, l) in enumerate(self.sheets): for i_sheet, (s, l) in enumerate(self.sheets):
ws = wb.create_sheet(str(l)) ws = wb.create_sheet(str(l))
@@ -361,8 +347,7 @@ class MultiSheetListExporter(ListExporter):
total = line.total total = line.total
continue continue
ws.append([ ws.append([
excel_safe(val) val for val in line
for val in line
]) ])
if total: if total:
counter += 1 counter += 1

View File

@@ -705,7 +705,7 @@ class BaseQuestionsForm(forms.Form):
label=label, required=required, label=label, required=required,
min_value=q.valid_number_min or Decimal('0.00'), min_value=q.valid_number_min or Decimal('0.00'),
max_value=q.valid_number_max, max_value=q.valid_number_max,
help_text=q.help_text, help_text=help_text,
initial=initial.answer if initial else None, initial=initial.answer if initial else None,
) )
elif q.type == Question.TYPE_STRING: elif q.type == Question.TYPE_STRING:

View File

@@ -0,0 +1,113 @@
import re
from inspect import isgenerator
from openpyxl import Workbook
from openpyxl.cell.cell import (
ILLEGAL_CHARACTERS_RE, KNOWN_TYPES, TIME_TYPES, TYPE_FORMULA, TYPE_STRING,
Cell,
)
from openpyxl.compat import NUMERIC_TYPES
from openpyxl.utils import column_index_from_string
from openpyxl.utils.exceptions import ReadOnlyWorkbookException
from openpyxl.worksheet._write_only import WriteOnlyWorksheet
from openpyxl.worksheet.worksheet import Worksheet
SAFE_TYPES = NUMERIC_TYPES + TIME_TYPES + (bool, type(None))
"""
This module provides a safer version of openpyxl's `Workbook` class to generate XLSX files from
user-generated data using `WriteOnlyWorksheet` and `ws.append()`. We commonly use these methods
to output e.g. order data, which contains data from untrusted sources such as attendee names.
There are mainly two problems this solves:
- It makes sure strings starting with = are treated as text, not as a formula, as openpyxl will
otherwise assume, which can be used for remote code execution.
- It removes characters considered invalid by Excel to avoid exporter crashes.
"""
def remove_invalid_excel_chars(val):
if isinstance(val, Cell):
return val
if not isinstance(val, KNOWN_TYPES):
val = str(val)
if isinstance(val, bytes):
val = val.decode("utf-8", errors="ignore")
if isinstance(val, str):
val = re.sub(ILLEGAL_CHARACTERS_RE, '', val)
return val
def SafeCell(*args, value=None, **kwargs):
value = remove_invalid_excel_chars(value)
c = Cell(*args, value=value, **kwargs)
if c.data_type == TYPE_FORMULA:
c.data_type = TYPE_STRING
return c
class SafeAppendMixin:
def append(self, iterable):
row_idx = self._current_row + 1
if isinstance(iterable, (list, tuple, range)) or isgenerator(iterable):
for col_idx, content in enumerate(iterable, 1):
if isinstance(content, Cell):
# compatible with write-only mode
cell = content
if cell.parent and cell.parent != self:
raise ValueError("Cells cannot be copied from other worksheets")
cell.parent = self
cell.column = col_idx
cell.row = row_idx
else:
cell = SafeCell(self, row=row_idx, column=col_idx, value=remove_invalid_excel_chars(content))
self._cells[(row_idx, col_idx)] = cell
elif isinstance(iterable, dict):
for col_idx, content in iterable.items():
if isinstance(col_idx, str):
col_idx = column_index_from_string(col_idx)
cell = SafeCell(self, row=row_idx, column=col_idx, value=content)
self._cells[(row_idx, col_idx)] = cell
else:
self._invalid_row(iterable)
self._current_row = row_idx
class SafeWriteOnlyWorksheet(SafeAppendMixin, WriteOnlyWorksheet):
pass
class SafeWorksheet(SafeAppendMixin, Worksheet):
pass
class SafeWorkbook(Workbook):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self._sheets:
# monkeypatch existing sheets
for s in self._sheets:
s.append = SafeAppendMixin.append
def create_sheet(self, title=None, index=None):
if self.read_only:
raise ReadOnlyWorkbookException('Cannot create new sheet in a read-only workbook')
if self.write_only:
new_ws = SafeWriteOnlyWorksheet(parent=self, title=title)
else:
new_ws = SafeWorksheet(parent=self, title=title)
self._add_sheet(sheet=new_ws, index=index)
return new_ws

View File

@@ -47,6 +47,7 @@ from pretix.base.forms.questions import (
BaseInvoiceAddressForm, BaseQuestionsForm, WrappedPhoneNumberPrefixWidget, BaseInvoiceAddressForm, BaseQuestionsForm, WrappedPhoneNumberPrefixWidget,
guess_phone_prefix, guess_phone_prefix,
) )
from pretix.base.templatetags.rich_text import rich_text
from pretix.base.validators import EmailBanlistValidator from pretix.base.validators import EmailBanlistValidator
from pretix.presale.signals import contact_form_fields from pretix.presale.signals import contact_form_fields
@@ -82,7 +83,7 @@ class ContactForm(forms.Form):
self.fields['phone'] = PhoneNumberField( self.fields['phone'] = PhoneNumberField(
label=_('Phone number'), label=_('Phone number'),
required=self.event.settings.order_phone_required, required=self.event.settings.order_phone_required,
help_text=self.event.settings.checkout_phone_helptext, help_text=rich_text(self.event.settings.checkout_phone_helptext),
widget=WrappedPhoneNumberPrefixWidget() widget=WrappedPhoneNumberPrefixWidget()
) )
@@ -91,7 +92,7 @@ class ContactForm(forms.Form):
# is an autofocus field. Who would have thought… See e.g. here: # is an autofocus field. Who would have thought… See e.g. here:
# https://floatboxjs.com/forum/topic.php?post=8440&usebb_sid=2e116486a9ec6b7070e045aea8cded5b#post8440 # https://floatboxjs.com/forum/topic.php?post=8440&usebb_sid=2e116486a9ec6b7070e045aea8cded5b#post8440
self.fields['email'].widget.attrs['autofocus'] = 'autofocus' self.fields['email'].widget.attrs['autofocus'] = 'autofocus'
self.fields['email'].help_text = self.event.settings.checkout_email_helptext self.fields['email'].help_text = rich_text(self.event.settings.checkout_email_helptext)
responses = contact_form_fields.send(self.event, request=self.request) responses = contact_form_fields.send(self.event, request=self.request)
for r, response in responses: for r, response in responses:

View File

@@ -28,6 +28,7 @@ from pretix.base.forms.questions import (
NamePartsFormField, WrappedPhoneNumberPrefixWidget, guess_phone_prefix, NamePartsFormField, WrappedPhoneNumberPrefixWidget, guess_phone_prefix,
) )
from pretix.base.models import Quota, WaitingListEntry from pretix.base.models import Quota, WaitingListEntry
from pretix.base.templatetags.rich_text import rich_text
from pretix.presale.views.event import get_grouped_items from pretix.presale.views.event import get_grouped_items
@@ -99,7 +100,7 @@ class WaitingListForm(forms.ModelForm):
self.fields['phone'] = PhoneNumberField( self.fields['phone'] = PhoneNumberField(
label=_("Phone number"), label=_("Phone number"),
required=event.settings.waiting_list_phones_required, required=event.settings.waiting_list_phones_required,
help_text=event.settings.waiting_list_phones_explanation_text, help_text=rich_text(event.settings.waiting_list_phones_explanation_text),
widget=WrappedPhoneNumberPrefixWidget() widget=WrappedPhoneNumberPrefixWidget()
) )
else: else:

View File

@@ -219,7 +219,10 @@ $(function () {
// multi-input fields have a role=group with aria-labelledby // multi-input fields have a role=group with aria-labelledby
var label = this.hasAttribute("aria-labelledby") ? $("#" + this.getAttribute("aria-labelledby")) : $("[for="+target.attr("id")+"]"); var label = this.hasAttribute("aria-labelledby") ? $("#" + this.getAttribute("aria-labelledby")) : $("[for="+target.attr("id")+"]");
content.append("<li><a href='#" + target.attr("id") + "'>" + label.get(0).childNodes[0].nodeValue + "</a>: "+desc.text()+"</li>"); var $li = $("<li>");
$li.text(": " + desc.text())
$li.prepend($("<a>").attr("href", "#" + target.attr("id")).text(label.get(0).childNodes[0].nodeValue))
content.append($li);
}); });
$(this).append(content); $(this).append(content);
}); });

View File

@@ -172,7 +172,7 @@ setup(
'Django==3.2.*', 'Django==3.2.*',
'django-bootstrap3==15.0.*', 'django-bootstrap3==15.0.*',
'django-compressor==2.4.*', 'django-compressor==2.4.*',
'django-countries>=7.2', 'django-countries==7.2.*',
'django-filter==21.1', 'django-filter==21.1',
'django-formset-js-improved==0.5.0.2', 'django-formset-js-improved==0.5.0.2',
'django-formtools==2.3', 'django-formtools==2.3',

View File

@@ -0,0 +1,38 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# 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/>.
#
from openpyxl.cell.cell import TYPE_STRING
from pretix.helpers.safe_openpyxl import SafeWorkbook
def test_nullbyte_removed():
wb = SafeWorkbook()
ws = wb.create_sheet()
ws.append(["foo\u0000bar"])
assert ws.cell(1, 1).value == "foobar"
def test_no_formulas():
wb = SafeWorkbook()
ws = wb.create_sheet()
ws.append(["=1+1"])
assert ws.cell(1, 1).data_type == TYPE_STRING