Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/api-guide/fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,12 +471,13 @@ Requires either the `Pillow` package or `PIL` package. The `Pillow` package is

A field class that validates a list of objects.

**Signature**: `ListField(child=<A_FIELD_INSTANCE>, allow_empty=True, min_length=None, max_length=None)`
**Signature**: `ListField(child=<A_FIELD_INSTANCE>, allow_empty=True, min_length=None, max_length=None, max_depth=None)`
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think adding a default limit for max_depth would be a good idea? Users could still override it if needed, but having a cushion would help even if they don't set a limit. What’s your take on that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered that, but a default limit might break existing setups that legitimately use deep nesting. Keeping it None by default feels safer for backward compatibility. That said, we should definitely highlight it in the docs as a security best practice.


* `child` - A field instance that should be used for validating the objects in the list. If this argument is not provided then objects in the list will not be validated.
* `allow_empty` - Designates if empty lists are allowed.
* `min_length` - Validates that the list contains no fewer than this number of elements.
* `max_length` - Validates that the list contains no more than this number of elements.
* `max_depth` - Validates that nesting depth does not exceed this value. This applies to both the field schema depth and the raw input data depth. A value of 1 permits a flat structure (e.g., `[1, 2, 3]`) but rejects nested data (e.g., `[[1, 2]]`). Defaults to `None` (no limit).

For example, to validate a list of integers you might use something like the following:

Expand All @@ -495,10 +496,11 @@ We can now reuse our custom `StringListField` class throughout our application,

A field class that validates a dictionary of objects. The keys in `DictField` are always assumed to be string values.

**Signature**: `DictField(child=<A_FIELD_INSTANCE>, allow_empty=True)`
**Signature**: `DictField(child=<A_FIELD_INSTANCE>, allow_empty=True, max_depth=None)`

* `child` - A field instance that should be used for validating the values in the dictionary. If this argument is not provided then values in the mapping will not be validated.
* `allow_empty` - Designates if empty dictionaries are allowed.
* `max_depth` - Validates that nesting depth does not exceed this value. This applies to both the field schema depth and the raw input data depth. A value of 1 permits a flat structure (e.g., `{"a": 1}`) but rejects nested data (e.g., `{"a": {"b": 1}}`). Defaults to `None` (no limit).

For example, to create a field that validates a mapping of strings to strings, you would write something like this:

Expand Down
69 changes: 67 additions & 2 deletions rest_framework/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -1650,14 +1650,16 @@ class ListField(Field):
'not_a_list': _('Expected a list of items but got type "{input_type}".'),
'empty': _('This list may not be empty.'),
'min_length': _('Ensure this field has at least {min_length} elements.'),
'max_length': _('Ensure this field has no more than {max_length} elements.')
'max_length': _('Ensure this field has no more than {max_length} elements.'),
'max_depth': _('List nesting depth exceeds maximum allowed depth of {max_depth}.')
}

def __init__(self, **kwargs):
self.child = kwargs.pop('child', copy.deepcopy(self.child))
self.allow_empty = kwargs.pop('allow_empty', True)
self.max_length = kwargs.pop('max_length', None)
self.min_length = kwargs.pop('min_length', None)
self.max_depth = kwargs.pop('max_depth', None)

assert not inspect.isclass(self.child), '`child` has not been instantiated.'
assert self.child.source is None, (
Expand All @@ -1666,6 +1668,8 @@ def __init__(self, **kwargs):
)

super().__init__(**kwargs)
self._current_depth = 0
self._root_max_depth = self.max_depth
self.child.bind(field_name='', parent=self)
if self.max_length is not None:
message = lazy_format(self.error_messages['max_length'], max_length=self.max_length)
Expand All @@ -1674,6 +1678,29 @@ def __init__(self, **kwargs):
message = lazy_format(self.error_messages['min_length'], min_length=self.min_length)
self.validators.append(MinLengthValidator(self.min_length, message=message))

def bind(self, field_name, parent):
super().bind(field_name, parent)
if self.max_depth is None and hasattr(parent, '_root_max_depth') and parent._root_max_depth is not None:
self._root_max_depth = parent._root_max_depth
self._current_depth = parent._current_depth + 1
self._propagate_depth_to_child()

def _propagate_depth_to_child(self):
if self._root_max_depth is not None and hasattr(self.child, '_root_max_depth'):
self.child._root_max_depth = self._root_max_depth
self.child._current_depth = self._current_depth + 1
if hasattr(self.child, '_propagate_depth_to_child'):
self.child._propagate_depth_to_child()

