From ad866a1ca3e7d60da888d25d27e46a8adb2ed36e Mon Sep 17 00:00:00 2001 From: Natalia <124304+nessita@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:51:45 -0300 Subject: [PATCH] Fixed CVE-2024-56374 -- Mitigated potential DoS in IPv6 validation. Thanks Saravana Kumar for the report, and Sarah Boyce and Mariusz Felisiak for the reviews. CVE: CVE-2024-56374 Upstream-Status: Backport https://github.com/django/django/commit/ad866a1ca3e7d60da888d25d27e46a8adb2ed36e Signed-off-by: Natalia <124304+nessita@users.noreply.github.com> Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> Signed-off-by: Saravanan %% original patch: CVE-2024-56374.patch --- django/db/models/fields/__init__.py | 6 +-- django/forms/fields.py | 7 +++- django/utils/ipv6.py | 22 ++++++++-- docs/ref/forms/fields.txt | 13 +++++- docs/releases/2.2.28.txt | 12 ++++++ .../field_tests/test_genericipaddressfield.py | 35 +++++++++++++++- tests/utils_tests/test_ipv6.py | 40 +++++++++++++++++-- 7 files changed, 120 insertions(+), 15 deletions(-) diff --git a/django/db/models/fields/__init__.py b/django/db/models/fields/__init__.py index e2d1846..c77702f 100644 --- a/django/db/models/fields/__init__.py +++ b/django/db/models/fields/__init__.py @@ -26,7 +26,7 @@ from django.utils.dateparse import ( ) from django.utils.duration import duration_microseconds, duration_string from django.utils.functional import Promise, cached_property -from django.utils.ipv6 import clean_ipv6_address +from django.utils.ipv6 import MAX_IPV6_ADDRESS_LENGTH, clean_ipv6_address from django.utils.itercompat import is_iterable from django.utils.text import capfirst from django.utils.translation import gettext_lazy as _ @@ -1904,7 +1904,7 @@ class GenericIPAddressField(Field): self.default_validators, invalid_error_message = \ validators.ip_address_validators(protocol, unpack_ipv4) self.default_error_messages['invalid'] = invalid_error_message - kwargs['max_length'] = 39 + kwargs["max_length"] = MAX_IPV6_ADDRESS_LENGTH super().__init__(verbose_name, name, *args, **kwargs) def check(self, **kwargs): @@ -1931,7 +1931,7 @@ class GenericIPAddressField(Field): kwargs['unpack_ipv4'] = self.unpack_ipv4 if self.protocol != "both": kwargs['protocol'] = self.protocol - if kwargs.get("max_length") == 39: + if kwargs.get("max_length") == self.max_length: del kwargs['max_length'] return name, path, args, kwargs diff --git a/django/forms/fields.py b/django/forms/fields.py index f939338..b3156b9 100644 --- a/django/forms/fields.py +++ b/django/forms/fields.py @@ -29,7 +29,7 @@ from django.forms.widgets import ( from django.utils import formats from django.utils.dateparse import parse_duration from django.utils.duration import duration_string -from django.utils.ipv6 import clean_ipv6_address +from django.utils.ipv6 import MAX_IPV6_ADDRESS_LENGTH, clean_ipv6_address from django.utils.translation import gettext_lazy as _, ngettext_lazy __all__ = ( @@ -1162,6 +1162,7 @@ class GenericIPAddressField(CharField): def __init__(self, *, protocol='both', unpack_ipv4=False, **kwargs): self.unpack_ipv4 = unpack_ipv4 self.default_validators = validators.ip_address_validators(protocol, unpack_ipv4)[0] + kwargs.setdefault("max_length", MAX_IPV6_ADDRESS_LENGTH) super().__init__(**kwargs) def to_python(self, value): @@ -1169,7 +1170,9 @@ class GenericIPAddressField(CharField): return '' value = value.strip() if value and ':' in value: - return clean_ipv6_address(value, self.unpack_ipv4) + return clean_ipv6_address( + value, self.unpack_ipv4, max_length=self.max_length + ) return value diff --git a/django/utils/ipv6.py b/django/utils/ipv6.py index ddb8c80..aed7902 100644 --- a/django/utils/ipv6.py +++ b/django/utils/ipv6.py @@ -3,9 +3,23 @@ import ipaddress from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ +MAX_IPV6_ADDRESS_LENGTH = 39 -def clean_ipv6_address(ip_str, unpack_ipv4=False, - error_message=_("This is not a valid IPv6 address.")): + +def _ipv6_address_from_str(ip_str, max_length=MAX_IPV6_ADDRESS_LENGTH): + if len(ip_str) > max_length: + raise ValueError( + f"Unable to convert {ip_str} to an IPv6 address (value too long)." + ) + return ipaddress.IPv6Address(int(ipaddress.IPv6Address(ip_str))) + + +def clean_ipv6_address( + ip_str, + unpack_ipv4=False, + error_message=_("This is not a valid IPv6 address."), + max_length=MAX_IPV6_ADDRESS_LENGTH, + ): """ Clean an IPv6 address string. @@ -23,7 +37,7 @@ def clean_ipv6_address(ip_str, unpack_ipv4=False, Return a compressed IPv6 address or the same value. """ try: - addr = ipaddress.IPv6Address(int(ipaddress.IPv6Address(ip_str))) + addr = _ipv6_address_from_str(ip_str, max_length) except ValueError: raise ValidationError(error_message, code='invalid') @@ -40,7 +54,7 @@ def is_valid_ipv6_address(ip_str): Return whether or not the `ip_str` string is a valid IPv6 address. """ try: - ipaddress.IPv6Address(ip_str) + _ipv6_address_from_str(ip_str) except ValueError: return False return True diff --git a/docs/ref/forms/fields.txt b/docs/ref/forms/fields.txt index 3a888ef..688890a 100644 --- a/docs/ref/forms/fields.txt +++ b/docs/ref/forms/fields.txt @@ -791,7 +791,7 @@ For each field, we describe the default widget used if you don't specify * Empty value: ``''`` (an empty string) * Normalizes to: A string. IPv6 addresses are normalized as described below. * Validates that the given value is a valid IP address. - * Error message keys: ``required``, ``invalid`` + * Error message keys: ``required``, ``invalid``, ``max_length`` The IPv6 address normalization follows :rfc:`4291#section-2.2` section 2.2, including using the IPv4 format suggested in paragraph 3 of that section, like @@ -799,7 +799,7 @@ For each field, we describe the default widget used if you don't specify ``2001::1``, and ``::ffff:0a0a:0a0a`` to ``::ffff:10.10.10.10``. All characters are converted to lowercase. - Takes two optional arguments: + Takes three optional arguments: .. attribute:: protocol @@ -814,6 +814,15 @@ For each field, we describe the default widget used if you don't specify ``192.0.2.1``. Default is disabled. Can only be used when ``protocol`` is set to ``'both'``. + .. attribute:: max_length + + Defaults to 39, and behaves the same way as it does for + :class:`CharField`. + + .. versionchanged:: 4.2.18 + + The default value for ``max_length`` was set to 39 characters. + ``MultipleChoiceField`` ----------------------- diff --git a/docs/releases/2.2.28.txt b/docs/releases/2.2.28.txt index 7096d13..0e092f0 100644 --- a/docs/releases/2.2.28.txt +++ b/docs/releases/2.2.28.txt @@ -105,3 +105,15 @@ CVE-2025-26699: Potential denial-of-service vulnerability in ``django.utils.text The ``wrap()`` and :tfilter:`wordwrap` template filter were subject to a potential denial-of-service attack when used with very long strings. +CVE-2024-56374: Potential denial-of-service vulnerability in IPv6 validation +============================================================================ + +Lack of upper bound limit enforcement in strings passed when performing IPv6 +validation could lead to a potential denial-of-service attack. The undocumented +and private functions ``clean_ipv6_address`` and ``is_valid_ipv6_address`` were +vulnerable, as was the :class:`django.forms.GenericIPAddressField` form field, +which has now been updated to define a ``max_length`` of 39 characters. + +The :class:`django.db.models.GenericIPAddressField` model field was not +affected. + diff --git a/tests/forms_tests/field_tests/test_genericipaddressfield.py b/tests/forms_tests/field_tests/test_genericipaddressfield.py index 97a83e3..4c79d78 100644 --- a/tests/forms_tests/field_tests/test_genericipaddressfield.py +++ b/tests/forms_tests/field_tests/test_genericipaddressfield.py @@ -1,5 +1,6 @@ from django.forms import GenericIPAddressField, ValidationError from django.test import SimpleTestCase +from django.utils.ipv6 import MAX_IPV6_ADDRESS_LENGTH class GenericIPAddressFieldTest(SimpleTestCase): @@ -89,6 +90,35 @@ class GenericIPAddressFieldTest(SimpleTestCase): with self.assertRaisesMessage(ValidationError, "'This is not a valid IPv6 address.'"): f.clean('1:2') + def test_generic_ipaddress_max_length_custom(self): + # Valid IPv4-mapped IPv6 address, len 45. + addr = "0000:0000:0000:0000:0000:ffff:192.168.100.228" + f = GenericIPAddressField(max_length=len(addr)) + f.clean(addr) + + def test_generic_ipaddress_max_length_validation_error(self): + # Valid IPv4-mapped IPv6 address, len 45. + addr = "0000:0000:0000:0000:0000:ffff:192.168.100.228" + + cases = [ + ({}, MAX_IPV6_ADDRESS_LENGTH), # Default value. + ({"max_length": len(addr) - 1}, len(addr) - 1), + ] + for kwargs, max_length in cases: + max_length_plus_one = max_length + 1 + msg = ( + f"Ensure this value has at most {max_length} characters (it has " + f"{max_length_plus_one}).'" + ) + with self.subTest(max_length=max_length): + f = GenericIPAddressField(**kwargs) + with self.assertRaisesMessage(ValidationError, msg): + f.clean("x" * max_length_plus_one) + with self.assertRaisesMessage( + ValidationError, "This is not a valid IPv6 address." + ): + f.clean(addr) + def test_generic_ipaddress_as_generic_not_required(self): f = GenericIPAddressField(required=False) self.assertEqual(f.clean(''), '') @@ -103,7 +133,10 @@ class GenericIPAddressFieldTest(SimpleTestCase): with self.assertRaisesMessage(ValidationError, "'Enter a valid IPv4 or IPv6 address.'"): f.clean('256.125.1.5') self.assertEqual(f.clean(' fe80::223:6cff:fe8a:2e8a '), 'fe80::223:6cff:fe8a:2e8a') - self.assertEqual(f.clean(' 2a02::223:6cff:fe8a:2e8a '), '2a02::223:6cff:fe8a:2e8a') + self.assertEqual( + f.clean(" " * MAX_IPV6_ADDRESS_LENGTH + " 2a02::223:6cff:fe8a:2e8a "), + "2a02::223:6cff:fe8a:2e8a", + ) with self.assertRaisesMessage(ValidationError, "'This is not a valid IPv6 address.'"): f.clean('12345:2:3:4') with self.assertRaisesMessage(ValidationError, "'This is not a valid IPv6 address.'"): diff --git a/tests/utils_tests/test_ipv6.py b/tests/utils_tests/test_ipv6.py index 4e434f3..1ac6763 100644 --- a/tests/utils_tests/test_ipv6.py +++ b/tests/utils_tests/test_ipv6.py @@ -1,9 +1,17 @@ -import unittest +import traceback +from io import StringIO -from django.utils.ipv6 import clean_ipv6_address, is_valid_ipv6_address +from django.core.exceptions import ValidationError +from django.test import SimpleTestCase +from django.utils.ipv6 import ( + MAX_IPV6_ADDRESS_LENGTH, + clean_ipv6_address, + is_valid_ipv6_address, +) +from django.utils.version import PY310 -class TestUtilsIPv6(unittest.TestCase): +class TestUtilsIPv6(SimpleTestCase): def test_validates_correct_plain_address(self): self.assertTrue(is_valid_ipv6_address('fe80::223:6cff:fe8a:2e8a')) @@ -55,3 +63,29 @@ class TestUtilsIPv6(unittest.TestCase): self.assertEqual(clean_ipv6_address('::ffff:0a0a:0a0a', unpack_ipv4=True), '10.10.10.10') self.assertEqual(clean_ipv6_address('::ffff:1234:1234', unpack_ipv4=True), '18.52.18.52') self.assertEqual(clean_ipv6_address('::ffff:18.52.18.52', unpack_ipv4=True), '18.52.18.52') + + def test_address_too_long(self): + addresses = [ + "0000:0000:0000:0000:0000:ffff:192.168.100.228", # IPv4-mapped IPv6 address + "0000:0000:0000:0000:0000:ffff:192.168.100.228%123456", # % scope/zone + "fe80::223:6cff:fe8a:2e8a:1234:5678:00000", # MAX_IPV6_ADDRESS_LENGTH + 1 + ] + msg = "This is the error message." + value_error_msg = "Unable to convert %s to an IPv6 address (value too long)." + for addr in addresses: + with self.subTest(addr=addr): + self.assertGreater(len(addr), MAX_IPV6_ADDRESS_LENGTH) + self.assertEqual(is_valid_ipv6_address(addr), False) + with self.assertRaisesMessage(ValidationError, msg) as ctx: + clean_ipv6_address(addr, error_message=msg) + exception_traceback = StringIO() + if PY310: + traceback.print_exception(ctx.exception, file=exception_traceback) + else: + traceback.print_exception( + type(ctx.exception), + value=ctx.exception, + tb=ctx.exception.__traceback__, + file=exception_traceback, + ) + self.assertIn(value_error_msg % addr, exception_traceback.getvalue()) -- 2.40.0