Fixed #20347 -- Allowed customizing the maximum number of instantiated forms in formsets.

Co-authored-by: ethurgood <ethurgood@gmail.com>
This commit is contained in:
David Smith 2020-04-30 08:34:53 +01:00 committed by Mariusz Felisiak
parent b5aa9cb20f
commit 433dd737f9
8 changed files with 202 additions and 19 deletions

View File

@ -433,16 +433,21 @@ class BaseFormSet:
def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False,
can_delete=False, max_num=None, validate_max=False, can_delete=False, max_num=None, validate_max=False,
min_num=None, validate_min=False): min_num=None, validate_min=False, absolute_max=None):
"""Return a FormSet for the given form class.""" """Return a FormSet for the given form class."""
if min_num is None: if min_num is None:
min_num = DEFAULT_MIN_NUM min_num = DEFAULT_MIN_NUM
if max_num is None: if max_num is None:
max_num = DEFAULT_MAX_NUM max_num = DEFAULT_MAX_NUM
# hard limit on forms instantiated, to prevent memory-exhaustion attacks # absolute_max is a hard limit on forms instantiated, to prevent
# limit is simply max_num + DEFAULT_MAX_NUM (which is 2*DEFAULT_MAX_NUM # memory-exhaustion attacks. Default to max_num + DEFAULT_MAX_NUM
# if max_num is None in the first place) # (which is 2 * DEFAULT_MAX_NUM if max_num is None in the first place).
absolute_max = max_num + DEFAULT_MAX_NUM if absolute_max is None:
absolute_max = max_num + DEFAULT_MAX_NUM
if max_num > absolute_max:
raise ValueError(
"'absolute_max' must be greater or equal to 'max_num'."
)
attrs = { attrs = {
'form': form, 'form': form,
'extra': extra, 'extra': extra,

View File

@ -862,7 +862,8 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None,
can_order=False, max_num=None, fields=None, exclude=None, can_order=False, max_num=None, fields=None, exclude=None,
widgets=None, validate_max=False, localized_fields=None, widgets=None, validate_max=False, localized_fields=None,
labels=None, help_texts=None, error_messages=None, labels=None, help_texts=None, error_messages=None,
min_num=None, validate_min=False, field_classes=None): min_num=None, validate_min=False, field_classes=None,
absolute_max=None):
"""Return a FormSet class for the given Django model class.""" """Return a FormSet class for the given Django model class."""
meta = getattr(form, 'Meta', None) meta = getattr(form, 'Meta', None)
if (getattr(meta, 'fields', fields) is None and if (getattr(meta, 'fields', fields) is None and
@ -879,7 +880,8 @@ def modelformset_factory(model, form=ModelForm, formfield_callback=None,
error_messages=error_messages, field_classes=field_classes) error_messages=error_messages, field_classes=field_classes)
FormSet = formset_factory(form, formset, extra=extra, min_num=min_num, max_num=max_num, FormSet = formset_factory(form, formset, extra=extra, min_num=min_num, max_num=max_num,
can_order=can_order, can_delete=can_delete, can_order=can_order, can_delete=can_delete,
validate_min=validate_min, validate_max=validate_max) validate_min=validate_min, validate_max=validate_max,
absolute_max=absolute_max)
FormSet.model = model FormSet.model = model
return FormSet return FormSet
@ -1048,7 +1050,8 @@ def inlineformset_factory(parent_model, model, form=ModelForm,
can_delete=True, max_num=None, formfield_callback=None, can_delete=True, max_num=None, formfield_callback=None,
widgets=None, validate_max=False, localized_fields=None, widgets=None, validate_max=False, localized_fields=None,
labels=None, help_texts=None, error_messages=None, labels=None, help_texts=None, error_messages=None,
min_num=None, validate_min=False, field_classes=None): min_num=None, validate_min=False, field_classes=None,
absolute_max=None):
""" """
Return an ``InlineFormSet`` for the given kwargs. Return an ``InlineFormSet`` for the given kwargs.
@ -1078,6 +1081,7 @@ def inlineformset_factory(parent_model, model, form=ModelForm,
'help_texts': help_texts, 'help_texts': help_texts,
'error_messages': error_messages, 'error_messages': error_messages,
'field_classes': field_classes, 'field_classes': field_classes,
'absolute_max': absolute_max,
} }
FormSet = modelformset_factory(model, **kwargs) FormSet = modelformset_factory(model, **kwargs)
FormSet.fk = fk FormSet.fk = fk