def _check_data_depth(self, data, current_level):
items = data.values() if isinstance(data, dict) else data
for item in items:
if isinstance(item, (list, tuple, dict)):
next_level = current_level + 1
if next_level > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
self._check_data_depth(item, next_level)
Comment on lines +1695 to +1702
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is significant code duplication in the _check_data_depth method implementations across BaseSerializer (lines 145-169), ListSerializer (lines 710-734), ListField (lines 1695-1702), and DictField (lines 1799-1806). These implementations all perform similar recursive depth checking with the same validation logic, but use different error handling approaches (some use self.fail(), others construct ValidationError directly).

This duplication makes the code harder to maintain and increases the risk of inconsistencies. Consider extracting this logic into a shared utility function or mixin method that can be reused across all these classes.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplication is intentional. Fields use self.fail() for error handling, while Serializers construct ValidationError directly - they follow different API patterns.

Extracting this would need conditionals for different error handling, reducing clarity for 8 lines of straightforward logic. The architectural separation between Fields and Serializers is worth maintaining here.


def get_value(self, dictionary):
if self.field_name not in dictionary:
if getattr(self.root, 'partial', False):
Expand All @@ -1699,6 +1726,12 @@ def to_internal_value(self, data):
self.fail('not_a_list', input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail('empty')
if self._root_max_depth is not None:
start_level = self._current_depth if self._current_depth > 0 else 1
if start_level > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
if self.max_depth is not None:
self._check_data_depth(data, start_level)
return self.run_child_validation(data)

def to_representation(self, data):
Expand Down Expand Up @@ -1730,11 +1763,13 @@ class DictField(Field):
default_error_messages = {
'not_a_dict': _('Expected a dictionary of items but got type "{input_type}".'),
'empty': _('This dictionary may not be empty.'),
'max_depth': _('Dictionary nesting depth exceeds maximum allowed depth of {max_depth}.')
}

def __init__(self, **kwargs):
self.child = kwargs.pop('child', copy.deepcopy(self.child))
self.allow_empty = kwargs.pop('allow_empty', True)
self.max_depth = kwargs.pop('max_depth', None)

assert not inspect.isclass(self.child), '`child` has not been instantiated.'
assert self.child.source is None, (
Expand All @@ -1743,8 +1778,33 @@ def __init__(self, **kwargs):
)

super().__init__(**kwargs)
self._current_depth = 0
self._root_max_depth = self.max_depth
self.child.bind(field_name='', parent=self)

def bind(self, field_name, parent):
super().bind(field_name, parent)
if self.max_depth is None and hasattr(parent, '_root_max_depth') and parent._root_max_depth is not None:
self._root_max_depth = parent._root_max_depth
self._current_depth = parent._current_depth + 1
self._propagate_depth_to_child()

def _propagate_depth_to_child(self):
if self._root_max_depth is not None and hasattr(self.child, '_root_max_depth'):
self.child._root_max_depth = self._root_max_depth
self.child._current_depth = self._current_depth + 1
if hasattr(self.child, '_propagate_depth_to_child'):
self.child._propagate_depth_to_child()

def _check_data_depth(self, data, current_level):
items = data.values() if isinstance(data, dict) else data
for item in items:
if isinstance(item, (list, tuple, dict)):
next_level = current_level + 1
if next_level > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
self._check_data_depth(item, next_level)

def get_value(self, dictionary):
# We override the default field access in order to support
# dictionaries in HTML forms.
Expand All @@ -1762,7 +1822,12 @@ def to_internal_value(self, data):
self.fail('not_a_dict', input_type=type(data).__name__)
if not self.allow_empty and len(data) == 0:
self.fail('empty')

if self._root_max_depth is not None:
start_level = self._current_depth if self._current_depth > 0 else 1
if start_level > self._root_max_depth:
self.fail('max_depth', max_depth=self._root_max_depth)
if self.max_depth is not None:
self._check_data_depth(data, start_level)
return self.run_child_validation(data)

def to_representation(self, value):
Expand Down
12 changes: 11 additions & 1 deletion rest_framework/locale/en/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
#
#
# Translators:
msgid ""
msgstr ""
Expand Down Expand Up @@ -357,6 +357,11 @@ msgstr "Ensure this field has at least {min_length} elements."
msgid "Ensure this field has no more than {max_length} elements."
msgstr "Ensure this field has no more than {max_length} elements."

#: fields.py:1654
#, python-brace-format
msgid "List nesting depth exceeds maximum allowed depth of {max_depth}."
msgstr "List nesting depth exceeds maximum allowed depth of {max_depth}."

#: fields.py:1682
#, python-brace-format
msgid "Expected a dictionary of items but got type \"{input_type}\"."
Expand All @@ -366,6 +371,11 @@ msgstr "Expected a dictionary of items but got type \"{input_type}\"."
msgid "This dictionary may not be empty."
msgstr "This dictionary may not be empty."

#: fields.py:1733
#, python-brace-format
msgid "Dictionary nesting depth exceeds maximum allowed depth of {max_depth}."
msgstr "Dictionary nesting depth exceeds maximum allowed depth of {max_depth}."

#: fields.py:1755
msgid "Value must be valid JSON."
msgstr "Value must be valid JSON."
Expand Down
87 changes: 85 additions & 2 deletions rest_framework/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@
'read_only', 'write_only', 'required', 'default', 'initial', 'source',
'label', 'help_text', 'style', 'error_messages', 'allow_empty',
'instance', 'data', 'partial', 'context', 'allow_null',
'max_length', 'min_length'
'max_length', 'min_length', 'max_depth'
)
LIST_SERIALIZER_KWARGS_REMOVE = ('allow_empty', 'min_length', 'max_length')
LIST_SERIALIZER_KWARGS_REMOVE = ('allow_empty', 'min_length', 'max_length', 'max_depth')

