CVE-2020-15225: Incorrect Conversion between Numeric Types (CWE-681)
value: django-filter is a generic system for filtering Django QuerySets based on user selections. In django-filter before version 2.4.0, automatically generated `NumberFilter` instances, whose value was later converted to an integer, were subject to potential DoS from maliciously input using exponential format with sufficiently large exponents. Version 2.4.0+ applies a `MaxValueValidator` with a a default `limit_value` of 1e50 to the form field used by `NumberFilter` instances. In addition, `NumberFilter` implements the new `get_max_validator()` which should return a configured validator instance to customise the limit, or else `None` to disable the additional validation. Users may manually apply an equivalent validator if they are not able to upgrade.
CWE description: When converting from one data type to another, such as long to integer, data can be omitted or translated in a way that produces unexpected values. If the resulting values are used in a sensitive context, then dangerous behaviors may occur.
CVSS v3 base score: 6.5
Published: 2021-04-29T21:15Z
Repository: https://github.com/carltongibson/django-filter
Language: Python
Files changed: CHANGES.rst, django_filters/__init__.py, django_filters/filters.py, docs/conf.py, docs/ref/filters.txt, requirements/maintainer.txt, setup.py, tests/test_forms.py
Commit date: 2020-09-27 11:04:03 +0200
Commit message: Version 2.4 (#1272)

* Added MaxValueValidator to NumberFilter.
* Update docs copyright year.
* Droped using bumpversion.

Diff: diff --git a/.bumpversion.cfg b/.bumpversion.cfg
deleted file mode 100644
index 8c96352..0000000
--- a/.bumpversion.cfg
+++ /dev/null
@@ -1,24 +0,0 @@
-[bumpversion]
-current_version = 2.3.0
-commit = False
-tag = False
-parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\.(?P<release>[a-z]+)(?P<num>\d+))?
-serialize =
-	{major}.{minor}.{patch}.{release}{num}
-	{major}.{minor}.{patch}
-
-[bumpversion:file:django_filters/__init__.py]
-
-[bumpversion:file:setup.py]
-
-[bumpversion:file:docs/conf.py]
-
-[bumpversion:part:release]
-optional_value = final
-values =
-	dev
-	final
-
-[bumpversion:part:num]
-first_value = 1
-
diff --git a/CHANGES.rst b/CHANGES.rst
index 0bfd961..8d6e8de 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,5 +1,22 @@
+Version 2.4.0 (2020-9-27)
+--------------------------
+
+* SECURITY: Added a ``MaxValueValidator`` to the form field for
+  ``NumberFilter``. This prevents a potential DoS attack if numbers with very
+  large exponents were subsequently converted to integers.
+
+  The default limit value for the validator is ``1e50``.
+
+  The new ``NumberFilter.get_max_validator()`` allows customising the used
+  validator, and may return ``None`` to disable the validation entirely.
+
+* Added testing against Django 3.1 and Python 3.9.
+
+  In addition tests against Django main development branch are now required to
+  pass.
+
 Version 2.3.0 (2020-6-5)
 ------------------------
 
 * Fixed import of FieldDoesNotExist. (#1127)
 * Added testing against Django 3.0. (#1125)
diff --git a/django_filters/__init__.py b/django_filters/__init__.py
index e42bf62..7828c53 100644
--- a/django_filters/__init__.py
+++ b/django_filters/__init__.py
@@ -8,11 +8,11 @@ from .filterset import FilterSet
 #   If DRF is not installed, no-op.
 if pkgutil.find_loader('rest_framework') is not None:
     from . import rest_framework
 del pkgutil
 
-__version__ = '2.3.0'
+__version__ = '2.4.0'
 
 
 def parse_version(version):
     '''
     '0.1.2.dev1' -> (0, 1, 2, 'dev1')
diff --git a/django_filters/filters.py b/django_filters/filters.py
index 8f3bd4c..8190af8 100644
--- a/django_filters/filters.py
+++ b/django_filters/filters.py
@@ -1,9 +1,10 @@
 from collections import OrderedDict
 from datetime import timedelta
 
 from django import forms
+from django.core.validators import MaxValueValidator
 from django.db.models import Q
 from django.db.models.constants import LOOKUP_SEP
 from django.forms.utils import pretty_name
 from django.utils.itercompat import is_iterable
 from django.utils.timezone import now
@@ -355,10 +356,27 @@ class ModelMultipleChoiceFilter(QuerySetRequestMixin, MultipleChoiceFilter):
 
 
 class NumberFilter(Filter):
     field_class = forms.DecimalField
 
+    def get_max_validator(self):
+        """
+        Return a MaxValueValidator for the field, or None to disable.
+        """
+        return MaxValueValidator(1e50)
+
+    @property
+    def field(self):
+        if not hasattr(self, '_field'):
+            field = super().field
+            max_validator = self.get_max_validator()
+            if max_validator:
+                field.validators.append(max_validator)
+
+            self._field = field
+        return self._field
+
 
 class NumericRangeFilter(Filter):
     field_class = RangeField
 
     def filter(self, qs, value):
diff --git a/docs/conf.py b/docs/conf.py
index 4a9e589..5de3b5e 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -11,10 +11,12 @@
 # All configuration values have a default; values that are commented out
 # serve to show the default.
 
 import sys, os
 
+from django_filters import __version__
+
 # If extensions (or modules to document with autodoc) are in another directory,
 # add these directories to sys.path here. If the directory is relative to the
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 #sys.path.insert(0, os.path.abspath('.'))
 
@@ -39,20 +41,20 @@ source_suffix = '.txt'
 # The master toctree document.
 master_doc = 'index'
 
 # General information about the project.
 project = u'django-filter'
-copyright = u'2019, Alex Gaynor, Carlton Gibson and others.'
+copyright = u'2020, Alex Gaynor, Carlton Gibson and others.'
 
 # The version info for the project you're documenting, acts as replacement for
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 #
 # The short X.Y version.
-version = '2.3'
+version = __version__
 # The full version, including alpha/beta/rc tags.
-release = '2.3.0'
+release = __version__
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
 #language = None
 
diff --git a/docs/ref/filters.txt b/docs/ref/filters.txt
index d144417..fcf0974 100644
--- a/docs/ref/filters.txt
+++ b/docs/ref/filters.txt
@@ -424,10 +424,16 @@ QuerySet, which then gets used as the model's manager::
 ~~~~~~~~~~~~~~~~
 
 Filters based on a numerical value, used with ``IntegerField``, ``FloatField``,
 and ``DecimalField`` by default.
 
+.. method:: NumberFilter.get_max_validator()
+
+    Return a ``MaxValueValidator`` instance that will be added to
+    ``field.validators``. By default uses a limit value of ``1e50``. Return
+    ``None`` to disable maximum value validation.
+
 ``NumericRangeFilter``
 ~~~~~~~~~~~~~~~~~~~~~~
 
 Filters where a value is between two numerical values, or greater than a minimum or less
 than a maximum where only one limit value is provided. This filter is designed to work
diff --git a/requirements/maintainer.txt b/requirements/maintainer.txt
index cd30f97..baf75e0 100644
--- a/requirements/maintainer.txt
+++ b/requirements/maintainer.txt
@@ -1,10 +1,9 @@
 alabaster==0.7.7
 argh==0.26.1
 Babel==2.2.0
 backports.ssl-match-hostname==3.4.0.2
-bumpversion==0.5.3
 certifi==2015.9.6.2
 docutils==0.12
 funcsigs==0.4
 Jinja2>=2.10.1
 livereload==2.4.0
diff --git a/setup.py b/setup.py
index b9ae214..dd129aa 100644
--- a/setup.py
+++ b/setup.py
@@ -1,32 +1,35 @@
 import os
 import sys
 from setuptools import setup, find_packages
 
+# FIXME: Main module requires django to be present, so cannot run setup.py in
+# clean environment.
+# from django_filters import __version__
+__version__ = '2.4.0'
+
 f = open('README.rst')
 readme = f.read()
 f.close()
 
-version = '2.3.0'
-
 if sys.argv[-1] == 'publish':
     if os.system("pip freeze | grep wheel"):
         print("wheel not installed.\nUse `pip install wheel`.\nExiting.")
         sys.exit()
     if os.system("pip freeze | grep twine"):
         print("twine not installed.\nUse `pip install twine`.\nExiting.")
         sys.exit()
     os.system("python setup.py sdist bdist_wheel")
     os.system("twine upload dist/*")
     print("You probably want to also tag the version now:")
-    print("  git tag -a %s -m 'version %s'" % (version, version))
+    print("  git tag -a %s -m 'version %s'" % (__version__, __version__))
     print("  git push --tags")
     sys.exit()
 
 setup(
     name='django-filter',
-    version=version,
+    version=__version__,
     description=('Django-filter is a reusable Django application for allowing'
                  ' users to filter querysets dynamically.'),
     long_description=readme,
     author='Alex Gaynor',
     author_email='alex.gaynor@gmail.com',
diff --git a/tests/test_forms.py b/tests/test_forms.py
index 13ca50d..568d748 100644
--- a/tests/test_forms.py
+++ b/tests/test_forms.py
@@ -253,5 +253,19 @@ class FilterSetValidityTests(TestCase):
 
         self.assertTrue(f.is_bound)
         self.assertFalse(f.is_valid())
         self.assertEqual(f.data, {'price': 'four dollars'})
         self.assertEqual(f.errors, {'price': ['Enter a number.']})
+
+    def test_number_filter_max_value_validation(self):
+        class F(FilterSet):
+            class Meta:
+                model = Book
+                fields = ['average_rating']
+
+        f = F({'average_rating': '1E1001'})
+        self.assertTrue(f.is_bound)
+        self.assertFalse(f.is_valid())
+        self.assertEqual(
+            f.errors,
+            {'average_rating': ['Ensure this value is less than or equal to 1e+50.']}
+        )

Vulnerable code: [bumpversion]
current_version = 2.3.0
commit = False
tag = False
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\.(?P<release>[a-z]+)(?P<num>\d+))?
serialize =
	{major}.{minor}.{patch}.{release}{num}
	{major}.{minor}.{patch}

[bumpversion:file:django_filters/__init__.py]

[bumpversion:file:setup.py]

[bumpversion:file:docs/conf.py]

[bumpversion:part:release]
optional_value = final
values =
	dev
	final

[bumpversion:part:num]
first_value = 1

__version__ = '2.3.0'
copyright = u'2019, Alex Gaynor, Carlton Gibson and others.'
version = '2.3'
release = '2.3.0'
bumpversion==0.5.3
version = '2.3.0'

    print("  git tag -a %s -m 'version %s'" % (version, version))
    version=version,
Fixed code: Version 2.4.0 (2020-9-27)
--------------------------

* SECURITY: Added a ``MaxValueValidator`` to the form field for
  ``NumberFilter``. This prevents a potential DoS attack if numbers with very
  large exponents were subsequently converted to integers.

  The default limit value for the validator is ``1e50``.

  The new ``NumberFilter.get_max_validator()`` allows customising the used
  validator, and may return ``None`` to disable the validation entirely.

* Added testing against Django 3.1 and Python 3.9.

  In addition tests against Django main development branch are now required to
  pass.

__version__ = '2.4.0'
from django.core.validators import MaxValueValidator
    def get_max_validator(self):
        """
        Return a MaxValueValidator for the field, or None to disable.
        """
        return MaxValueValidator(1e50)

    @property
    def field(self):
        if not hasattr(self, '_field'):
            field = super().field
            max_validator = self.get_max_validator()
            if max_validator:
                field.validators.append(max_validator)

            self._field = field
        return self._field

from django_filters import __version__

copyright = u'2020, Alex Gaynor, Carlton Gibson and others.'
version = __version__
release = __version__
.. method:: NumberFilter.get_max_validator()

    Return a ``MaxValueValidator`` instance that will be added to
    ``field.validators``. By default uses a limit value of ``1e50``. Return
    ``None`` to disable maximum value validation.

# FIXME: Main module requires django to be present, so cannot run setup.py in
# clean environment.
# from django_filters import __version__
__version__ = '2.4.0'

    print("  git tag -a %s -m 'version %s'" % (__version__, __version__))
    version=__version__,

    def test_number_filter_max_value_validation(self):
        class F(FilterSet):
            class Meta:
                model = Book
                fields = ['average_rating']

        f = F({'average_rating': '1E1001'})
        self.assertTrue(f.is_bound)
        self.assertFalse(f.is_valid())
        self.assertEqual(
            f.errors,
            {'average_rating': ['Ensure this value is less than or equal to 1e+50.']}
        )