View File

@ -11,8 +11,12 @@ Formset API reference. For introductory material about formsets, see the
``formset_factory`` ``formset_factory``
=================== ===================
.. function:: formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None, validate_max=False, min_num=None, validate_min=False) .. function:: formset_factory(form, formset=BaseFormSet, extra=1, can_order=False, can_delete=False, max_num=None, validate_max=False, min_num=None, validate_min=False, absolute_max=None)
Returns a ``FormSet`` class for the given ``form`` class. Returns a ``FormSet`` class for the given ``form`` class.
See :doc:`formsets </topics/forms/formsets>` for example usage. See :doc:`formsets </topics/forms/formsets>` for example usage.
.. versionchanged:: 3.2
The ``absolute_max`` argument was added.

View File

@ -52,7 +52,7 @@ Model Form API reference. For introductory material about model forms, see the
``modelformset_factory`` ``modelformset_factory``
======================== ========================
.. function:: modelformset_factory(model, form=ModelForm, formfield_callback=None, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None) .. function:: modelformset_factory(model, form=ModelForm, formfield_callback=None, formset=BaseModelFormSet, extra=1, can_delete=False, can_order=False, max_num=None, fields=None, exclude=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None)
Returns a ``FormSet`` class for the given ``model`` class. Returns a ``FormSet`` class for the given ``model`` class.
@ -62,16 +62,20 @@ Model Form API reference. For introductory material about model forms, see the
through to :func:`~django.forms.models.modelform_factory`. through to :func:`~django.forms.models.modelform_factory`.
Arguments ``formset``, ``extra``, ``max_num``, ``can_order``, Arguments ``formset``, ``extra``, ``max_num``, ``can_order``,
``can_delete`` and ``validate_max`` are passed through to ``can_delete``, ``validate_max``, and ``absolute_max`` are passed through
:func:`~django.forms.formsets.formset_factory`. See :doc:`formsets to :func:`~django.forms.formsets.formset_factory`. See :doc:`formsets
</topics/forms/formsets>` for details. </topics/forms/formsets>` for details.
See :ref:`model-formsets` for example usage. See :ref:`model-formsets` for example usage.
.. versionchanged:: 3.2
The ``absolute_max`` argument was added.
``inlineformset_factory`` ``inlineformset_factory``
========================= =========================
.. function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None) .. function:: inlineformset_factory(parent_model, model, form=ModelForm, formset=BaseInlineFormSet, fk_name=None, fields=None, exclude=None, extra=3, can_order=False, can_delete=True, max_num=None, formfield_callback=None, widgets=None, validate_max=False, localized_fields=None, labels=None, help_texts=None, error_messages=None, min_num=None, validate_min=False, field_classes=None, absolute_max=None)
Returns an ``InlineFormSet`` using :func:`modelformset_factory` with Returns an ``InlineFormSet`` using :func:`modelformset_factory` with
defaults of ``formset=``:class:`~django.forms.models.BaseInlineFormSet`, defaults of ``formset=``:class:`~django.forms.models.BaseInlineFormSet`,
@ -81,3 +85,7 @@ Model Form API reference. For introductory material about model forms, see the
the ``parent_model``, you must specify a ``fk_name``. the ``parent_model``, you must specify a ``fk_name``.
See :ref:`inline-formsets` for example usage. See :ref:`inline-formsets` for example usage.
.. versionchanged:: 3.2
The ``absolute_max`` argument was added.

View File

