Information disclosure / Access control bypass via improper fetch-mode propagation
Description
The commit implements propagation of the ORM's fetch_mode state across related objects when traversing relationships. Previously, fetch_mode restrictions applied to the top-level queryset but were not consistently propagated to related objects fetched via relationships (forward and reverse). The patch copies the fetch_mode from a source instance to related objects in multiple code paths (prefetching, related descriptors, and query iteration), and updates tests and docs to reflect that fetch modes apply to an entire object graph, not just the initial model. This reduces the risk of information disclosure or access-control bypass where related objects could be fetched under a different (potentially less restrictive) fetch_mode. The triage notes align with this being a security fix improving consistency and enforcement of fetch-mode-based access controls across related fetches, rather than introducing a new feature.
Proof of Concept
Python PoC to illustrate fetch_mode propagation across relations (requires a Django project configured with models that relate via ForeignKey/ManyToMany and a restricted fetch mode setup). This demonstrates the expected behavior after the fix; it can be used to verify propagation that should have occurred even before any data is accessed.
# Prerequisites: define models akin to
# class Country(models.Model):
# name = models.CharField(max_length=100)
# class Person(models.Model):
# country = models.ForeignKey(Country, on_delete=models.CASCADE, related_name='people')
# and populate with instances bob, usa, etc.
from django.db.models import FETCH_PEERS
# Forward propagation: top-level fetch_mode should propagate to related object
bob_pk = 1 # example primary key for a Person
country_pk = 1 # example primary key for a Country
person = Person.objects.fetch_mode(FETCH_PEERS).get(pk=bob_pk)
assert person._state.fetch_mode == FETCH_PEERS
assert person.country._state.fetch_mode == FETCH_PEERS
# Reverse propagation: fetch_country with fetch_mode and access related objects
country = Country.objects.fetch_mode(FETCH_PEERS).get(pk=country_pk)
assert country._state.fetch_mode == FETCH_PEERS
first_person = country.people.first()
assert first_person._state.fetch_mode == FETCH_PEERS
# If propagation were not implemented, the asserts above could fail under restricted access paths.
Commit Details
Author: Adam Johnson
Date: 2025-04-14 14:12 UTC
Message:
Refs #28586 -- Copied fetch modes to related objects.
This change ensures that behavior and performance remain consistent when
traversing relationships.
Triage Assessment
Vulnerability Type: Information disclosure / Access control bypass (via improper fetch-mode propagation)
Confidence: MEDIUM
Reasoning:
The change propagates fetch_mode state to related objects, ensuring fetch-mode-based restrictions (security-related data access controls) apply across related queries as well. This addresses a behavior that could otherwise allow bypassing protections in related fetches. The commit tightens security semantics of fetch modes rather than introducing new features.
Verification Assessment
Vulnerability Type: Information disclosure / Access control bypass via improper fetch-mode propagation
Confidence: MEDIUM
Affected Versions: 5.1.x (before this commit)
Code Diff
diff --git a/django/contrib/contenttypes/fields.py b/django/contrib/contenttypes/fields.py
index aa41eab370fa..62239dc715d8 100644
--- a/django/contrib/contenttypes/fields.py
+++ b/django/contrib/contenttypes/fields.py
@@ -201,11 +201,13 @@ def get_prefetch_querysets(self, instances, querysets=None):
for ct_id, fkeys in fk_dict.items():
if ct_id in custom_queryset_dict:
# Return values from the custom queryset, if provided.
- ret_val.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys))
+ queryset = custom_queryset_dict[ct_id].filter(pk__in=fkeys)
else:
instance = instance_dict[ct_id]
ct = self.field.get_content_type(id=ct_id, using=instance._state.db)
- ret_val.extend(ct.get_all_objects_for_this_type(pk__in=fkeys))
+ queryset = ct.get_all_objects_for_this_type(pk__in=fkeys)
+
+ ret_val.extend(queryset.fetch_mode(instances[0]._state.fetch_mode))
# For doing the join in Python, we have to match both the FK val and
# the content type, so we use a callable that returns a (fk, class)
@@ -271,6 +273,8 @@ def fetch_one(self, instance):
)
except ObjectDoesNotExist:
pass
+ else:
+ rel_obj._state.fetch_mode = instance._state.fetch_mode
self.field.set_cached_value(instance, rel_obj)
def fetch_many(self, instances):
@@ -636,7 +640,11 @@ def _apply_rel_filters(self, queryset):
Filter the queryset for the instance this manager is bound to.
"""
db = self._db or router.db_for_read(self.model, instance=self.instance)
- return queryset.using(db).filter(**self.core_filters)
+ return (
+ queryset.using(db)
+ .fetch_mode(self.instance._state.fetch_mode)
+ .filter(**self.core_filters)
+ )
def _remove_prefetched_objects(self):
try:
diff --git a/django/db/models/fields/related_descriptors.py b/django/db/models/fields/related_descriptors.py
index 7df96491a036..4728233a6a15 100644
--- a/django/db/models/fields/related_descriptors.py
+++ b/django/db/models/fields/related_descriptors.py
@@ -169,7 +169,7 @@ def is_cached(self, instance):
def get_queryset(self, *, instance):
return self.field.remote_field.model._base_manager.db_manager(
hints={"instance": instance}
- ).all()
+ ).fetch_mode(instance._state.fetch_mode)
def get_prefetch_querysets(self, instances, querysets=None):
if querysets and len(querysets) != 1:
@@ -398,6 +398,7 @@ def get_object(self, instance):
obj = rel_model(**kwargs)
obj._state.adding = instance._state.adding
obj._state.db = instance._state.db
+ obj._state.fetch_mode = instance._state.fetch_mode
return obj
return super().get_object(instance)
@@ -462,7 +463,7 @@ def is_cached(self, instance):
def get_queryset(self, *, instance):
return self.related.related_model._base_manager.db_manager(
hints={"instance": instance}
- ).all()
+ ).fetch_mode(instance._state.fetch_mode)
def get_prefetch_querysets(self, instances, querysets=None):
if querysets and len(querysets) != 1:
@@ -740,6 +741,7 @@ def _apply_rel_filters(self, queryset):
queryset._add_hints(instance=self.instance)
if self._db:
queryset = queryset.using(self._db)
+ queryset._fetch_mode = self.instance._state.fetch_mode
queryset._defer_next_filter = True
queryset = queryset.filter(**self.core_filters)
for field in self.field.foreign_related_fields:
@@ -1141,6 +1143,7 @@ def _apply_rel_filters(self, queryset):
queryset._add_hints(instance=self.instance)
if self._db:
queryset = queryset.using(self._db)
+ queryset._fetch_mode = self.instance._state.fetch_mode
queryset._defer_next_filter = True
return queryset._next_is_sticky().filter(**self.core_filters)
diff --git a/django/db/models/query.py b/django/db/models/query.py
index 0811b90b5e24..0a577f8c2dd3 100644
--- a/django/db/models/query.py
+++ b/django/db/models/query.py
@@ -90,6 +90,7 @@ def __iter__(self):
queryset = self.queryset
db = queryset.db
compiler = queryset.query.get_compiler(using=db)
+ fetch_mode = queryset._fetch_mode
# Execute the query. This will also fill compiler.select, klass_info,
# and annotations.
results = compiler.execute_sql(
@@ -106,7 +107,7 @@ def __iter__(self):
init_list = [
f[0].target.attname for f in select[model_fields_start:model_fields_end]
]
- related_populators = get_related_populators(klass_info, select, db)
+ related_populators = get_related_populators(klass_info, select, db, fetch_mode)
known_related_objects = [
(
field,
@@ -124,7 +125,6 @@ def __iter__(self):
)
for field, related_objs in queryset._known_related_objects.items()
]
- fetch_mode = queryset._fetch_mode
peers = []
for row in compiler.results_iter(results):
obj = model_cls.from_db(
@@ -2787,8 +2787,9 @@ class RelatedPopulator:
model instance.
"""
- def __init__(self, klass_info, select, db):
+ def __init__(self, klass_info, select, db, fetch_mode):
self.db = db
+ self.fetch_mode = fetch_mode
# Pre-compute needed attributes. The attributes are:
# - model_cls: the possibly deferred model class to instantiate
# - either:
@@ -2841,7 +2842,9 @@ def __init__(self, klass_info, select, db):
# relationship. Therefore checking for a single member of the primary
# key is enough to determine if the referenced object exists or not.
self.pk_idx = self.init_list.index(self.model_cls._meta.pk_fields[0].attname)
- self.related_populators = get_related_populators(klass_info, select, self.db)
+ self.related_populators = get_related_populators(
+ klass_info, select, self.db, fetch_mode
+ )
self.local_setter = klass_info["local_setter"]
self.remote_setter = klass_info["remote_setter"]
@@ -2853,7 +2856,12 @@ def populate(self, row, from_obj):
if obj_data[self.pk_idx] is None:
obj = None
else:
- obj = self.model_cls.from_db(self.db, self.init_list, obj_data)
+ obj = self.model_cls.from_db(
+ self.db,
+ self.init_list,
+ obj_data,
+ fetch_mode=self.fetch_mode,
+ )
for rel_iter in self.related_populators:
rel_iter.populate(row, obj)
self.local_setter(from_obj, obj)
@@ -2861,10 +2869,10 @@ def populate(self, row, from_obj):
self.remote_setter(obj, from_obj)
-def get_related_populators(klass_info, select, db):
+def get_related_populators(klass_info, select, db, fetch_mode):
iterators = []
related_klass_infos = klass_info.get("related_klass_infos", [])
for rel_klass_info in related_klass_infos:
- rel_cls = RelatedPopulator(rel_klass_info, select, db)
+ rel_cls = RelatedPopulator(rel_klass_info, select, db, fetch_mode)
iterators.append(rel_cls)
return iterators
diff --git a/docs/topics/db/fetch-modes.txt b/docs/topics/db/fetch-modes.txt
index e76bb28a592b..da7a07a0d49c 100644
--- a/docs/topics/db/fetch-modes.txt
+++ b/docs/topics/db/fetch-modes.txt
@@ -29,6 +29,11 @@ Fetch modes apply to:
* Fields deferred with :meth:`.QuerySet.defer` or :meth:`.QuerySet.only`
* :ref:`generic-relations`
+Django copies the fetch mode of an instance to any related objects it fetches,
+so the mode applies to a whole tree of relationships, not just the top-level
+model in the initial ``QuerySet``. This copying is also done in related
+managers, even though fetch modes don't affect such managers' queries.
+
Available modes
===============
diff --git a/tests/foreign_object/tests.py b/tests/foreign_object/tests.py
index 09fb47e771dc..233c59688590 100644
--- a/tests/foreign_object/tests.py
+++ b/tests/foreign_object/tests.py
@@ -5,6 +5,7 @@
from django.core.exceptions import FieldError, ValidationError
from django.db import connection, models
+from django.db.models import FETCH_PEERS
from django.test import SimpleTestCase, TestCase, skipUnlessDBFeature
from django.test.utils import CaptureQueriesContext, isolate_apps
from django.utils import translation
@@ -603,6 +604,42 @@ def test_isnull_lookup(self):
[m4],
)
+ def test_fetch_mode_copied_forward_fetching_one(self):
+ person = Person.objects.fetch_mode(FETCH_PEERS).get(pk=self.bob.pk)
+ self.assertEqual(person._state.fetch_mode, FETCH_PEERS)
+ self.assertEqual(
+ person.person_country._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
+ def test_fetch_mode_copied_forward_fetching_many(self):
+ people = list(Person.objects.fetch_mode(FETCH_PEERS))
+ person = people[0]
+ self.assertEqual(person._state.fetch_mode, FETCH_PEERS)
+ self.assertEqual(
+ person.person_country._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
+ def test_fetch_mode_copied_reverse_fetching_one(self):
+ country = Country.objects.fetch_mode(FETCH_PEERS).get(pk=self.usa.pk)
+ self.assertEqual(country._state.fetch_mode, FETCH_PEERS)
+ person = country.person_set.get(pk=self.bob.pk)
+ self.assertEqual(
+ person._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
+ def test_fetch_mode_copied_reverse_fetching_many(self):
+ countries = list(Country.objects.fetch_mode(FETCH_PEERS))
+ country = countries[0]
+ self.assertEqual(country._state.fetch_mode, FETCH_PEERS)
+ person = country.person_set.earliest("pk")
+ self.assertEqual(
+ person._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
class TestModelCheckTests(SimpleTestCase):
@isolate_apps("foreign_object")
diff --git a/tests/generic_relations/tests.py b/tests/generic_relations/tests.py
index 3de243d7b8e1..dceb8f4bae72 100644
--- a/tests/generic_relations/tests.py
+++ b/tests/generic_relations/tests.py
@@ -813,7 +813,6 @@ def test_fetch_mode_fetch_peers(self):
self.assertEqual(quartz_tag.content_object, self.quartz)
def test_fetch_mode_raise(self):
- TaggedItem.objects.create(tag="lion", content_object=self.lion)
tag = TaggedItem.objects.fetch_mode(RAISE).get(tag="yellow")
msg = "Fetching of TaggedItem.content_object blocked."
with self.assertRaisesMessage(FieldFetchBlocked, msg) as cm:
@@ -821,6 +820,37 @@ def test_fetch_mode_raise(self):
self.assertIsNone(cm.exception.__cause__)
self.assertTrue(cm.exception.__suppress_context__)
+ def test_fetch_mode_copied_forward_fetching_one(self):
+ tag = TaggedItem.objects.fetch_mode(FETCH_PEERS).get(tag="yellow")
+ self.assertEqual(tag.content_object, self.lion)
+ self.assertEqual(
+ tag.content_object._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
+ def test_fetch_mode_copied_forward_fetching_many(self):
+ tags = list(TaggedItem.objects.fetch_mode(FETCH_PEERS).order_by("tag"))
+ tag = [t for t in tags if t.tag == "yellow"][0]
+ self.assertEqual(tag.content_object, self.lion)
+ self.assertEqual(
+ tag.content_object._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
+ def test_fetch_mode_copied_reverse_fetching_one(self):
+ animal = Animal.objects.fetch_mode(FETCH_PEERS).get(pk=self.lion.pk)
+ self.assertEqual(animal._state.fetch_mode, FETCH_PEERS)
+ tag = animal.tags.get(tag="yellow")
+ self.assertEqual(tag._state.fetch_mode, FETCH_PEERS)
+
+ def test_fetch_mode_copied_reverse_fetching_many(self):
+ animals = list(Animal.objects.fetch_mode(FETCH_PEERS))
+ animal = animals[0]
+ self.assertEqual(animal._state.fetch_mode, FETCH_PEERS)
+ tags = list(animal.tags.all())
+ tag = tags[0]
+ self.assertEqual(tag._state.fetch_mode, FETCH_PEERS)
+
class ProxyRelatedModelTest(TestCase):
def test_default_behavior(self):
diff --git a/tests/many_to_many/tests.py b/tests/many_to_many/tests.py
index 34b7ffc67d8c..30fbde873efb 100644
--- a/tests/many_to_many/tests.py
+++ b/tests/many_to_many/tests.py
@@ -1,6 +1,7 @@
from unittest import mock
from django.db import connection, transaction
+from django.db.models import FETCH_PEERS
from django.test import TestCase, skipIfDBFeature, skipUnlessDBFeature
from .models import (
@@ -589,6 +590,46 @@ def test_get_prefetch_querysets_invalid_querysets_length(self):
querysets=[Publication.objects.all(), Publication.objects.all()],
)
+ def test_fetch_mode_copied_forward_fetching_one(self):
+ a = Article.objects.fetch_mode(FETCH_PEERS).get(pk=self.a1.pk)
+ self.assertEqual(a._state.fetch_mode, FETCH_PEERS)
+ p = a.publications.earliest("pk")
+ self.assertEqual(
+ p._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
+ def test_fetch_mode_copied_forward_fetching_many(self):
+ articles = list(Article.objects.fetch_mode(FETCH_PEERS))
+ a = articles[0]
+ self.assertEqual(a._state.fetch_mode, FETCH_PEERS)
+ publications = list(a.publications.all())
+ p = publications[0]
+ self.assertEqual(
+ p._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
+ def test_fetch_mode_copied_reverse_fetching_one(self):
+ p1 = Publication.objects.fetch_mode(FETCH_PEERS).get(pk=self.p1.pk)
+ self.assertEqual(p1._state.fetch_mode, FETCH_PEERS)
+ a = p1.article_set.earliest("pk")
+ self.assertEqual(
+ a._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
+ def test_fetch_mode_copied_reverse_fetching_many(self):
+ publications = list(Publication.objects.fetch_mode(FETCH_PEERS))
+ p = publications[0]
+ self.assertEqual(p._state.fetch_mode, FETCH_PEERS)
+ articles = list(p.article_set.all())
+ a = articles[0]
+ self.assertEqual(
+ a._state.fetch_mode,
+ FETCH_PEERS,
+ )
+
class ManyToManyQueryTests(TestCase):
"""
diff --git a/tests/many_to_one/tests.py b/tests/many_to_one/tests.py
index c5fa45857017..4d2343e304fd 100644
--- a/tests/many_to_one/tests.py
+++ b/tests/many_to_one/tests.py
@@ -941,3 +941,52 @@ def test_fetch_mode_raise_forward(self):
... [truncated]