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

This commit is contained in:
Jeremy Stretch 2026-05-11 13:54:07 -04:00 committed by GitHub
parent 3e4ad4a5da
commit 2e9c3119ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 72 additions and 5 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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(),
)

View File

@ -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'))

View File

@ -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):
"""