Selaa lähdekoodia

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

Jeremy Stretch 1 viikko sitten
vanhempi
commit
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.
 
+#### 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
 
 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`
 
 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
 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_django.optimizer import DjangoOptimizerExtension
 
@@ -36,6 +36,17 @@ class Query(
     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(
     query=Query,
     config=StrawberryConfig(
@@ -44,8 +55,5 @@ schema = strawberry.Schema(
             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)
 GRAPHQL_DEFAULT_VERSION = getattr(configuration, 'GRAPHQL_DEFAULT_VERSION', 1)
 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())
 HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {})
 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 strawberry
 from django.test import override_settings
 from django.urls import reverse
 from rest_framework import status
+from strawberry.extensions import QueryDepthLimiter
+from strawberry.schema.config import StrawberryConfig
 
 from dcim.choices import LocationStatusChoices
 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
 
 
@@ -20,6 +25,45 @@ class GraphQLTestCase(TestCase):
         response = self.client.get(url)
         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)
     def test_graphiql_interface(self):
         """