ALL_FIELDS = '__all__'

Expand Down Expand Up @@ -111,14 +111,62 @@ class BaseSerializer(Field):
.data - Available.
"""

default_error_messages = {
'max_depth': _('Nesting depth exceeds maximum allowed depth of {max_depth}.')
}

def __init__(self, instance=None, data=empty, **kwargs):
self.instance = instance
if data is not empty:
self.initial_data = data
self.partial = kwargs.pop('partial', False)
self._context = kwargs.pop('context', {})
kwargs.pop('many', None)
self.max_depth = kwargs.pop('max_depth', None)
super().__init__(**kwargs)
self._current_depth = 0
self._root_max_depth = self.max_depth

def bind(self, field_name, parent):
super().bind(field_name, parent)
if self.max_depth is None and hasattr(parent, '_root_max_depth') and parent._root_max_depth is not None:
self._root_max_depth = parent._root_max_depth
self._current_depth = parent._current_depth + 1

def _propagate_depth_to_child(self):
if self._root_max_depth is not None and 'fields' in self.__dict__:
for field in self.__dict__['fields'].values():
if hasattr(field, '_root_max_depth'):
field._root_max_depth = self._root_max_depth
field._current_depth = self._current_depth + 1
if hasattr(field, '_propagate_depth_to_child'):
field._propagate_depth_to_child()

def _check_data_depth(self, data, current_level):
if isinstance(data, dict):
for value in data.values():
if isinstance(value, (list, tuple, dict)):
next_level = current_level + 1
if next_level > self._root_max_depth:
message = self.error_messages['max_depth'].format(
max_depth=self._root_max_depth
)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='max_depth')
self._check_data_depth(value, next_level)
elif isinstance(data, (list, tuple)):
for item in data:
if isinstance(item, (list, tuple, dict)):
next_level = current_level + 1
if next_level > self._root_max_depth:
message = self.error_messages['max_depth'].format(
max_depth=self._root_max_depth
)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='max_depth')
self._check_data_depth(item, next_level)

def __new__(cls, *args, **kwargs):
# We override this method in order to automatically create
Expand Down Expand Up @@ -373,6 +421,8 @@ def fields(self):
fields = BindingDict(self)
for key, value in self.get_fields().items():
fields[key] = value
if self._root_max_depth is not None:
self._propagate_depth_to_child()
return fields

@property
Expand Down Expand Up @@ -489,6 +539,9 @@ def to_internal_value(self, data):
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='invalid')
if self._root_max_depth is not None and self.max_depth is not None:
start_level = self._current_depth
self._check_data_depth(data, start_level)

ret = {}
errors = {}
Expand Down Expand Up @@ -654,6 +707,32 @@ def run_child_validation(self, data):
"""
return self.child.run_validation(data)

def _check_data_depth(self, data, current_level):
if isinstance(data, (list, tuple)):
for item in data:
if isinstance(item, (list, tuple, dict)):
next_level = current_level + 1
if next_level > self._root_max_depth:
message = self.error_messages['max_depth'].format(
max_depth=self._root_max_depth
)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='max_depth')
self._check_data_depth(item, next_level)
elif isinstance(data, dict):
for value in data.values():
if isinstance(value, (list, tuple, dict)):
next_level = current_level + 1
if next_level > self._root_max_depth:
message = self.error_messages['max_depth'].format(
max_depth=self._root_max_depth
)
raise ValidationError({
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='max_depth')
self._check_data_depth(value, next_level)

def to_internal_value(self, data):
"""
List of dicts of native values <- List of dicts of primitive datatypes.
Expand All @@ -669,6 +748,10 @@ def to_internal_value(self, data):
api_settings.NON_FIELD_ERRORS_KEY: [message]
}, code='not_a_list')

if self._root_max_depth is not None and self.max_depth is not None:
start_level = self._current_depth
self._check_data_depth(data, start_level)

if not self.allow_empty and len(data) == 0:
message = self.error_messages['empty']
raise ValidationError({
Expand Down
Loading
Loading