Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

[1.5.x] Added a default limit to the maximum number of forms in a for…

…mset.

This is a security fix. Disclosure and advisory coming shortly.
  • Loading branch information...
commit 3ef4bbf495cc6c061789132e3d50a8231a89406b 1 parent 0e46c7f
Aymeric Augustin authored February 12, 2013 carljm committed February 19, 2013
23  django/forms/formsets.py
@@ -21,6 +21,9 @@
21 21
 ORDERING_FIELD_NAME = 'ORDER'
22 22
 DELETION_FIELD_NAME = 'DELETE'
23 23
 
  24
+# default maximum number of forms in a formset, to prevent memory exhaustion
  25
+DEFAULT_MAX_NUM = 1000
  26
+
24 27
 class ManagementForm(Form):
25 28
     """
26 29
     ``ManagementForm`` is used to keep track of how many form instances
@@ -97,11 +100,10 @@ def total_form_count(self):
97 100
             total_forms = initial_forms + self.extra
98 101
             # Allow all existing related objects/inlines to be displayed,
99 102
             # but don't allow extra beyond max_num.
100  
-            if self.max_num is not None:
101  
-                if initial_forms > self.max_num >= 0:
102  
-                    total_forms = initial_forms
103  
-                elif total_forms > self.max_num >= 0:
104  
-                    total_forms = self.max_num
  103
+            if initial_forms > self.max_num >= 0:
  104
+                total_forms = initial_forms
  105
+            elif total_forms > self.max_num >= 0:
  106
+                total_forms = self.max_num
105 107
         return total_forms
106 108
 
107 109
     def initial_form_count(self):
@@ -111,14 +113,14 @@ def initial_form_count(self):
111 113
         else:
112 114
             # Use the length of the inital data if it's there, 0 otherwise.
113 115
             initial_forms = self.initial and len(self.initial) or 0
114  
-            if self.max_num is not None and (initial_forms > self.max_num >= 0):
  116
+            if initial_forms > self.max_num >= 0:
115 117
                 initial_forms = self.max_num
116 118
         return initial_forms
117 119
 
118 120
     def _construct_forms(self):
119 121
         # instantiate all the forms and put them in self.forms
120 122
         self.forms = []
121  
-        for i in xrange(self.total_form_count()):
  123
+        for i in xrange(min(self.total_form_count(), self.absolute_max)):
122 124
             self.forms.append(self._construct_form(i))
123 125
 
124 126
     def _construct_form(self, i, **kwargs):
@@ -368,9 +370,14 @@ def as_ul(self):
368 370
 def formset_factory(form, formset=BaseFormSet, extra=1, can_order=False,
369 371
                     can_delete=False, max_num=None):
370 372
     """Return a FormSet for the given form class."""
  373
+    if max_num is None:
  374
+        max_num = DEFAULT_MAX_NUM
  375
+    # hard limit on forms instantiated, to prevent memory-exhaustion attacks
  376
+    # limit defaults to DEFAULT_MAX_NUM, but developer can increase it via max_num
  377
+    absolute_max = max(DEFAULT_MAX_NUM, max_num)
371 378
     attrs = {'form': form, 'extra': extra,
372 379
              'can_order': can_order, 'can_delete': can_delete,
373  
-             'max_num': max_num}
  380
+             'max_num': max_num, 'absolute_max': absolute_max}
374 381
     return type(form.__name__ + str('FormSet'), (formset,), attrs)
375 382
 
376 383
 def all_valid(formsets):
4  docs/topics/forms/formsets.txt
@@ -98,8 +98,8 @@ If the value of ``max_num`` is greater than the number of existing
98 98
 objects, up to ``extra`` additional blank forms will be added to the formset,
99 99
 so long as the total number of forms does not exceed ``max_num``.
100 100
 
101  
-A ``max_num`` value of ``None`` (the default) puts no limit on the number of
102  
-forms displayed.
  101
+A ``max_num`` value of ``None`` (the default) puts a high limit on the number
  102
+of forms displayed (1000). In practice this is equivalent to no limit.
103 103
 
104 104
 Formset validation
105 105
 ------------------
4  docs/topics/forms/modelforms.txt
@@ -727,8 +727,8 @@ so long as the total number of forms does not exceed ``max_num``::
727 727
     <tr><th><label for="id_form-2-name">Name:</label></th><td><input id="id_form-2-name" type="text" name="form-2-name" value="Walt Whitman" maxlength="100" /><input type="hidden" name="form-2-id" value="2" id="id_form-2-id" /></td></tr>
728 728
     <tr><th><label for="id_form-3-name">Name:</label></th><td><input id="id_form-3-name" type="text" name="form-3-name" maxlength="100" /><input type="hidden" name="form-3-id" id="id_form-3-id" /></td></tr>
729 729
 
730  
-A ``max_num`` value of ``None`` (the default) puts no limit on the number of
731  
-forms displayed.
  730
+A ``max_num`` value of ``None`` (the default) puts a high limit on the number
  731
+of forms displayed (1000). In practice this is equivalent to no limit.
732 732
 
733 733
 Using a model formset in a view
734 734
 -------------------------------
70  tests/regressiontests/forms/tests/formsets.py
@@ -2,7 +2,7 @@
2 2
 from __future__ import unicode_literals
3 3
 
4 4
 from django.forms import (CharField, DateField, FileField, Form, IntegerField,
5  
-    ValidationError)
  5
+    ValidationError, formsets)
6 6
 from django.forms.formsets import BaseFormSet, formset_factory
7 7
 from django.forms.util import ErrorList
8 8
 from django.test import TestCase
@@ -51,7 +51,7 @@ def test_basic_formset(self):
51 51
         # for adding data. By default, it displays 1 blank form. It can display more,
52 52
         # but we'll look at how to do so later.
53 53
         formset = ChoiceFormSet(auto_id=False, prefix='choices')
54  
-        self.assertHTMLEqual(str(formset), """<input type="hidden" name="choices-TOTAL_FORMS" value="1" /><input type="hidden" name="choices-INITIAL_FORMS" value="0" /><input type="hidden" name="choices-MAX_NUM_FORMS" />
  54
