Skip to content
Open
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
79 changes: 69 additions & 10 deletions src/Core/Models/GraphQLFilterParsers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <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)
{
throw new DataApiBuilderException(
message: $"The provided GraphQL filter exceeds the maximum allowed nesting depth of {maxNestedFilterDepth}.",
statusCode: HttpStatusCode.BadRequest,
subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
}
}

/// <summary>
/// Parse a predicate for a *FilterInput input type
/// </summary>
Expand All @@ -55,6 +89,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 +162,8 @@ public Predicate Parse(
filterArgumentSchema: filterArgumentSchema,
otherPredicates,
queryStructure,
op)));
op,
nestingLevel)));
}
else
{
Expand Down Expand Up @@ -185,7 +235,8 @@ public Predicate Parse(
subfields,
predicates,
queryStructure,
metadataProvider);
metadataProvider,
nestingLevel);
}
else if (queryStructure is CosmosQueryStructure cosmosQueryStructure)
{
Expand All @@ -212,7 +263,8 @@ public Predicate Parse(
nestedFieldTypeName,
predicates,
cosmosQueryStructure,
metadataProvider);
metadataProvider,
nestingLevel);
}
else
{
Expand All @@ -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;
Expand Down Expand Up @@ -292,7 +345,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 +381,8 @@ private void HandleNestedFilterForCosmos(
Predicate existsQueryFilterPredicate = Parse(ctx,
filterField,
subfields,
existsQuery);
existsQuery,
nestingLevel + 1);

predicatesForExistsQuery.Push(existsQueryFilterPredicate);

Expand Down Expand Up @@ -369,7 +424,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 +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
Expand Down Expand Up @@ -531,7 +588,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 +631,8 @@ private Predicate ParseAndOr(
Parse(ctx,
filterArgumentSchema,
subfields,
baseQuery)));
baseQuery,
nestingLevel)));
}

return MakeChainPredicate(operands, op);
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.BadRequest, 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);
}
}
}