diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 0466bd980..8f0c9e231 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -1,4 +1,3 @@ -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.backends.postgresql.psycopg_any import NumericRange from django.utils.translation import gettext as _ @@ -112,7 +111,8 @@ class ContentTypeField(RelatedField): def to_internal_value(self, data): try: app_label, model = data.split('.') - return ContentType.objects.get_by_natural_key(app_label=app_label, model=model) + # Scoped to the field's declared queryset, not the raw ContentType table (#22748). + return self.get_queryset().get(app_label=app_label, model=model) except ObjectDoesNotExist: self.fail('does_not_exist', content_type=data) except (AttributeError, TypeError, ValueError): diff --git a/netbox/netbox/tests/test_api.py b/netbox/netbox/tests/test_api.py index 06f6b273b..0375e2bd7 100644 --- a/netbox/netbox/tests/test_api.py +++ b/netbox/netbox/tests/test_api.py @@ -1,5 +1,6 @@ import uuid +from django.contrib.contenttypes.models import ContentType from django.db.backends.postgresql.psycopg_any import NumericRange from django.test import RequestFactory, TestCase from django.urls import reverse @@ -7,8 +8,9 @@ from rest_framework.exceptions import ValidationError from rest_framework.request import Request from dcim.api.serializers import RackSerializer +from dcim.models import Device, Site from netbox.api.exceptions import QuerySetNotOrdered -from netbox.api.fields import IntegerRangeSerializer, RelatedObjectCountField +from netbox.api.fields import ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField from netbox.api.pagination import NetBoxPagination from users.models import Token from utilities.testing import APITestCase @@ -178,3 +180,60 @@ class IntegerRangeSerializerTestCase(TestCase): serializer.to_internal_value(['100', '200']) with self.assertRaises(ValidationError): serializer.to_internal_value([100.5, 200.5]) + + +class ContentTypeFieldTestCase(TestCase): + + def test_to_internal_value_resolves_content_type_within_queryset(self): + """A content type present in the field's declared queryset resolves successfully.""" + site_ct = ContentType.objects.get_for_model(Site) + field = ContentTypeField(queryset=ContentType.objects.filter(pk=site_ct.pk)) + self.assertEqual(field.to_internal_value('dcim.site'), site_ct) + + def test_to_internal_value_rejects_content_type_outside_queryset(self): + """ + Regression test for #22748: ContentTypeField.to_internal_value() previously resolved + against the raw, unfiltered ContentType table via get_by_natural_key(), ignoring the + field's own declared queryset entirely. A content type that is real and resolvable in + general, but falls outside the specific queryset a given field declares, must be + rejected rather than silently accepted. + """ + site_ct = ContentType.objects.get_for_model(Site) + device_ct = ContentType.objects.get_for_model(Device) + + # Scope the field to a single, unrelated content type so `dcim.site` is guaranteed to + # fall outside it. + field = ContentTypeField(queryset=ContentType.objects.filter(pk=device_ct.pk)) + with self.assertRaises(ValidationError): + field.to_internal_value('dcim.site') + + # Sanity check: the rejected content type is a genuine, generally-resolvable content + # type, so the rejection above is attributable to queryset scoping and not a bogus value. + self.assertTrue(ContentType.objects.filter(pk=site_ct.pk).exists()) + + def test_to_internal_value_rejects_malformed_input(self): + """Input must be exactly '.'.""" + field = ContentTypeField(queryset=ContentType.objects.all()) + with self.assertRaises(ValidationError): + field.to_internal_value('not-a-valid-format') + with self.assertRaises(ValidationError): + field.to_internal_value('too.many.dots') + + def test_to_internal_value_rejects_nonexistent_content_type(self): + """A syntactically valid but nonexistent content type must be rejected.""" + field = ContentTypeField(queryset=ContentType.objects.all()) + with self.assertRaises(ValidationError): + field.to_internal_value('nonexistent_app.nonexistent_model') + + def test_to_internal_value_many_rejects_content_type_outside_queryset(self): + """ + many=True wraps the field in a ManyRelatedField, which delegates per-item validation to + the child field's to_internal_value() unconditionally; the same queryset scoping must + hold there too. + """ + device_ct = ContentType.objects.get_for_model(Device) + field = ContentTypeField(queryset=ContentType.objects.filter(pk=device_ct.pk), many=True) + + self.assertEqual(field.to_internal_value(['dcim.device']), [device_ct]) + with self.assertRaises(ValidationError): + field.to_internal_value(['dcim.device', 'dcim.site'])