diff --git a/src/Core/Models/GraphQLFilterParsers.cs b/src/Core/Models/GraphQLFilterParsers.cs
index 6a97de9f04..e3346930cd 100644
--- a/src/Core/Models/GraphQLFilterParsers.cs
+++ b/src/Core/Models/GraphQLFilterParsers.cs
@@ -39,6 +39,41 @@ 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)
+ {
+ // DatabaseInputError is the substatus the GraphQL status-code middleware maps to HTTP 400.
+ throw new DataApiBuilderException(
+ message: $"The provided GraphQL filter exceeds the maximum allowed nesting depth of {maxNestedFilterDepth}.",
+ statusCode: HttpStatusCode.BadRequest,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.DatabaseInputError);
+ }
+ }
+
///
/// Parse a predicate for a *FilterInput input type
///
@@ -55,6 +90,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 +163,8 @@ public Predicate Parse(
filterArgumentSchema: filterArgumentSchema,
otherPredicates,
queryStructure,
- op)));
+ op,
+ nestingLevel)));
}
else
{
@@ -185,7 +236,8 @@ public Predicate Parse(
subfields,
predicates,
queryStructure,
- metadataProvider);
+ metadataProvider,
+ nestingLevel);
}
else if (queryStructure is CosmosQueryStructure cosmosQueryStructure)
{
@@ -212,7 +264,8 @@ public Predicate Parse(
nestedFieldTypeName,
predicates,
cosmosQueryStructure,
- metadataProvider);
+ metadataProvider,
+ nestingLevel);
}
else
{
@@ -223,7 +276,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 +346,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 +382,8 @@ private void HandleNestedFilterForCosmos(
Predicate existsQueryFilterPredicate = Parse(ctx,
filterField,
subfields,
- existsQuery);
+ existsQuery,
+ nestingLevel + 1);
predicatesForExistsQuery.Push(existsQueryFilterPredicate);
@@ -369,7 +425,8 @@ private void HandleNestedFilterForSql(
List subfields,
List predicates,
BaseQueryStructure queryStructure,
- ISqlMetadataProvider metadataProvider)
+ ISqlMetadataProvider metadataProvider,
+ int nestingLevel)
{
string? targetGraphQLTypeNameForFilter = RelationshipDirectiveType.GetTarget(filterField);
@@ -416,7 +473,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 +589,8 @@ private Predicate ParseAndOr(
IInputValueDefinition filterArgumentSchema,
List fields,
BaseQueryStructure baseQuery,
- PredicateOperation op)
+ PredicateOperation op,
+ int nestingLevel)
{
if (fields.Count == 0)
{
@@ -573,7 +632,8 @@ private Predicate ParseAndOr(
Parse(ctx,
filterArgumentSchema,
subfields,
- baseQuery)));
+ baseQuery,
+ nestingLevel)));
}
return MakeChainPredicate(operands, op);
diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs
index c17fed580c..a9578137b7 100644
--- a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs
+++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs
@@ -4,12 +4,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Azure.DataApiBuilder.Config.ObjectModel;
+using Azure.DataApiBuilder.Core.Configurations;
+using Azure.DataApiBuilder.Service.Exceptions;
using Azure.DataApiBuilder.Service.GraphQLBuilder.Queries;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Azure.DataApiBuilder.Service.Tests.SqlTests.GraphQLQueryTests
@@ -31,6 +37,58 @@ public static async Task SetupAsync(TestContext context)
}
#region Tests
+ ///
+ /// Endpoint-level boundary test for the GraphQL nested-filter depth guard.
+ /// A filter nesting the maximum allowed number of relationship levels (20) executes and returns HTTP 200,
+ /// while one additional level (21) is rejected with HTTP 400 and a DatabaseInputError code.
+ ///
+ [TestMethod]
+ public async Task NestedFilterDepthLimit_BoundaryTest()
+ {
+ // 20 relationship levels: at the maximum, allowed.
+ HttpResponseMessage okResponse = await PostNestedRelationshipFilterQueryAsync(relationshipDepth: 20);
+ string okBody = await okResponse.Content.ReadAsStringAsync();
+ Assert.AreEqual(HttpStatusCode.OK, okResponse.StatusCode,
+ $"A nested filter at the maximum allowed depth (20) should succeed. Body: {okBody}");
+ Assert.IsFalse(okBody.Contains("maximum allowed nesting depth"),
+ $"A depth-20 nested filter should not trigger the depth guard. Body: {okBody}");
+
+ // 21 relationship levels: over the maximum, rejected with HTTP 400.
+ HttpResponseMessage badResponse = await PostNestedRelationshipFilterQueryAsync(relationshipDepth: 21);
+ string badBody = await badResponse.Content.ReadAsStringAsync();
+ Assert.AreEqual(HttpStatusCode.BadRequest, badResponse.StatusCode,
+ $"A nested filter exceeding the maximum depth (21) should return HTTP 400. Body: {badBody}");
+ StringAssert.Contains(badBody, "maximum allowed nesting depth");
+ StringAssert.Contains(badBody, nameof(DataApiBuilderException.SubStatusCodes.DatabaseInputError));
+ }
+
+ ///
+ /// Posts a books query whose filter nests the books/authors relationship to the requested depth.
+ ///
+ private static async Task PostNestedRelationshipFilterQueryAsync(int relationshipDepth)
+ {
+ // Build an alternating books/authors relationship filter of the requested depth around a scalar leaf.
+ // The outermost relationship from the books entity is 'authors', then it alternates each level.
+ string filter = "{ id: { eq: 1 } }";
+ for (int level = relationshipDepth; level >= 1; level--)
+ {
+ string relationshipField = (level % 2 == 1) ? "authors" : "books";
+ filter = $"{{ {relationshipField}: {filter} }}";
+ }
+
+ string query = $"{{ books(filter: {filter}) {{ items {{ id }} }} }}";
+
+ RuntimeConfigProvider configProvider = _application.Services.GetService();
+ string graphQLEndpoint = configProvider.GetConfig().GraphQLPath;
+
+ HttpRequestMessage request = new(HttpMethod.Post, graphQLEndpoint)
+ {
+ Content = JsonContent.Create(new { query })
+ };
+
+ return await HttpClient.SendAsync(request);
+ }
+
///
/// Gets array of results for querying more than one item.
///
diff --git a/src/Service.Tests/UnitTests/GraphQLFilterParserUnitTests.cs b/src/Service.Tests/UnitTests/GraphQLFilterParserUnitTests.cs
new file mode 100644
index 0000000000..b2a1e56672
--- /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.DatabaseInputError, 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);
+ }
+ }
+}