Kaynağa Gözat

Closes #22060: Introduce a config parameter to enforce GraphQL maximum query depth (#22162)

Jeremy Stretch 1 hafta önce
ebeveyn
işleme
2e9c3119ce

+ 4 - 0
docs/best-practices/performance-handbook.md

@@ -40,6 +40,10 @@ NetBox paginates large result sets to reduce the overall response size. The [`MA
 
 
 By default, NetBox restricts a GraphQL query to 10 aliases. Consider reducing this number by setting [`GRAPHQL_MAX_ALIASES`](../configuration/graphql-api.md#graphql_max_aliases) to a lower value.
 By default, NetBox restricts a GraphQL query to 10 aliases. Consider reducing this number by setting [`GRAPHQL_MAX_ALIASES`](../configuration/graphql-api.md#graphql_max_aliases) to a lower value.
 
 
+#### Limit GraphQL Query Depth
+
+Deeply nested GraphQL queries can impose substantial overhead, consuming undue server resources and increasing response times. Consider setting [`GRAPHQL_MAX_QUERY_DEPTH`](../configuration/graphql-api.md#graphql_max_query_depth) to limit the maximum nesting depth for any GraphQL query.
+
 #### Designate Isolated Deployments
 #### Designate Isolated Deployments
 
 
 If your NetBox installation does not have Internet access, set [`ISOLATED_DEPLOYMENT`](../configuration/system.md#isolated_deployment) to True. This will prevent the application from attempting routine external requests.
 If your NetBox installation does not have Internet access, set [`ISOLATED_DEPLOYMENT`](../configuration/system.md#isolated_deployment) to True. This will prevent the application from attempting routine external requests.

+ 10 - 0
docs/configuration/graphql-api.md

@@ -25,3 +25,13 @@ Setting this to `False` will disable the GraphQL API.
 Default: `10`
 Default: `10`
 
 
 The maximum number of queries that a GraphQL API request may contain.
 The maximum number of queries that a GraphQL API request may contain.
+
+---
+
+## GRAPHQL_MAX_QUERY_DEPTH
+
+!!! note "This parameter was introduced in NetBox v4.6.1."
+
+Default: `None` (no limit)
+
+The maximum allowed depth of any GraphQL query. When set to a positive integer, requests containing queries that exceed this depth will be rejected. Leaving this parameter unset (or setting it to `None` or `0`) disables query depth enforcement.

+ 13 - 5
netbox/netbox/graphql/schema.py

@@ -1,6 +1,6 @@
 import strawberry
 import strawberry
 from django.conf import settings
 from django.conf import settings
-from strawberry.extensions import MaxAliasesLimiter
+from strawberry.extensions import MaxAliasesLimiter, QueryDepthLimiter, SchemaExtension
 from strawberry.schema.config import StrawberryConfig
 from strawberry.schema.config import StrawberryConfig
 from strawberry_django.optimizer import DjangoOptimizerExtension
 from strawberry_django.optimizer import DjangoOptimizerExtension
 
 
@@ -36,6 +36,17 @@ class Query(
     pass
     pass
 
 
 
 
+def get_schema_extensions() -> list[SchemaExtension]:
+    extensions: list[SchemaExtension] = [
+        DjangoOptimizerExtension(prefetch_custom_queryset=True),
+        MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES),
+    ]
+    max_depth = settings.GRAPHQL_MAX_QUERY_DEPTH
+    if max_depth and max_depth > 0:
+        extensions.append(QueryDepthLimiter(max_depth=max_depth))
+    return extensions
+
+
 schema = strawberry.Schema(
 schema = strawberry.Schema(
     query=Query,
     query=Query,
     config=StrawberryConfig(
     config=StrawberryConfig(
@@ -44,8 +55,5 @@ schema = strawberry.Schema(
             BigInt: BigIntScalar,
             BigInt: BigIntScalar,
         },
         },
     ),
     ),
-    extensions=[
-        DjangoOptimizerExtension(prefetch_custom_queryset=True),
-        MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES),
-    ],
+    extensions=get_schema_extensions(),
 )
 )

+ 1 - 0
netbox/netbox/settings.py

@@ -130,6 +130,7 @@ FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
 FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
 FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440)
 GRAPHQL_DEFAULT_VERSION = getattr(configuration, 'GRAPHQL_DEFAULT_VERSION', 1)
 GRAPHQL_DEFAULT_VERSION = getattr(configuration, 'GRAPHQL_DEFAULT_VERSION', 1)
 GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
 GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10)
+GRAPHQL_MAX_QUERY_DEPTH = getattr(configuration, 'GRAPHQL_MAX_QUERY_DEPTH', None)
 HOSTNAME = getattr(configuration, 'HOSTNAME', platform.node())
 HOSTNAME = getattr(configuration, 'HOSTNAME', platform.node())
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
 INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))

+ 44 - 0
netbox/netbox/tests/test_graphql.py

@@ -1,11 +1,16 @@
 import json
 import json
 
 
+import strawberry
 from django.test import override_settings
 from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
+from strawberry.extensions import QueryDepthLimiter
+from strawberry.schema.config import StrawberryConfig
 
 
 from dcim.choices import LocationStatusChoices
 from dcim.choices import LocationStatusChoices
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
 from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Site, VirtualChassis
+from netbox.graphql.scalars import BigInt, BigIntScalar
+from netbox.graphql.schema import Query, get_schema_extensions
 from utilities.testing import APITestCase, TestCase, disable_warnings
 from utilities.testing import APITestCase, TestCase, disable_warnings
 
 
 
 
@@ -20,6 +25,45 @@ class GraphQLTestCase(TestCase):
         response = self.client.get(url)
         response = self.client.get(url)
         self.assertHttpStatus(response, 404)
         self.assertHttpStatus(response, 404)
 
 
+    def test_graphql_max_query_depth_disabled_by_default(self):
+        """
+        QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is unset.
+        """
+        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in get_schema_extensions()))
+
+    @override_settings(GRAPHQL_MAX_QUERY_DEPTH=0)
+    def test_graphql_max_query_depth_disabled_when_zero(self):
+        """
+        QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is zero.
+        """
+        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in get_schema_extensions()))
+
+    @override_settings(GRAPHQL_MAX_QUERY_DEPTH=-1)
+    def test_graphql_max_query_depth_disabled_when_negative(self):
+        """
+        QueryDepthLimiter should not be installed when GRAPHQL_MAX_QUERY_DEPTH is negative.
+        """
+        self.assertFalse(any(isinstance(ext, QueryDepthLimiter) for ext in get_schema_extensions()))
+
+    @override_settings(GRAPHQL_MAX_QUERY_DEPTH=3)
+    def test_graphql_max_query_depth_enforced(self):
+        """
+        Queries exceeding GRAPHQL_MAX_QUERY_DEPTH should be rejected.
+        """
+        extensions = get_schema_extensions()
+        self.assertTrue(any(isinstance(ext, QueryDepthLimiter) for ext in extensions))
+
+        # Build a temporary schema with the configured extensions and execute a deep query
+        test_schema = strawberry.Schema(
+            query=Query,
+            config=StrawberryConfig(auto_camel_case=False, scalar_map={BigInt: BigIntScalar}),
+            extensions=extensions,
+        )
+        deep_query = '{ site_list { tenant { group { parent { parent { parent { name } } } } } } }'
+        result = test_schema.execute_sync(deep_query)
+        self.assertIsNotNone(result.errors)
+        self.assertIn('exceeds maximum operation depth', str(result.errors[0]))
+
     @override_settings(LOGIN_REQUIRED=True)
     @override_settings(LOGIN_REQUIRED=True)
     def test_graphiql_interface(self):
     def test_graphiql_interface(self):
         """
         """