+        self.assertHTMLEqual(str(formset), """<input type="hidden" name="choices-TOTAL_FORMS" value="1" /><input type="hidden" name="choices-INITIAL_FORMS" value="0" /><input type="hidden" name="choices-MAX_NUM_FORMS" value="1000" />
55 55
 <tr><th>Choice:</th><td><input type="text" name="choices-0-choice" /></td></tr>
56 56
 <tr><th>Votes:</th><td><input type="text" name="choices-0-votes" /></td></tr>""")
57 57
 
@@ -654,8 +654,8 @@ def test_limiting_max_forms(self):
654 654
         # Limiting the maximum number of forms ########################################
655 655
         # Base case for max_num.
656 656
 
657  
-        # When not passed, max_num will take its default value of None, i.e. unlimited
658  
-        # number of forms, only controlled by the value of the extra parameter.
  657
+        # When not passed, max_num will take a high default value, leaving the
  658
+        # number of forms only controlled by the value of the extra parameter.
659 659
 
660 660
         LimitedFavoriteDrinkFormSet = formset_factory(FavoriteDrinkForm, extra=3)
661 661
         formset = LimitedFavoriteDrinkFormSet()
@@ -702,8 +702,8 @@ def test_limiting_max_forms(self):
702 702
     def test_max_num_with_initial_data(self):
703 703
         # max_num with initial data
704 704
 
705  
-        # When not passed, max_num will take its default value of None, i.e. unlimited
706  
-        # number of forms, only controlled by the values of the initial and extra
  705
+        # When not passed, max_num will take a high default value, leaving the
  706
+        # number of forms only controlled by the value of the initial and extra
707 707
         # parameters.
708 708
 
