diff --git a/src/Core/Models/GraphQLFilterParsers.cs b/src/Core/Models/GraphQLFilterParsers.cs
index 6a97de9f04..deb9b91c9d 100644
--- a/src/Core/Models/GraphQLFilterParsers.cs
+++ b/src/Core/Models/GraphQLFilterParsers.cs
@@ -39,6 +39,40 @@ public GQLFilterParser(RuntimeConfigProvider runtimeConfigProvider, IMetadataPro
_metadataProviderFactory = metadataProviderFactory;
}
+ // Absolute ceiling on relationship-nesting depth within a single GraphQL filter argument.
+ // Applied when no stricter runtime.graphql.depth-limit is configured, to prevent nested-filter depth-bomb DoS
+ // (deeply nested filters amplify into correlated EXISTS subqueries that HotChocolate's execution-depth rule does not bound).
+ internal const int MAX_NESTED_FILTER_DEPTH = 20;
+
+ ///
+ /// Returns the maximum allowed relationship-nesting depth for GraphQL filters:
+ /// the configured runtime.graphql.depth-limit when set and stricter, otherwise the hardcoded safety ceiling.
+ ///
+ internal int GetMaxNestedFilterDepth()
+ {
+ int? configuredDepthLimit = _configProvider.GetConfig().Runtime?.GraphQL?.DepthLimit;
+ if (configuredDepthLimit is > 0 && configuredDepthLimit.Value < MAX_NESTED_FILTER_DEPTH)
+ {
+ return configuredDepthLimit.Value;
+ }
+
+ return MAX_NESTED_FILTER_DEPTH;
+ }
+
+ ///
+ /// Enforces the relationship-nesting depth limit for GraphQL filters, throwing a BadRequest when exceeded.
+ ///
+ internal static void EnsureWithinNestedFilterDepth(int nestingLevel, int maxNestedFilterDepth)
+ {
+ if (nestingLevel > maxNestedFilterDepth)
+ {
+ throw new DataApiBuilderException(
+ message: $"The provided GraphQL filter exceeds the maximum allowed nesting depth of {maxNestedFilterDepth}.",
+ statusCode: HttpStatusCode.BadRequest,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
+ }
+ }
+
///
/// Parse a predicate for a *FilterInput input type
///
@@ -55,6 +89,21 @@ public Predicate Parse(
List fields,
BaseQueryStructure queryStructure)
{
+ return Parse(ctx, filterArgumentSchema, fields, queryStructure, nestingLevel: 0);
+ }
+
+ ///
+ /// Recursive overload that tracks and bounds the relationship-nesting depth of the filter being parsed.
+ ///
+ private Predicate Parse(
+ IMiddlewareContext ctx,
+ IInputValueDefinition filterArgumentSchema,
+ List fields,
+ BaseQueryStructure queryStructure,
+ int nestingLevel)
+ {
+ EnsureWithinNestedFilterDepth(nestingLevel, GetMaxNestedFilterDepth());
+
string schemaName = queryStructure.DatabaseObject.SchemaName;
string sourceName = queryStructure.DatabaseObject.Name;
string sourceAlias = queryStructure.SourceAlias;
@@ -113,7 +162,8 @@ public Predicate Parse(
filterArgumentSchema: filterArgumentSchema,
otherPredicates,
queryStructure,
- op)));
+ op,
+ nestingLevel)));
}
else
{
@@ -185,7 +235,8 @@ public Predicate Parse(
subfields,
predicates,
queryStructure,
- metadataProvider);
+ metadataProvider,
+ nestingLevel);
}
else if (queryStructure is CosmosQueryStructure cosmosQueryStructure)
{
@@ -212,7 +263,8 @@ public Predicate Parse(
nestedFieldTypeName,
predicates,
cosmosQueryStructure,
- metadataProvider);
+ metadataProvider,
+ nestingLevel);
}
else
{
@@ -223,7 +275,8 @@ public Predicate Parse(
predicates.Push(new PredicateOperand(Parse(ctx,
filterArgumentObject.Fields[name],
subfields,
- cosmosQueryStructure)));
+ cosmosQueryStructure,
+ nestingLevel + 1)));
cosmosQueryStructure.DatabaseObject.Name = sourceName;
cosmosQueryStructure.SourceAlias = sourceAlias;
@@ -292,7 +345,8 @@ private void HandleNestedFilterForCosmos(
string entityType,
List predicates,
CosmosQueryStructure queryStructure,
- ISqlMetadataProvider metadataProvider)
+ ISqlMetadataProvider metadataProvider,
+ int nestingLevel)
{
// Validate that the field referenced in the nested input filter can be accessed.
bool entityAccessPermitted = queryStructure.AuthorizationResolver.AreRoleAndOperationDefinedForEntity(
@@ -327,7 +381,8 @@ private void HandleNestedFilterForCosmos(
Predicate existsQueryFilterPredicate = Parse(ctx,
filterField,
subfields,
- existsQuery);
+ existsQuery,
+ nestingLevel + 1);
predicatesForExistsQuery.Push(existsQueryFilterPredicate);
@@ -369,7 +424,8 @@ private void HandleNestedFilterForSql(
List subfields,
List predicates,
BaseQueryStructure queryStructure,
- ISqlMetadataProvider metadataProvider)
+ ISqlMetadataProvider metadataProvider,
+ int nestingLevel)
{
string? targetGraphQLTypeNameForFilter = RelationshipDirectiveType.GetTarget(filterField);
@@ -416,7 +472,8 @@ private void HandleNestedFilterForSql(
Predicate existsQueryFilterPredicate = Parse(ctx,
filterField,
subfields,
- existsQuery);
+ existsQuery,
+ nestingLevel + 1);
predicatesForExistsQuery.Push(existsQueryFilterPredicate);
// Add JoinPredicates to the subquery query structure so a predicate connecting
@@ -531,7 +588,8 @@ private Predicate ParseAndOr(
IInputValueDefinition filterArgumentSchema,
List fields,
BaseQueryStructure baseQuery,
- PredicateOperation op)
+ PredicateOperation op,
+ int nestingLevel)
{
if (fields.Count == 0)
{
@@ -573,7 +631,8 @@ private Predicate ParseAndOr(
Parse(ctx,
filterArgumentSchema,
subfields,
- baseQuery)));
+ baseQuery,
+ nestingLevel)));
}
return MakeChainPredicate(operands, op);
diff --git a/src/Service.Tests/UnitTests/GraphQLFilterParserUnitTests.cs b/src/Service.Tests/UnitTests/GraphQLFilterParserUnitTests.cs
new file mode 100644
index 0000000000..e1010f5f5c
--- /dev/null
+++ b/src/Service.Tests/UnitTests/GraphQLFilterParserUnitTests.cs
@@ -0,0 +1,82 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Core.Models;
+using Azure.DataApiBuilder.Core.Services.MetadataProviders;
+using Azure.DataApiBuilder.Service.Exceptions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Moq;
+
+namespace Azure.DataApiBuilder.Service.Tests.UnitTests
+{
+ ///
+ /// Unit tests for the GraphQL nested-filter depth guard in .
+ /// The guard bounds relationship-nesting depth of filter arguments (e.g. filter:{rel:{rel:{...}}}),
+ /// which HotChocolate's execution-depth rule does not cover, to prevent nested-filter depth-bomb DoS.
+ ///
+ [TestClass]
+ public class GraphQLFilterParserUnitTests
+ {
+ ///
+ /// A nesting level beyond the maximum is rejected with a BadRequest.
+ ///
+ [TestMethod]
+ public void EnsureWithinNestedFilterDepth_ThrowsWhenExceeded()
+ {
+ DataApiBuilderException ex = Assert.ThrowsException(
+ () => GQLFilterParser.EnsureWithinNestedFilterDepth(nestingLevel: 21, maxNestedFilterDepth: 20));
+
+ Assert.AreEqual(System.Net.HttpStatusCode.BadRequest, ex.StatusCode);
+ Assert.AreEqual(DataApiBuilderException.SubStatusCodes.BadRequest, ex.SubStatusCode);
+ }
+
+ ///
+ /// A nesting level at or below the maximum is allowed.
+ ///
+ [DataTestMethod]
+ [DataRow(0)]
+ [DataRow(1)]
+ [DataRow(20)]
+ public void EnsureWithinNestedFilterDepth_DoesNotThrowWithinLimit(int nestingLevel)
+ {
+ // Should not throw.
+ GQLFilterParser.EnsureWithinNestedFilterDepth(nestingLevel, maxNestedFilterDepth: 20);
+ }
+
+ ///
+ /// The effective nested-filter depth limit falls back to the hardcoded safety ceiling when
+ /// runtime.graphql.depth-limit is not configured, uses the depth-limit only when it is stricter,
+ /// and never exceeds the ceiling even when depth-limit is larger or set to -1 (unlimited).
+ ///
+ [DataTestMethod]
+ [DataRow(null, GQLFilterParser.MAX_NESTED_FILTER_DEPTH, DisplayName = "No depth-limit -> safety ceiling")]
+ [DataRow(5, 5, DisplayName = "Stricter depth-limit is used")]
+ [DataRow(50, GQLFilterParser.MAX_NESTED_FILTER_DEPTH, DisplayName = "Higher depth-limit is capped at ceiling")]
+ [DataRow(-1, GQLFilterParser.MAX_NESTED_FILTER_DEPTH, DisplayName = "Unlimited (-1) depth-limit still capped at ceiling")]
+ public void GetMaxNestedFilterDepth_ResolvesEffectiveLimit(int? depthLimit, int expected)
+ {
+ GQLFilterParser parser = CreateParserWithDepthLimit(depthLimit);
+ Assert.AreEqual(expected, parser.GetMaxNestedFilterDepth());
+ }
+
+ private static GQLFilterParser CreateParserWithDepthLimit(int? depthLimit)
+ {
+ RuntimeConfig config = new(
+ Schema: "",
+ DataSource: new(DatabaseType.MSSQL, "", new()),
+ Runtime: new(
+ Rest: new(),
+ GraphQL: new(DepthLimit: depthLimit) { UserProvidedDepthLimit = depthLimit is not null },
+ Mcp: new(),
+ Host: new(null, null)),
+ Entities: new(new Dictionary()));
+
+ RuntimeConfigProvider provider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
+ Mock metadataProviderFactory = new();
+ return new GQLFilterParser(provider, metadataProviderFactory.Object);
+ }
+ }
+}