Information disclosure / Access control bypass via improper fetch-mode propagation

MEDIUM
django/django
Commit: 6dc9b0401803
Affected: 5.1.x (before this commit)
2026-04-05 13:15 UTC

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]
← Back to Alerts View on GitHub →