diff --git a/docs/best-practices/performance-handbook.md b/docs/best-practices/performance-handbook.md index 9703a08cd..f4e7b8af5 100644 --- a/docs/best-practices/performance-handbook.md +++ b/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. diff --git a/docs/configuration/graphql-api.md b/docs/configuration/graphql-api.md index e58bb0576..3fed0482b 100644 --- a/docs/configuration/graphql-api.md +++ b/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. diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 5fb27bf4e..1cce4a7dc 100644 --- a/netbox/netbox/graphql/schema.py +++ b/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(), ) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 33631f943..ddfe558ea 100644 --- a/netbox/netbox/settings.py +++ b/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')) diff --git a/netbox/netbox/tests/test_graphql.py b/netbox/netbox/tests/test_graphql.py index 3914a9493..573578e7a 100644 --- a/netbox/netbox/tests/test_graphql.py +++ b/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): """