Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 70 additions & 10 deletions src/Core/Models/GraphQLFilterParsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// Enforces the relationship-nesting depth limit for GraphQL filters, throwing a BadRequest when exceeded.
/// </summary>
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);
}
}

/// <summary>
/// Parse a predicate for a *FilterInput input type
/// </summary>
Expand All @@ -55,6 +90,21 @@ public Predicate Parse(
List<ObjectFieldNode> fields,
BaseQueryStructure queryStructure)
{
return Parse(ctx, filterArgumentSchema, fields, queryStructure, nestingLevel: 0);
}

/// <summary>
/// Recursive overload that tracks and bounds the relationship-nesting depth of the filter being parsed.
/// </summary>
private Predicate Parse(
IMiddlewareContext ctx,
IInputValueDefinition filterArgumentSchema,
List<ObjectFieldNode> fields,
BaseQueryStructure queryStructure,
int nestingLevel)
{
EnsureWithinNestedFilterDepth(nestingLevel, GetMaxNestedFilterDepth());

string schemaName = queryStructure.DatabaseObject.SchemaName;
string sourceName = queryStructure.DatabaseObject.Name;
string sourceAlias = queryStructure.SourceAlias;
Expand Down Expand Up @@ -113,7 +163,8 @@ public Predicate Parse(
filterArgumentSchema: filterArgumentSchema,
otherPredicates,
queryStructure,
op)));
op,
nestingLevel)));
}
else
{
Expand Down Expand Up @@ -185,7 +236,8 @@ public Predicate Parse(
subfields,
predicates,
queryStructure,
metadataProvider);
metadataProvider,
nestingLevel);
}
else if (queryStructure is CosmosQueryStructure cosmosQueryStructure)
{
Expand All @@ -212,7 +264,8 @@ public Predicate Parse(
nestedFieldTypeName,
predicates,
cosmosQueryStructure,
metadataProvider);
metadataProvider,
nestingLevel);
}
else
{
Expand All @@ -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;
Expand Down Expand Up @@ -292,7 +346,8 @@ private void HandleNestedFilterForCosmos(
string entityType,
List<PredicateOperand> 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(
Expand Down Expand Up @@ -327,7 +382,8 @@ private void HandleNestedFilterForCosmos(
Predicate existsQueryFilterPredicate = Parse(ctx,
filterField,
subfields,
existsQuery);
existsQuery,
nestingLevel + 1);

predicatesForExistsQuery.Push(existsQueryFilterPredicate);

Expand Down Expand Up @@ -369,7 +425,8 @@ private void HandleNestedFilterForSql(
List<ObjectFieldNode> subfields,
List<PredicateOperand> predicates,
BaseQueryStructure queryStructure,
ISqlMetadataProvider metadataProvider)
ISqlMetadataProvider metadataProvider,
int nestingLevel)
{
string? targetGraphQLTypeNameForFilter = RelationshipDirectiveType.GetTarget(filterField);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -531,7 +589,8 @@ private Predicate ParseAndOr(
IInputValueDefinition filterArgumentSchema,
List<IValueNode> fields,
BaseQueryStructure baseQuery,
PredicateOperation op)
PredicateOperation op,
int nestingLevel)
{
if (fields.Count == 0)
{
Expand Down Expand Up @@ -573,7 +632,8 @@ private Predicate ParseAndOr(
Parse(ctx,
filterArgumentSchema,
subfields,
baseQuery)));
baseQuery,
nestingLevel)));
}

return MakeChainPredicate(operands, op);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,6 +37,58 @@ public static async Task SetupAsync(TestContext context)
}

#region Tests
/// <summary>
/// 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.
/// </summary>
[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));
}

/// <summary>
/// Posts a books query whose filter nests the books/authors relationship to the requested depth.
/// </summary>
private static async Task<HttpResponseMessage> 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<RuntimeConfigProvider>();
string graphQLEndpoint = configProvider.GetConfig().GraphQLPath;

HttpRequestMessage request = new(HttpMethod.Post, graphQLEndpoint)
{
Content = JsonContent.Create(new { query })
};

return await HttpClient.SendAsync(request);
}

/// <summary>
/// Gets array of results for querying more than one item.
/// </summary>
Expand Down
82 changes: 82 additions & 0 deletions src/Service.Tests/UnitTests/GraphQLFilterParserUnitTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Unit tests for the GraphQL nested-filter depth guard in <see cref="GQLFilterParser"/>.
/// 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.
/// </summary>
[TestClass]
public class GraphQLFilterParserUnitTests
{
/// <summary>
/// A nesting level beyond the maximum is rejected with a BadRequest.
/// </summary>
[TestMethod]
public void EnsureWithinNestedFilterDepth_ThrowsWhenExceeded()
{
DataApiBuilderException ex = Assert.ThrowsException<DataApiBuilderException>(
() => GQLFilterParser.EnsureWithinNestedFilterDepth(nestingLevel: 21, maxNestedFilterDepth: 20));

Assert.AreEqual(System.Net.HttpStatusCode.BadRequest, ex.StatusCode);
Assert.AreEqual(DataApiBuilderException.SubStatusCodes.DatabaseInputError, ex.SubStatusCode);
}

/// <summary>
/// A nesting level at or below the maximum is allowed.
/// </summary>
[DataTestMethod]
[DataRow(0)]
[DataRow(1)]
[DataRow(20)]
public void EnsureWithinNestedFilterDepth_DoesNotThrowWithinLimit(int nestingLevel)
{
// Should not throw.
GQLFilterParser.EnsureWithinNestedFilterDepth(nestingLevel, maxNestedFilterDepth: 20);
}

/// <summary>
/// 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).
/// </summary>
[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<string, Entity>()));

RuntimeConfigProvider provider = TestHelper.GenerateInMemoryRuntimeConfigProvider(config);
Mock<IMetadataProviderFactory> metadataProviderFactory = new();
return new GQLFilterParser(provider, metadataProviderFactory.Object);
}
}
}