From b3ddb1a50b0a058e4c14d60f4b46f043b4d8136d Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Tue, 18 Aug 2026 11:17:29 +0530 Subject: [PATCH 1/2] Bound GraphQL nested-filter recursion depth Thread a nesting-level counter through the recursive GraphQL filter parser and reject filters whose relationship nesting exceeds runtime.graphql.depth-limit (when set and stricter) or a hardcoded safety ceiling. Prevents nested-filter depth-bomb amplification into deeply correlated EXISTS subqueries, which the HotChocolate execution-depth rule does not cover. --- src/Core/Models/GraphQLFilterParsers.cs | 69 +++++++++++++--- .../UnitTests/GraphQLFilterParserUnitTests.cs | 82 +++++++++++++++++++ 2 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 src/Service.Tests/UnitTests/GraphQLFilterParserUnitTests.cs diff --git a/src/Core/Models/GraphQLFilterParsers.cs b/src/Core/Models/GraphQLFilterParsers.cs index 6a97de9f04..2536e3e096 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 /// @@ -53,8 +87,11 @@ public Predicate Parse( IMiddlewareContext ctx, IInputValueDefinition filterArgumentSchema, List fields, - BaseQueryStructure queryStructure) + BaseQueryStructure queryStructure, + int nestingLevel = 0) { + EnsureWithinNestedFilterDepth(nestingLevel, GetMaxNestedFilterDepth()); + string schemaName = queryStructure.DatabaseObject.SchemaName; string sourceName = queryStructure.DatabaseObject.Name; string sourceAlias = queryStructure.SourceAlias; @@ -113,7 +150,8 @@ public Predicate Parse( filterArgumentSchema: filterArgumentSchema, otherPredicates, queryStructure, - op))); + op, + nestingLevel))); } else { @@ -185,7 +223,8 @@ public Predicate Parse( subfields, predicates, queryStructure, - metadataProvider); + metadataProvider, + nestingLevel); } else if (queryStructure is CosmosQueryStructure cosmosQueryStructure) { @@ -212,7 +251,8 @@ public Predicate Parse( nestedFieldTypeName, predicates, cosmosQueryStructure, - metadataProvider); + metadataProvider, + nestingLevel); } else { @@ -223,7 +263,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 +333,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 +369,8 @@ private void HandleNestedFilterForCosmos( Predicate existsQueryFilterPredicate = Parse(ctx, filterField, subfields, - existsQuery); + existsQuery, + nestingLevel + 1); predicatesForExistsQuery.Push(existsQueryFilterPredicate); @@ -369,7 +412,8 @@ private void HandleNestedFilterForSql( List subfields, List predicates, BaseQueryStructure queryStructure, - ISqlMetadataProvider metadataProvider) + ISqlMetadataProvider metadataProvider, + int nestingLevel) { string? targetGraphQLTypeNameForFilter = RelationshipDirectiveType.GetTarget(filterField); @@ -416,7 +460,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 +576,8 @@ private Predicate ParseAndOr( IInputValueDefinition filterArgumentSchema, List fields, BaseQueryStructure baseQuery, - PredicateOperation op) + PredicateOperation op, + int nestingLevel) { if (fields.Count == 0) { @@ -573,7 +619,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); + } + } +} From 84de5ba490885dcb758764795c7c6f065eb609e5 Mon Sep 17 00:00:00 2001 From: souvikghosh04 Date: Thu, 20 Aug 2026 09:48:36 +0530 Subject: [PATCH 2/2] Preserve public Parse signature; add private recursion overload Address review feedback: keep the original 4-parameter public GQLFilterParser.Parse for binary compatibility and move the nesting-depth-tracking logic into a separate private 5-parameter overload used for recursion. --- src/Core/Models/GraphQLFilterParsers.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Core/Models/GraphQLFilterParsers.cs b/src/Core/Models/GraphQLFilterParsers.cs index 2536e3e096..deb9b91c9d 100644 --- a/src/Core/Models/GraphQLFilterParsers.cs +++ b/src/Core/Models/GraphQLFilterParsers.cs @@ -84,11 +84,23 @@ internal static void EnsureWithinNestedFilterDepth(int nestingLevel, int maxNest /// source definition of the table/view of the underlying *FilterInput being processed, /// and the function that parametrizes literals before they are written in string predicate operands. public Predicate Parse( + IMiddlewareContext ctx, + IInputValueDefinition filterArgumentSchema, + 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 = 0) + int nestingLevel) { EnsureWithinNestedFilterDepth(nestingLevel, GetMaxNestedFilterDepth());