@ -136,7 +136,10 @@ File Uploads
Forms Forms
~~~~~ ~~~~~
* ... * The new ``absolute_max`` argument for :func:`.formset_factory`,
:func:`.inlineformset_factory`, and :func:`.modelformset_factory` allows
customizing the maximum number of forms that can be instantiated when
supplying ``POST`` data. See :ref:`formsets-absolute-max` for more details.
Generic Views Generic Views
~~~~~~~~~~~~~ ~~~~~~~~~~~~~

View File

@ -126,6 +126,38 @@ affect validation. If ``validate_max=True`` is passed to the
:func:`~django.forms.formsets.formset_factory`, then ``max_num`` will affect :func:`~django.forms.formsets.formset_factory`, then ``max_num`` will affect
validation. See :ref:`validate_max`. validation. See :ref:`validate_max`.
.. _formsets-absolute-max:
Limiting the maximum number of instantiated forms
=================================================
.. versionadded:: 3.2
The ``absolute_max`` parameter to :func:`.formset_factory` allows limiting the
number of forms that can be instantiated when supplying ``POST`` data. This
protects against memory exhaustion attacks using forged ``POST`` requests::
>>> from django.forms.formsets import formset_factory
>>> from myapp.forms import ArticleForm
>>> ArticleFormSet = formset_factory(ArticleForm, absolute_max=1500)
>>> data = {
... 'form-TOTAL_FORMS': '1501',
... 'form-INITIAL_FORMS': '0',
... 'form-MAX_NUM_FORMS': '',
... }
>>> formset = ArticleFormSet(data)
>>> len(formset.forms)
1500
>>> formset.is_valid()
False
>>> formset.non_form_errors()
['Please submit 1000 or fewer forms.']
When ``absolute_max`` is None, it defaults to ``max_num + 1000``. (If
``max_num`` is ``None``, it defaults to ``2000``).
If ``absolute_max`` is less than ``max_num``, a ``ValueError`` will be raised.
Formset validation Formset validation
================== ==================
@ -348,11 +380,11 @@ excessive.
.. note:: .. note::
Regardless of ``validate_max``, if the number of forms in a data set Regardless of ``validate_max``, if the number of forms in a data set
exceeds ``max_num`` by more than 1000, then the form will fail to validate exceeds ``absolute_max``, then the form will fail to validate as if
as if ``validate_max`` were set, and additionally only the first 1000 ``validate_max`` were set, and additionally only the first ``absolute_max``
forms above ``max_num`` will be validated. The remainder will be forms will be validated. The remainder will be truncated entirely. This is
truncated entirely. This is to protect against memory exhaustion attacks to protect against memory exhaustion attacks using forged POST requests.
using forged POST requests. See :ref:`formsets-absolute-max`.
``validate_min`` ``validate_min``
---------------- ----------------

View File

@ -892,6 +892,55 @@ class FormsFormsetTestCase(SimpleTestCase):
) )
self.assertEqual(formset.absolute_max, 2000) self.assertEqual(formset.absolute_max, 2000)
def test_absolute_max(self):
data = {
'form-TOTAL_FORMS': '2001',
'form-INITIAL_FORMS': '0',
'form-MAX_NUM_FORMS': '0',
}
AbsoluteMaxFavoriteDrinksFormSet = formset_factory(
FavoriteDrinkForm,
absolute_max=3000,
)
formset = AbsoluteMaxFavoriteDrinksFormSet(data=data)
self.assertIs(formset.is_valid(), True)
self.assertEqual(len(formset.forms), 2001)
# absolute_max provides a hard limit.
data['form-TOTAL_FORMS'] = '3001'
formset = AbsoluteMaxFavoriteDrinksFormSet(data=data)
self.assertIs(formset.is_valid(), False)
self.assertEqual(len(formset.forms), 3000)
self.assertEqual(
formset.non_form_errors(),
['Please submit 1000 or fewer forms.'],
)
def test_absolute_max_with_max_num(self):
data = {
'form-TOTAL_FORMS': '1001',
'form-INITIAL_FORMS': '0',
'form-MAX_NUM_FORMS': '0',
}
LimitedFavoriteDrinksFormSet = formset_factory(
FavoriteDrinkForm,
max_num=30,
absolute_max=1000,
)
formset = LimitedFavoriteDrinksFormSet(data=data)
self.assertIs(formset.is_valid(), False)
self.assertEqual(len(formset.forms), 1000)
self.assertEqual(
formset.non_form_errors(),
['Please submit 30 or fewer forms.'],
)
def test_absolute_max_invalid(self):
msg = "'absolute_max' must be greater or equal to 'max_num'."
for max_num in [None, 31]:
with self.subTest(max_num=max_num):
with self.assertRaisesMessage(ValueError, msg):
formset_factory(FavoriteDrinkForm, max_num=max_num, absolute_max=30)
def test_more_initial_form_result_in_one(self): def test_more_initial_form_result_in_one(self):
""" """
One form from initial and extra=3 with max_num=2 results in the one One form from initial and extra=3 with max_num=2 results in the one