709 709
         initial = [
@@ -878,6 +878,64 @@ def is_valid(self):
878 878
         self.assertTrue(formset.is_valid())
879 879
         self.assertTrue(all([form.is_valid_called for form in formset.forms]))
880 880
 
  881
+    def test_hard_limit_on_instantiated_forms(self):
  882
+        """A formset has a hard limit on the number of forms instantiated."""
  883
+        # reduce the default limit of 1000 temporarily for testing
  884
+        _old_DEFAULT_MAX_NUM = formsets.DEFAULT_MAX_NUM
  885
+        try:
  886
+            formsets.DEFAULT_MAX_NUM = 3
  887
+            ChoiceFormSet = formset_factory(Choice)
  888
+            # someone fiddles with the mgmt form data...
  889
+            formset = ChoiceFormSet(
  890
+                {
  891
+                    'choices-TOTAL_FORMS': '4',
  892
+                    'choices-INITIAL_FORMS': '0',
  893
+                    'choices-MAX_NUM_FORMS': '4',
  894
+                    'choices-0-choice': 'Zero',
  895
+                    'choices-0-votes': '0',
  896
+                    'choices-1-choice': 'One',
  897
+                    'choices-1-votes': '1',
  898
+                    'choices-2-choice': 'Two',
  899
+                    'choices-2-votes': '2',
  900
+                    'choices-3-choice': 'Three',
  901
+                    'choices-3-votes': '3',
  902
+                    },
  903
+                prefix='choices',
  904
+                )
  905
+            # But we still only instantiate 3 forms
  906
+            self.assertEqual(len(formset.forms), 3)
  907
+        finally:
  908
+            formsets.DEFAULT_MAX_NUM = _old_DEFAULT_MAX_NUM
  909
+
  910
+    def test_increase_hard_limit(self):
  911
+        """Can increase the built-in forms limit via a higher max_num."""
  912
+        # reduce the default limit of 1000 temporarily for testing
  913
+        _old_DEFAULT_MAX_NUM = formsets.DEFAULT_MAX_NUM
  914
+        try:
  915
+            formsets.DEFAULT_MAX_NUM = 3
  916
+            # for this form, we want a limit of 4
  917
+            ChoiceFormSet = formset_factory(Choice, max_num=4)
  918
+            formset = ChoiceFormSet(
  919
+                {
  920
+                    'choices-TOTAL_FORMS': '4',
  921
+                    'choices-INITIAL_FORMS': '0',
  922
+                    'choices-MAX_NUM_FORMS': '4',
  923
+                    'choices-0-choice': 'Zero',
  924
+                    'choices-0-votes': '0',
  925
+                    'choices-1-choice': 'One',
  926
+                    'choices-1-votes': '1',
  927
+                    'choices-2-choice': 'Two',
  928
+                    'choices-2-votes': '2',
  929
+                    'choices-3-choice': 'Three',
  930
+                    'choices-3-votes': '3',
  931
+                    },
  932
+                prefix='choices',
  933
+                )
  934
+            # This time four forms are instantiated
  935
+            self.assertEqual(len(formset.forms), 4)
  936
+        finally:
  937
+            formsets.DEFAULT_MAX_NUM = _old_DEFAULT_MAX_NUM
  938
+
881 939
 
882 940
 data = {
883 941
     'choices-TOTAL_FORMS': '1', # the number of forms rendered
3  tests/regressiontests/generic_inline_admin/tests.py
@@ -6,6 +6,7 @@
6 6
 from django.contrib.admin.sites import AdminSite
7 7
 from django.contrib.contenttypes.generic import (
8 8
     generic_inlineformset_factory, GenericTabularInline)
  9
+from django.forms.formsets import DEFAULT_MAX_NUM
9 10
 from django.forms.models import ModelForm
10 11
 from django.test import TestCase
11 12
 from django.test.utils import override_settings
@@ -244,7 +245,7 @@ def test_get_formset_kwargs(self):
244 245
 
245 246
         # Create a formset with default arguments
246 247
         formset = media_inline.get_formset(request)
247  
-        self.assertEqual(formset.max_num, None)
  248
+        self.assertEqual(formset.max_num, DEFAULT_MAX_NUM)
248 249
         self.assertEqual(formset.can_order, False)
249 250
 
250 251
         # Create a formset with custom keyword arguments

0 notes on commit 3ef4bbf

Please sign in to comment.
Something went wrong with that request. Please try again.