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); + } + } +}