View File

@ -1838,3 +1838,81 @@ class TestModelFormsetOverridesTroughFormMeta(TestCase):
form = BookFormSet.form(data={'title': 'Foo ' * 30, 'author': author.id}) form = BookFormSet.form(data={'title': 'Foo ' * 30, 'author': author.id})
self.assertIs(Book._meta.get_field('title').__class__, models.CharField) self.assertIs(Book._meta.get_field('title').__class__, models.CharField)
self.assertIsInstance(form.fields['title'], forms.SlugField) self.assertIsInstance(form.fields['title'], forms.SlugField)
def test_modelformset_factory_absolute_max(self):
AuthorFormSet = modelformset_factory(Author, fields='__all__', absolute_max=1500)
data = {
'form-TOTAL_FORMS': '1501',
'form-INITIAL_FORMS': '0',
'form-MAX_NUM_FORMS': '0',
}
formset = AuthorFormSet(data=data)
self.assertIs(formset.is_valid(), False)
self.assertEqual(len(formset.forms), 1500)
self.assertEqual(
formset.non_form_errors(),
['Please submit 1000 or fewer forms.'],
)
def test_modelformset_factory_absolute_max_with_max_num(self):
AuthorFormSet = modelformset_factory(
Author,
fields='__all__',
max_num=20,
absolute_max=100,
)
data = {
'form-TOTAL_FORMS': '101',
'form-INITIAL_FORMS': '0',
'form-MAX_NUM_FORMS': '0',
}
formset = AuthorFormSet(data=data)
self.assertIs(formset.is_valid(), False)
self.assertEqual(len(formset.forms), 100)
self.assertEqual(
formset.non_form_errors(),
['Please submit 20 or fewer forms.'],
)
def test_inlineformset_factory_absolute_max(self):
author = Author.objects.create(name='Charles Baudelaire')
BookFormSet = inlineformset_factory(
Author,
Book,
fields='__all__',
absolute_max=1500,
)
data = {
'book_set-TOTAL_FORMS': '1501',
'book_set-INITIAL_FORMS': '0',
'book_set-MAX_NUM_FORMS': '0',
}
formset = BookFormSet(data, instance=author)
self.assertIs(formset.is_valid(), False)
self.assertEqual(len(formset.forms), 1500)
self.assertEqual(
formset.non_form_errors(),
['Please submit 1000 or fewer forms.'],
)
def test_inlineformset_factory_absolute_max_with_max_num(self):
author = Author.objects.create(name='Charles Baudelaire')
BookFormSet = inlineformset_factory(
Author,
Book,
fields='__all__',
max_num=20,
absolute_max=100,
)
data = {
'book_set-TOTAL_FORMS': '101',
'book_set-INITIAL_FORMS': '0',
'book_set-MAX_NUM_FORMS': '0',
}
formset = BookFormSet(data, instance=author)
self.assertIs(formset.is_valid(), False)
self.assertEqual(len(formset.forms), 100)
self.assertEqual(
formset.non_form_errors(),
['Please submit 20 or fewer forms.'],
)