diff --git a/src/HotChocolate/Core/benchmarks/Validation.Benchmarks/OverlappingFieldsMergedBenchmark.cs b/src/HotChocolate/Core/benchmarks/Validation.Benchmarks/OverlappingFieldsMergedBenchmark.cs index 1d905bd1322..1a96f67547d 100644 --- a/src/HotChocolate/Core/benchmarks/Validation.Benchmarks/OverlappingFieldsMergedBenchmark.cs +++ b/src/HotChocolate/Core/benchmarks/Validation.Benchmarks/OverlappingFieldsMergedBenchmark.cs @@ -149,7 +149,7 @@ public void FragmentHeavyQuery() private DocumentValidatorContext CreateContext(DocumentNode document) { var context = new DocumentValidatorContext(); - context.Initialize(_schema, default, document, 100, 100, null); + context.Initialize(_schema, default, document, 100, 100, 1_000, null); return context; } diff --git a/src/HotChocolate/Core/src/Types.Analyzers/Errors.cs b/src/HotChocolate/Core/src/Types.Analyzers/Errors.cs index d322a938226..d9e1f04867e 100644 --- a/src/HotChocolate/Core/src/Types.Analyzers/Errors.cs +++ b/src/HotChocolate/Core/src/Types.Analyzers/Errors.cs @@ -52,7 +52,7 @@ public static class Errors public static readonly DiagnosticDescriptor InterfaceTypeStaticKeywordMissing = new( - id: "HC0107", + id: "HC0112", title: "Static Keyword Missing", messageFormat: "A split interface type class needs to be a static class", category: "TypeSystem", @@ -79,7 +79,7 @@ public static class Errors public static readonly DiagnosticDescriptor DataLoaderCannotBeGeneric = new( - id: "HC0085", + id: "HC0111", title: "DataLoader Cannot Be Generic", messageFormat: "The DataLoader source generator cannot generate generic DataLoaders", category: "DataLoader", @@ -88,7 +88,7 @@ public static class Errors public static readonly DiagnosticDescriptor ConnectionSingleGenericTypeArgument = new( - id: "HC0086", + id: "HC0110", title: "Invalid Connection Structure", messageFormat: "A generic connection/edge type must have a single generic type argument that represents the node type", category: "TypeSystem", @@ -97,7 +97,7 @@ public static class Errors public static readonly DiagnosticDescriptor ConnectionNameFormatIsInvalid = new( - id: "HC0087", + id: "HC0109", title: "Invalid Connection/Edge Name Format", messageFormat: "A connection/edge name must be in the format `{0}Edge` or `{0}Connection`", category: "TypeSystem", @@ -106,7 +106,7 @@ public static class Errors public static readonly DiagnosticDescriptor ConnectionNameDuplicate = new( - id: "HC0088", + id: "HC0108", title: "Invalid Connection/Edge Name", messageFormat: "The type `{0}` cannot be mapped to the GraphQL type name `{1}` as `{2}` is already mapped to it", category: "TypeSystem", diff --git a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Validation.cs b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Validation.cs index 3ee7f57d4af..1e9c9b6f096 100644 --- a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Validation.cs +++ b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorBuilderExtensions.Validation.cs @@ -328,6 +328,36 @@ public static IRequestExecutorBuilder RemoveMaxAllowedFieldCycleDepthRule( return builder; } + /// + /// Sets the maximum allowed field merge comparisons during + /// overlapping-fields-can-be-merged validation. + /// + /// + /// The . + /// + /// + /// The maximum number of field-merge comparisons. + /// + /// + /// Returns an that can be used to chain + /// configuration. + /// + /// + /// is null. + /// + public static IRequestExecutorBuilder SetMaxAllowedFieldMergeComparisons( + this IRequestExecutorBuilder builder, + int maxAllowedFieldMergeComparisons) + { + ArgumentNullException.ThrowIfNull(builder); + + ConfigureValidation( + builder, + (_, b) => b.ModifyOptions(o => o.MaxAllowedFieldMergeComparisons = maxAllowedFieldMergeComparisons)); + + return builder; + } + /// /// Configures the underlying . /// diff --git a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs index 82f5e1eabe7..ae622abcd4e 100644 --- a/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Types/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs @@ -85,6 +85,7 @@ public static IServiceCollection AddGraphQLCore(this IServiceCollection services maxAllowedNodes: options.MaxAllowedNodes, maxAllowedTokens: options.MaxAllowedTokens, maxAllowedFields: options.MaxAllowedFields, + maxAllowedDirectives: options.MaxAllowedDirectives, maxAllowedRecursionDepth: options.MaxAllowedRecursionDepth); }); diff --git a/src/HotChocolate/Core/src/Types/Execution/Options/RequestParserOptions.cs b/src/HotChocolate/Core/src/Types/Execution/Options/RequestParserOptions.cs index 32c19fc628c..a7cc6252a30 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Options/RequestParserOptions.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Options/RequestParserOptions.cs @@ -55,6 +55,15 @@ public sealed class RequestParserOptions /// public int MaxAllowedFields { get; set; } = 2048; + /// + /// + /// The maximum number of directives allowed per location (e.g. per field, + /// per operation, per fragment definition). Repeatable directives can be used + /// to exhaust CPU and memory resources if not limited. + /// + /// + public int MaxAllowedDirectives { get; set; } = 4; + /// /// /// The maximum allowed recursion depth when parsing a document. diff --git a/src/HotChocolate/Core/src/Validation/DocumentValidator.cs b/src/HotChocolate/Core/src/Validation/DocumentValidator.cs index fab2839514b..3dd17539300 100644 --- a/src/HotChocolate/Core/src/Validation/DocumentValidator.cs +++ b/src/HotChocolate/Core/src/Validation/DocumentValidator.cs @@ -18,6 +18,7 @@ public sealed class DocumentValidator private readonly IDocumentValidatorRule[] _nonCacheableRules; private readonly int _maxAllowedErrors; private readonly int _maxLocationsPerError; + private readonly int _maxAllowedFragmentVisits; /// /// Initializes a new instance of . @@ -34,11 +35,15 @@ public sealed class DocumentValidator /// /// The maximum number of locations that will be added to a validation error. /// + /// + /// The maximum number of fragment visits allowed during validation. + /// internal DocumentValidator( ObjectPool contextPool, IDocumentValidatorRule[] rules, int maxAllowedErrors, - int maxLocationsPerError) + int maxLocationsPerError, + int maxAllowedFragmentVisits) { ArgumentNullException.ThrowIfNull(rules); ArgumentNullException.ThrowIfNull(contextPool); @@ -49,6 +54,7 @@ internal DocumentValidator( _nonCacheableRules = [.. rules.Where(rule => !rule.IsCacheable)]; _maxAllowedErrors = maxAllowedErrors > 0 ? maxAllowedErrors : 1; _maxLocationsPerError = maxLocationsPerError > 0 ? maxLocationsPerError : 1; + _maxAllowedFragmentVisits = maxAllowedFragmentVisits > 0 ? maxAllowedFragmentVisits : 1; } /// @@ -158,7 +164,7 @@ private DocumentValidatorContext RentContext( IFeatureCollection? features) { var context = _contextPool.Get(); - context.Initialize(schema, documentId, document, _maxAllowedErrors, _maxLocationsPerError, features); + context.Initialize(schema, documentId, document, _maxAllowedErrors, _maxLocationsPerError, _maxAllowedFragmentVisits, features); return context; } diff --git a/src/HotChocolate/Core/src/Validation/DocumentValidatorBuilder.cs b/src/HotChocolate/Core/src/Validation/DocumentValidatorBuilder.cs index 9414b95c82d..e870177294e 100644 --- a/src/HotChocolate/Core/src/Validation/DocumentValidatorBuilder.cs +++ b/src/HotChocolate/Core/src/Validation/DocumentValidatorBuilder.cs @@ -229,7 +229,8 @@ public DocumentValidator Build() contextPool, [.. rules], _options.MaxAllowedErrors, - _options.MaxLocationsPerError); + _options.MaxLocationsPerError, + _options.MaxAllowedFragmentVisits); } private static T CreateInstance( diff --git a/src/HotChocolate/Core/src/Validation/DocumentValidatorContext.cs b/src/HotChocolate/Core/src/Validation/DocumentValidatorContext.cs index 00d5b3263a5..48cf22347f5 100644 --- a/src/HotChocolate/Core/src/Validation/DocumentValidatorContext.cs +++ b/src/HotChocolate/Core/src/Validation/DocumentValidatorContext.cs @@ -144,6 +144,7 @@ public void Initialize( DocumentNode document, int maxAllowedErrors, int maxLocationsPerError, + int maxAllowedFragmentVisits, IFeatureCollection? features) { ArgumentNullException.ThrowIfNull(schema); @@ -156,6 +157,8 @@ public void Initialize( _maxAllowedErrors = maxAllowedErrors; _maxLocationsPerError = maxLocationsPerError; + Fragments.SetMaxAllowedFragmentVisits(maxAllowedFragmentVisits); + _features.Initialize(features); foreach (var definitionNode in document.Definitions) @@ -247,6 +250,8 @@ public sealed class FragmentContext { private readonly HashSet _visited = []; private readonly Dictionary _fragments = new(StringComparer.Ordinal); + private int _maxAllowedFragmentVisits; + private int _fragmentVisits; public IEnumerable Names => _fragments.Keys; @@ -267,9 +272,16 @@ public bool TryGet(FragmentSpreadNode spread, [NotNullWhen(true)] out FragmentDe public bool TryEnter(FragmentSpreadNode spread, [NotNullWhen(true)] out FragmentDefinitionNode? fragment) { + if (_fragmentVisits >= _maxAllowedFragmentVisits) + { + fragment = null; + return false; + } + if (_visited.Add(spread.Name.Value) && _fragments.TryGetValue(spread.Name.Value, out fragment)) { + _fragmentVisits++; return true; } @@ -286,13 +298,22 @@ public void Leave(FragmentDefinitionNode fragment) public bool Exists(FragmentSpreadNode spread) => _fragments.ContainsKey(spread.Name.Value); + internal void SetMaxAllowedFragmentVisits(int maxAllowedFragmentVisits) + { + _maxAllowedFragmentVisits = maxAllowedFragmentVisits; + } + internal void Reset() - => _visited.Clear(); + { + _visited.Clear(); + _fragmentVisits = 0; + } internal void Clear() { _visited.Clear(); _fragments.Clear(); + _fragmentVisits = 0; } } } diff --git a/src/HotChocolate/Core/src/Validation/Extensions/ValidationBuilderExtensions.cs b/src/HotChocolate/Core/src/Validation/Extensions/ValidationBuilderExtensions.cs index ea3c50044d3..6095136b171 100644 --- a/src/HotChocolate/Core/src/Validation/Extensions/ValidationBuilderExtensions.cs +++ b/src/HotChocolate/Core/src/Validation/Extensions/ValidationBuilderExtensions.cs @@ -119,7 +119,7 @@ public static DocumentValidatorBuilder AddFieldRules( return builder .AddRule() .AddRule() - .AddRule(); + .AddRule((_, o) => new OverlappingFieldsCanBeMergedRule(o.MaxAllowedFieldMergeComparisons)); } /// diff --git a/src/HotChocolate/Core/src/Validation/Options/ValidationOptions.cs b/src/HotChocolate/Core/src/Validation/Options/ValidationOptions.cs index 49d8633dcde..9dcc63a577a 100644 --- a/src/HotChocolate/Core/src/Validation/Options/ValidationOptions.cs +++ b/src/HotChocolate/Core/src/Validation/Options/ValidationOptions.cs @@ -80,4 +80,50 @@ public ushort MaxAllowedListRecursiveDepth get; set => field = value > 0 ? value : (ushort)16; } = 1; + + /// + /// + /// The maximum number of field-merge comparisons allowed during + /// overlapping-fields-can-be-merged validation. This prevents + /// adversarial queries with deeply nested inline fragments from + /// consuming unbounded CPU. + /// + /// Default: 100,000 + /// + public int MaxAllowedFieldMergeComparisons + { + get; + set + { + if (value < 1) + { + value = 100_000; + } + + field = value; + } + } = 100_000; + + /// + /// + /// The maximum number of fragment visits allowed during validation. + /// Each time a fragment spread is entered counts as one visit. + /// This prevents adversarial queries with deeply nested or repeated + /// fragment spreads from consuming unbounded CPU. + /// + /// Default: 1,000 + /// + public int MaxAllowedFragmentVisits + { + get; + set + { + if (value < 1) + { + value = 1_000; + } + + field = value; + } + } = 1_000; } diff --git a/src/HotChocolate/Core/src/Validation/Rules/OverlappingFieldsCanBeMergedRule.cs b/src/HotChocolate/Core/src/Validation/Rules/OverlappingFieldsCanBeMergedRule.cs index d69d6a46839..1f711a20944 100644 --- a/src/HotChocolate/Core/src/Validation/Rules/OverlappingFieldsCanBeMergedRule.cs +++ b/src/HotChocolate/Core/src/Validation/Rules/OverlappingFieldsCanBeMergedRule.cs @@ -15,6 +15,15 @@ namespace HotChocolate.Validation.Rules; /// internal sealed class OverlappingFieldsCanBeMergedRule : IDocumentValidatorRule { + private readonly int _maxAllowedFieldMergeComparisons; + + public OverlappingFieldsCanBeMergedRule(int maxAllowedFieldMergeComparisons) + { + ArgumentOutOfRangeException.ThrowIfLessThan(maxAllowedFieldMergeComparisons, 1); + + _maxAllowedFieldMergeComparisons = maxAllowedFieldMergeComparisons; + } + public ushort Priority => ushort.MaxValue; public bool IsCacheable => true; @@ -24,7 +33,7 @@ public void Validate(DocumentValidatorContext context, DocumentNode document) ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(document); - ValidateInternal(new MergeContext(context), document); + ValidateInternal(new MergeContext(context, _maxAllowedFieldMergeComparisons), document); } private static void ValidateInternal(MergeContext context, DocumentNode document) @@ -168,11 +177,22 @@ private static void SameResponseShapeByName( { foreach (var entry in fieldMap) { + if (context.BudgetExhausted) + { + return; + } + if (context.SameResponseShapeChecked.Contains(entry.Value)) { continue; } + if (!context.TryConsumeBudget(entry.Value.Count)) + { + context.ReportBudgetExhausted(); + return; + } + context.SameResponseShapeChecked.Add(entry.Value); var newPath = path.Append(entry.Key); @@ -198,16 +218,32 @@ private static void SameForCommonParentsByName( { foreach (var entry in fieldMap) { + if (context.BudgetExhausted) + { + return; + } + var groups = GroupByCommonParents(entry.Value); var newPath = path.Append(entry.Key); foreach (var group in groups) { + if (context.BudgetExhausted) + { + return; + } + if (context.SameForCommonParentsChecked.Contains(group)) { continue; } + if (!context.TryConsumeBudget(group.Count)) + { + context.ReportBudgetExhausted(); + return; + } + context.SameForCommonParentsChecked.Add(group); var conflict = RequireSameNameAndArguments(newPath, group, context); @@ -735,22 +771,62 @@ public int GetHashCode(HashSet obj) } } - private sealed class MergeContext(DocumentValidatorContext context) + private sealed class MergeContext { private const int MaxPoolSize = 8; + private readonly DocumentValidatorContext _context; + private readonly int _maxAllowedFieldMergeComparisons; private readonly Stack>> _fieldMapPool = new(); private readonly Stack> _stringSetPool = new(); private readonly Stack> _conflictListPool = new(); + private int _remainingBudget; + + public MergeContext(DocumentValidatorContext context, int maxAllowedFieldMergeComparisons) + { + _context = context; + _maxAllowedFieldMergeComparisons = maxAllowedFieldMergeComparisons; + _remainingBudget = maxAllowedFieldMergeComparisons; + TypenameFieldType = new NonNullType(context.Schema.Types["String"]); + IsStreamEnabled = context.Schema.DirectiveDefinitions.ContainsName(DirectiveNames.Stream.Name); + } + + public bool BudgetExhausted { get; private set; } + + public bool TryConsumeBudget(int cost) + { + _remainingBudget -= cost; + + if (_remainingBudget < 0) + { + BudgetExhausted = true; + return false; + } + + return true; + } + + public void ReportBudgetExhausted() + { + _context.FatalErrorDetected = true; + ReportError( + ErrorBuilder.New() + .SetMessage( + "The field merge validation budget of {0} comparisons was exhausted. " + + "The document is too complex to validate.", + _maxAllowedFieldMergeComparisons) + .SetCode(ErrorCodes.Validation.BudgetExceeded) + .SpecifiedBy("sec-Field-Selection-Merging") + .Build()); + } public ISchemaDefinition Schema - => context.Schema; + => _context.Schema; public int MaxLocationsPerError - => context.MaxLocationsPerError; + => _context.MaxLocationsPerError; - public IType TypenameFieldType { get; } = - new NonNullType(context.Schema.Types["String"]); + public IType TypenameFieldType { get; } public HashSet> SameResponseShapeChecked { get; } = new HashSet>(HashSetComparer.Instance); @@ -761,10 +837,9 @@ public int MaxLocationsPerError public HashSet> ConflictsReported { get; } = new HashSet>(HashSetComparer.Instance); - public DocumentValidatorContext.FragmentContext Fragments => context.Fragments; + public DocumentValidatorContext.FragmentContext Fragments => _context.Fragments; - public bool IsStreamEnabled { get; } = - context.Schema.DirectiveDefinitions.ContainsName(DirectiveNames.Stream.Name); + public bool IsStreamEnabled { get; } public Dictionary> RentFieldMap() { @@ -830,7 +905,7 @@ public void ReturnConflictList(List list) } public void ReportError(IError error) - => context.ReportError(error); + => _context.ReportError(error); } private sealed class FieldLocationComparer : IComparer diff --git a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/DataLoaderTests.GenerateSource_GenericBatchDataLoader_MatchesSnapshot.md b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/DataLoaderTests.GenerateSource_GenericBatchDataLoader_MatchesSnapshot.md index c4e26e51868..4ccf2af55ac 100644 --- a/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/DataLoaderTests.GenerateSource_GenericBatchDataLoader_MatchesSnapshot.md +++ b/src/HotChocolate/Core/test/Types.Analyzers.Tests/__snapshots__/DataLoaderTests.GenerateSource_GenericBatchDataLoader_MatchesSnapshot.md @@ -3,7 +3,7 @@ ```json [ { - "Id": "HC0085", + "Id": "HC0111", "Title": "DataLoader Cannot Be Generic", "Severity": "Error", "WarningLevel": 0, diff --git a/src/HotChocolate/Core/test/Validation.Tests/DocumentValidatorTests.cs b/src/HotChocolate/Core/test/Validation.Tests/DocumentValidatorTests.cs index 68f12043e97..9c5ad316d95 100644 --- a/src/HotChocolate/Core/test/Validation.Tests/DocumentValidatorTests.cs +++ b/src/HotChocolate/Core/test/Validation.Tests/DocumentValidatorTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using System.Text; using Microsoft.Extensions.DependencyInjection; using HotChocolate.Language; using HotChocolate.StarWars; @@ -868,6 +869,110 @@ public void Deep_Fragment_Expansion() public void Introspection_Cycle_Detected() => ExpectErrors(FileResource.Open("introspection_with_cycle.graphql")); + /// + /// Fragment traversal bomb: each fragment spreads the next one N times. + /// Without deduplication this creates N^(levels-1) traversals. + /// Validation must deduplicate fragment visits so this completes instantly. + /// See: CVE-2025-32032 + /// + [Fact] + public void Fragment_Traversal_Bomb_Should_Complete_Quickly() + { + // 10 fragment levels, each spreading the next 50 times. + // Without dedup: 50^9 ≈ 2×10^15 traversals (would hang forever). + // With dedup: 10 fragment visits (completes instantly). + var sb = new StringBuilder(); + sb.AppendLine("{...A}"); + + const string names = "ABCDEFGHIJ"; + for (var i = 0; i < names.Length - 1; i++) + { + sb.Append($"fragment {names[i]} on Query {{"); + for (var j = 0; j < 50; j++) + { + if (j > 0) + { + sb.Append(' '); + } + + sb.Append($"...{names[i + 1]}"); + } + + sb.AppendLine("}"); + } + + sb.AppendLine($"fragment {names[^1]} on Query {{ __typename }}"); + + ExpectValid(sb.ToString()); + } + + /// + /// Fragment expansion bomb: each fragment level doubles the number of + /// field expansions via two aliased sub-selections that reference the + /// same child fragment. At depth 20 this creates 2^20 ≈ 1M recursive + /// expansions in naive implementations. + /// See: CVE-2025-32032 + /// + [Fact] + public void Fragment_Expansion_Bomb_Should_Complete_Quickly() + { + // 20 levels, 2 branches per level = 2^20 ≈ 1,048,576 expansions + // in naive implementations. Fragment deduplication in the visitor + // layer reduces this to O(depth) visits. + const int depth = 20; + var sb = new StringBuilder(); + sb.AppendLine("{ field { ...F0 } }"); + + for (var i = 0; i < depth; i++) + { + sb.AppendLine( + $"fragment F{i} on Query {{ fa: field {{ ...F{i + 1} }} fb: field {{ ...F{i + 1} }} }}"); + } + + sb.AppendLine($"fragment F{depth} on Query {{ __typename }}"); + + ExpectValid(sb.ToString()); + } + + /// + /// Inline fragment amplification: multiple inline fragments on the same type, + /// each with many aliased __typename fields, cause O(n²) comparisons in the + /// overlapping-fields-can-be-merged rule. + /// The parser's MaxAllowedFields limit (default 2048) catches this before + /// validation even starts. + /// See: CVE-2023-26144 (graphql-js) + /// + [Fact] + public void Inline_Fragment_TypeName_Amplification_Should_Be_Rejected_By_Parser() + { + // 10 inline fragments × 500 aliased __typename fields = 5000 fields. + // Exceeds the parser's MaxAllowedFields limit (2048). + const int fragments = 10; + const int fieldsPerFragment = 500; + var sb = new StringBuilder(); + sb.AppendLine("{"); + + for (var i = 0; i < fragments; i++) + { + sb.Append(" ... on Query {"); + for (var j = 0; j < fieldsPerFragment; j++) + { + if (j > 0) + { + sb.Append(' '); + } + + sb.Append($"f{j}: __typename"); + } + + sb.AppendLine("}"); + } + + sb.AppendLine("}"); + + Assert.Throws(() => Utf8GraphQLParser.Parse(sb.ToString())); + } + private static void ExpectValid([StringSyntax("graphql")] string sourceText) => ExpectValid(null, null, sourceText); diff --git a/src/HotChocolate/Core/test/Validation.Tests/FieldSelectionMergingRuleTests.cs b/src/HotChocolate/Core/test/Validation.Tests/FieldSelectionMergingRuleTests.cs index ad22faf2928..c5997e1ba62 100644 --- a/src/HotChocolate/Core/test/Validation.Tests/FieldSelectionMergingRuleTests.cs +++ b/src/HotChocolate/Core/test/Validation.Tests/FieldSelectionMergingRuleTests.cs @@ -1,3 +1,4 @@ +using HotChocolate.Language; using HotChocolate.Types; using HotChocolate.Validation.Rules; @@ -7,7 +8,8 @@ public class FieldSelectionMergingRuleTests : DocumentValidatorVisitorTestBase { public FieldSelectionMergingRuleTests() - : base(builder => builder.AddRule()) + : base(builder => builder.AddRule( + (_, o) => new OverlappingFieldsCanBeMergedRule(o.MaxAllowedFieldMergeComparisons))) { } @@ -1399,4 +1401,80 @@ type Query { .AddResolver("Query", "y", () => "") .AddType(new AnyType()) .Create(); + + [Fact] + public void Budget_Exceeded_With_Many_Inline_Fragments() + { + // arrange - low budget to trigger exhaustion + var rule = new OverlappingFieldsCanBeMergedRule(maxAllowedFieldMergeComparisons: 50); + var fragments = string.Concat(Enumerable.Repeat("... on Dog { name }\n", 100)); + var query = $$""" + { + dog { + {{fragments}} + } + } + """; + var document = Utf8GraphQLParser.Parse(query); + var context = ValidationUtils.CreateContext(document); + + // act + rule.Validate(context, document); + + // assert + Assert.NotEmpty(context.Errors); + Assert.Contains( + context.Errors, + e => e.Code == ErrorCodes.Validation.BudgetExceeded); + Assert.True(context.FatalErrorDetected); + } + + [Fact] + public void Budget_Not_Exceeded_With_Higher_Limit() + { + // arrange - high budget, same query should pass + var rule = new OverlappingFieldsCanBeMergedRule(maxAllowedFieldMergeComparisons: 100_000); + var fragments = string.Concat(Enumerable.Repeat("... on Dog { name }\n", 100)); + var query = $$""" + { + dog { + {{fragments}} + } + } + """; + var document = Utf8GraphQLParser.Parse(query); + var context = ValidationUtils.CreateContext(document); + + // act + rule.Validate(context, document); + + // assert + Assert.Empty(context.Errors); + Assert.False(context.FatalErrorDetected); + } + + [Fact] + public void Default_Budget_Allows_Normal_Queries() + { + // arrange - default constructor (100,000 budget) + var rule = new OverlappingFieldsCanBeMergedRule(100_000); + var document = Utf8GraphQLParser.Parse( + """ + { + dog { + name + ... on Dog { name nickname } + ... on Dog { name } + } + } + """); + var context = ValidationUtils.CreateContext(document); + + // act + rule.Validate(context, document); + + // assert + Assert.Empty(context.Errors); + Assert.False(context.FatalErrorDetected); + } } diff --git a/src/HotChocolate/Core/test/Validation.Tests/ValidationUtils.cs b/src/HotChocolate/Core/test/Validation.Tests/ValidationUtils.cs index 133cea8f17e..826f65bb32f 100644 --- a/src/HotChocolate/Core/test/Validation.Tests/ValidationUtils.cs +++ b/src/HotChocolate/Core/test/Validation.Tests/ValidationUtils.cs @@ -13,12 +13,13 @@ public static DocumentValidatorContext CreateContext( ISchemaDefinition? schema = null, IFeatureCollection? features = null, int maxAllowedErrors = 5, - int maxLocationsPerError = 5) + int maxLocationsPerError = 5, + int maxAllowedFragmentVisits = 1_000) { schema ??= CreateSchema(); var context = new DocumentValidatorContext(); - context.Initialize(schema, default, document, maxAllowedErrors, maxLocationsPerError, features); + context.Initialize(schema, default, document, maxAllowedErrors, maxLocationsPerError, maxAllowedFragmentVisits, features); return context; } diff --git a/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostAnalyzerMiddleware.cs b/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostAnalyzerMiddleware.cs index ac3c5f85507..2993afdeb95 100644 --- a/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostAnalyzerMiddleware.cs +++ b/src/HotChocolate/CostAnalysis/src/CostAnalysis/CostAnalyzerMiddleware.cs @@ -96,6 +96,7 @@ private bool TryAnalyze( document, maxAllowedErrors: 1, maxLocationsPerError: 5, + maxAllowedFragmentVisits: 1_000, context.Features); var analyzer = new CostAnalyzer(requestOptions); diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Validation.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Validation.cs index 0363d896c9f..a1aee086fc3 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Validation.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/DependencyInjection/CoreFusionGatewayBuilderExtensions.Validation.cs @@ -136,6 +136,19 @@ public static IFusionGatewayBuilder SetIntrospectionAllowedDepth( return builder; } + public static IFusionGatewayBuilder SetMaxAllowedFieldMergeComparisons( + this IFusionGatewayBuilder builder, + int maxAllowedFieldMergeComparisons) + { + ArgumentNullException.ThrowIfNull(builder); + + ConfigureValidation( + builder, + (_, b) => b.ModifyOptions(o => o.MaxAllowedFieldMergeComparisons = maxAllowedFieldMergeComparisons)); + + return builder; + } + public static IFusionGatewayBuilder AddMaxAllowedFieldCycleDepthRule( this IFusionGatewayBuilder builder, ushort? defaultCycleLimit = 3, diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionParserOptions.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionParserOptions.cs index 13a227e11dd..7f6b924196c 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionParserOptions.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionParserOptions.cs @@ -51,6 +51,15 @@ public sealed class FusionParserOptions /// public int MaxAllowedFields { get; set; } = 2048; + /// + /// + /// The maximum number of directives allowed per location (e.g. per field, + /// per operation, per fragment definition). Repeatable directives can be used + /// to exhaust CPU and memory resources if not limited. + /// + /// + public int MaxAllowedDirectives { get; set; } = 4; + /// /// /// The maximum allowed recursion depth when parsing a document. diff --git a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs index 54a2234d5d1..42e039b0a76 100644 --- a/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs +++ b/src/HotChocolate/Fusion/src/Fusion.Execution/Execution/FusionRequestExecutorManager.cs @@ -293,6 +293,7 @@ private static ParserOptions CreateParserOptions(FusionGatewaySetup setup) maxAllowedNodes: options.MaxAllowedNodes, maxAllowedTokens: options.MaxAllowedTokens, maxAllowedFields: options.MaxAllowedFields, + maxAllowedDirectives: options.MaxAllowedDirectives, maxAllowedRecursionDepth: options.MaxAllowedRecursionDepth); } diff --git a/src/HotChocolate/Language/src/Language.Utf8/ParserOptions.cs b/src/HotChocolate/Language/src/Language.Utf8/ParserOptions.cs index 815abf3c211..58ae7880cbb 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/ParserOptions.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/ParserOptions.cs @@ -33,6 +33,9 @@ public sealed class ParserOptions /// /// The maximum number of fields allowed within a query document. /// + /// + /// The maximum number of directives allowed per location (e.g. per field, per operation). + /// /// /// The maximum allowed recursion depth when parsing a document. /// This prevents stack overflow from deeply nested queries. @@ -43,6 +46,7 @@ public ParserOptions( int maxAllowedNodes = int.MaxValue, int maxAllowedTokens = int.MaxValue, int maxAllowedFields = 2048, + int maxAllowedDirectives = 4, int maxAllowedRecursionDepth = 200) { NoLocations = noLocations; @@ -50,6 +54,7 @@ public ParserOptions( MaxAllowedTokens = maxAllowedTokens; MaxAllowedNodes = maxAllowedNodes; MaxAllowedFields = maxAllowedFields; + MaxAllowedDirectives = maxAllowedDirectives; MaxAllowedRecursionDepth = maxAllowedRecursionDepth; } @@ -92,6 +97,13 @@ public ParserOptions( /// public int MaxAllowedFields { get; } + /// + /// The maximum number of directives allowed per location (e.g. per field, + /// per operation, per fragment definition). Repeatable directives can be used + /// to exhaust CPU and memory resources if not limited. + /// + public int MaxAllowedDirectives { get; } + /// /// Gets the maximum allowed recursion depth of a parsed document. /// diff --git a/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.Designer.cs b/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.Designer.cs index b67252760af..a59b05d96e3 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.Designer.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.Designer.cs @@ -225,6 +225,12 @@ internal static string Utf8GraphQLParser_Start_MaxAllowedFieldsReached { } } + internal static string Utf8GraphQLParser_ParseDirective_MaxAllowedDirectivesReached { + get { + return ResourceManager.GetString("Utf8GraphQLParser_ParseDirective_MaxAllowedDirectivesReached", resourceCulture); + } + } + internal static string Utf8GraphQLParser_Start_MaxAllowedRecursionDepthReached { get { return ResourceManager.GetString("Utf8GraphQLParser_Start_MaxAllowedRecursionDepthReached", resourceCulture); diff --git a/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.resx b/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.resx index e1d2bb067b0..464706cef98 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.resx +++ b/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.resx @@ -207,6 +207,9 @@ The GraphQL request document contains more than {0} fields. Parsing aborted. + + A location in the GraphQL document contains more than {0} directives. Parsing aborted. + Document exceeds the maximum allowed recursion depth of {0}. Parsing aborted. diff --git a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Directives.cs b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Directives.cs index d9a1c28b7dc..3df4033d179 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Directives.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Directives.cs @@ -1,3 +1,5 @@ +using static HotChocolate.Language.Properties.LangUtf8Resources; + namespace HotChocolate.Language; public ref partial struct Utf8GraphQLParser @@ -62,7 +64,7 @@ private NameNode ParseDirectiveLocation() throw Unexpected(kind); } - private List ParseDirectives(bool isConstant) + private List ParseDirectives(bool isConstant, bool isQueryLocation = false) { if (_reader.Kind == TokenKind.At) { @@ -71,6 +73,15 @@ private List ParseDirectives(bool isConstant) while (_reader.Kind == TokenKind.At) { list.Add(ParseDirective(isConstant)); + + if (isQueryLocation && list.Count > _maxAllowedDirectives) + { + throw new SyntaxException( + _reader, + string.Format( + Utf8GraphQLParser_ParseDirective_MaxAllowedDirectivesReached, + _maxAllowedDirectives)); + } } return list; diff --git a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Fragments.cs b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Fragments.cs index a6ac1043676..b6d9d5c1195 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Fragments.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Fragments.cs @@ -53,7 +53,7 @@ private FragmentDefinitionNode ParseFragmentDefinition() ParseVariableDefinitions(); ExpectOnKeyword(); var typeCondition = ParseNamedType(); - var directives = ParseDirectives(false); + var directives = ParseDirectives(false, isQueryLocation: true); var selectionSet = ParseSelectionSet(); var location = CreateLocation(in start); @@ -73,7 +73,7 @@ private FragmentDefinitionNode ParseFragmentDefinition() var name = ParseFragmentName(); ExpectOnKeyword(); var typeCondition = ParseNamedType(); - var directives = ParseDirectives(false); + var directives = ParseDirectives(false, isQueryLocation: true); var selectionSet = ParseSelectionSet(); var location = CreateLocation(in start); @@ -101,7 +101,7 @@ private FragmentDefinitionNode ParseFragmentDefinition() private FragmentSpreadNode ParseFragmentSpread(in TokenInfo start) { var name = ParseFragmentName(); - var directives = ParseDirectives(false); + var directives = ParseDirectives(false, isQueryLocation: true); var location = CreateLocation(in start); return new FragmentSpreadNode @@ -127,7 +127,7 @@ private InlineFragmentNode ParseInlineFragment( in TokenInfo start, NamedTypeNode? typeCondition) { - var directives = ParseDirectives(false); + var directives = ParseDirectives(false, isQueryLocation: true); var selectionSet = ParseSelectionSet(); var location = CreateLocation(in start); diff --git a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Operations.cs b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Operations.cs index 11cb8768313..6ee89056dba 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Operations.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Operations.cs @@ -21,7 +21,7 @@ private OperationDefinitionNode ParseOperationDefinition() var operation = ParseOperationType(); var name = _reader.Kind == TokenKind.Name ? ParseName() : null; var variableDefinitions = ParseVariableDefinitions(); - var directives = ParseDirectives(false); + var directives = ParseDirectives(false, isQueryLocation: true); var selectionSet = ParseSelectionSet(); var location = CreateLocation(in start); @@ -48,7 +48,7 @@ private OperationDefinitionNode ParseOperationDefinition(OperationType operation var name = _reader.Kind == TokenKind.Name ? ParseName() : null; var variableDefinitions = ParseVariableDefinitions(); - var directives = ParseDirectives(false); + var directives = ParseDirectives(false, isQueryLocation: true); var selectionSet = ParseSelectionSet(); var location = CreateLocation(in start); @@ -157,7 +157,7 @@ private VariableDefinitionNode ParseVariableDefinition() ? ParseValueLiteral(true) : null; var directives = - ParseDirectives(isConstant: true); + ParseDirectives(isConstant: true, isQueryLocation: true); var location = CreateLocation(in start); @@ -274,7 +274,7 @@ private FieldNode ParseField() } var arguments = ParseArguments(false); - var directives = ParseDirectives(false); + var directives = ParseDirectives(false, isQueryLocation: true); var selectionSet = _reader.Kind == TokenKind.LeftBrace ? ParseSelectionSet() : null; diff --git a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.cs b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.cs index 231b08a3cc5..0933f9ba212 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.cs @@ -12,6 +12,7 @@ public ref partial struct Utf8GraphQLParser private readonly bool _allowFragmentVars; private readonly int _maxAllowedNodes; private readonly int _maxAllowedFields; + private readonly int _maxAllowedDirectives; private readonly int _maxAllowedRecursionDepth; private Utf8GraphQLReader _reader; private StringValueNode? _description; @@ -34,6 +35,7 @@ public Utf8GraphQLParser( _allowFragmentVars = options.Experimental.AllowFragmentVariables; _maxAllowedNodes = options.MaxAllowedNodes; _maxAllowedFields = options.MaxAllowedFields; + _maxAllowedDirectives = options.MaxAllowedDirectives; _maxAllowedRecursionDepth = options.MaxAllowedRecursionDepth; _reader = new Utf8GraphQLReader(sourceText, options.MaxAllowedTokens); _description = null; @@ -53,6 +55,7 @@ public Utf8GraphQLParser( _allowFragmentVars = options.Experimental.AllowFragmentVariables; _maxAllowedNodes = options.MaxAllowedNodes; _maxAllowedFields = options.MaxAllowedFields; + _maxAllowedDirectives = options.MaxAllowedDirectives; _maxAllowedRecursionDepth = options.MaxAllowedRecursionDepth; _reader = new Utf8GraphQLReader(sourceText, options.MaxAllowedTokens); _description = null; @@ -72,6 +75,7 @@ internal Utf8GraphQLParser( _allowFragmentVars = options.Experimental.AllowFragmentVariables; _maxAllowedNodes = options.MaxAllowedNodes; _maxAllowedFields = options.MaxAllowedFields; + _maxAllowedDirectives = options.MaxAllowedDirectives; _maxAllowedRecursionDepth = options.MaxAllowedRecursionDepth; _reader = reader; _description = null; diff --git a/src/HotChocolate/Language/test/Language.Tests/Parser/QueryParserTests.cs b/src/HotChocolate/Language/test/Language.Tests/Parser/QueryParserTests.cs index 3b887a8c7bf..59d7f314abd 100644 --- a/src/HotChocolate/Language/test/Language.Tests/Parser/QueryParserTests.cs +++ b/src/HotChocolate/Language/test/Language.Tests/Parser/QueryParserTests.cs @@ -157,6 +157,107 @@ public void Reject_Queries_With_More_Than_2048_Fields() .MatchInlineSnapshot("The GraphQL request document contains more than 2048 fields. Parsing aborted."); } + [Fact] + public void Default_MaxAllowedDirectives_Is_4() + { + Assert.Equal(4, ParserOptions.Default.MaxAllowedDirectives); + } + + [Fact] + public void Reject_Fields_Exceeding_Max_Allowed_Directives_Per_Location() + { + Assert + .Throws(() => Utf8GraphQLParser.Parse("{ a @d @d @d @d @d }")) + .Message + .MatchInlineSnapshot( + "A location in the GraphQL document contains more than 4 directives. Parsing aborted."); + } + + [Fact] + public void Allow_Fields_Within_Max_Allowed_Directives_Per_Location() + { + // does not throw with 4 directives, which is the default limit + Utf8GraphQLParser.Parse("{ a @d @d @d @d }"); + } + + [Fact] + public void Reject_Fields_Exceeding_Custom_Directive_Limit() + { + var options = new ParserOptions(maxAllowedDirectives: 2); + + Assert + .Throws(() => Utf8GraphQLParser.Parse("{ a @d @d @d }", options)) + .Message + .MatchInlineSnapshot( + "A location in the GraphQL document contains more than 2 directives. Parsing aborted."); + } + + [Fact] + public void Directive_Limit_Is_Per_Location_Not_Per_Document() + { + // 4 directives per location are allowed by default + Utf8GraphQLParser.Parse("{ a @d @d @d @d b @d @d @d @d c @d @d @d @d }"); + } + + [Theory] + [InlineData(5)] + [InlineData(1_000)] + [InlineData(30_000)] + public void Reject_Attack_Payload_Directive_Overloading(int directiveCount) + { + // CVE-2022-37734: 30,000+ directives on a single field. + var sb = new StringBuilder(); + sb.Append("{ a"); + + for (var i = 0; i < directiveCount; i++) + { + sb.Append(" @d"); + } + + sb.Append(" }"); + + Assert.Throws(() => Utf8GraphQLParser.Parse(sb.ToString())); + } + + [Fact] + public void Allow_Aliased_Fields_Within_Max_Allowed_Fields() + { + // 2000 aliased fields — within the 2048 default limit. + // See: CVE-2024-39895, CVE-2026-35441 (alias overloading) + const int aliasCount = 2000; + var sb = new StringBuilder(); + sb.Append('{'); + + for (var i = 0; i < aliasCount; i++) + { + sb.Append($" a{i}: __typename"); + } + + sb.Append(" }"); + + var document = Utf8GraphQLParser.Parse(sb.ToString()); + + Assert.NotNull(document); + } + + [Fact] + public void Reject_Aliased_Fields_Exceeding_Max_Allowed_Fields() + { + // 2049 aliased fields — exceeds the 2048 default limit. + const int aliasCount = 2049; + var sb = new StringBuilder(); + sb.Append('{'); + + for (var i = 0; i < aliasCount; i++) + { + sb.Append($" a{i}: __typename"); + } + + sb.Append(" }"); + + Assert.Throws(() => Utf8GraphQLParser.Parse(sb.ToString())); + } + [Fact] public void ParseSimpleShortHandFormQuery() { diff --git a/src/HotChocolate/Primitives/src/Primitives/ErrorCodes.cs b/src/HotChocolate/Primitives/src/Primitives/ErrorCodes.cs index b47620a27f2..8cce837b4a5 100644 --- a/src/HotChocolate/Primitives/src/Primitives/ErrorCodes.cs +++ b/src/HotChocolate/Primitives/src/Primitives/ErrorCodes.cs @@ -327,6 +327,11 @@ public static class Validation /// The maximum allowed coordinate cycle depth was exceeded. /// public const string MaxCoordinateCycleDepthOverflow = "HC0087"; + + /// + /// The field merge validation budget was exhausted. + /// + public const string BudgetExceeded = "HC0107"; } /// diff --git a/website/src/docs/docs.json b/website/src/docs/docs.json index cd076317e1d..62c559b5d06 100644 --- a/website/src/docs/docs.json +++ b/website/src/docs/docs.json @@ -222,6 +222,10 @@ "path": "authentication-and-authorization", "title": "Authentication and Authorization" }, + { + "path": "request-limits", + "title": "Request Limits" + }, { "path": "cache-control", "title": "Cache Control" @@ -474,6 +478,7 @@ { "path": "authentication", "title": "Authentication" }, { "path": "authorization", "title": "Authorization" }, { "path": "cost-analysis", "title": "Cost Analysis" }, + { "path": "request-limits", "title": "Request Limits" }, { "path": "introspection", "title": "Controlling Introspection" } ] }, diff --git a/website/src/docs/fusion/v16/request-limits.md b/website/src/docs/fusion/v16/request-limits.md new file mode 100644 index 00000000000..33e1c906af3 --- /dev/null +++ b/website/src/docs/fusion/v16/request-limits.md @@ -0,0 +1,185 @@ +--- +title: "Request Limits" +--- + +A Fusion gateway faces the same GraphQL query attacks as a standalone server: deep nesting, alias amplification, fragment expansion, and directive overloading. It also has gateway-specific risks like expensive query planning. Fusion enforces limits at every stage of the pipeline: parsing, validation, planning, and execution. + +This page covers: + +- Parser limits that reject payloads before the AST is fully constructed +- Validation limits that cap depth, cycles, and comparison work +- Planner guardrails that prevent expensive query plan generation +- Execution limits that bound time, request size, and transport features + +## Parser Limits + +Parser limits stop malicious payloads before the AST is fully constructed. They are the first line of defense because they run before any semantic analysis. + +```csharp +builder + .ModifyParserOptions(o => + { + o.MaxAllowedFields = 1024; + o.MaxAllowedDirectives = 4; + o.MaxAllowedRecursionDepth = 100; + }); +``` + +| Option | Default | Description | +| -------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------ | +| `MaxAllowedFields` | 2048 | Maximum number of fields allowed in a query document. | +| `MaxAllowedDirectives` | 4 per location | Maximum number of directives allowed on a single location (field, operation, or fragment definition). | +| `MaxAllowedRecursionDepth` | 200 | Maximum nesting depth the parser allows for selection sets, list values, object values, and type references. | +| `MaxAllowedNodes` | Unlimited | Maximum number of AST nodes the parser produces from a document. Defaults to unlimited. | +| `MaxAllowedTokens` | Unlimited | Maximum number of tokens the lexer processes. Defaults to unlimited. | + +## Validation Limits + +After parsing, the validation pipeline enforces semantic limits on the query structure. These limits protect against queries that are syntactically valid but computationally expensive to execute. + +### Execution Depth + +Caps how deeply nested a query can be. A depth of 10 covers most real-world queries while blocking deeply nested attacks: + +```csharp +builder.AddMaxExecutionDepthRule(10); +``` + +### Fragment Visits + +Each time a visitor enters a fragment spread counts as one visit. Queries with deeply nested or repeated fragment spreads can cause exponential visitor work. The total number of fragment visits per operation is capped at **1,000** by default. + +### Field Merge Comparisons + +The overlapping-fields-can-be-merged rule caps comparison work at 100,000 by default. No configuration is needed for most gateways. The default protects against fragment expansion bombs. + +### Field Coordinate Cycles + +Some schemas contain self-referential relationships. For example, a `User` type with a `friends` field that returns `[User]`. Without a limit, a client can nest this relationship arbitrarily deep, causing resolver fan-out that grows exponentially with each level. + +The field cycle depth rule tracks how many times each schema coordinate (e.g., `User.friends`) appears on the query path: + +```csharp +builder.AddMaxAllowedFieldCycleDepthRule(defaultCycleLimit: 3); +``` + +With a limit of 3, the following query is valid: + +```graphql +{ + user { + friends { + # User.friends — cycle 1 + friends { + # User.friends — cycle 2 + friends { + # User.friends — cycle 3 + name + } + } + } + } +} +``` + +Adding a fourth level of `friends` would be rejected. + +You can override the limit for specific coordinates: + +```csharp +builder.AddMaxAllowedFieldCycleDepthRule( + defaultCycleLimit: 3, + coordinateCycleLimits: + [ + (new SchemaCoordinate("Category", "parent"), 10), + ]); +``` + +This rule is enabled by default in non-development environments as part of the default security policy. You can remove it with `RemoveMaxAllowedFieldCycleDepthRule()` if your schema does not contain self-referential relationships. + +### Validation Errors + +Caps the total number of validation errors reported per request. The default is 5. When the limit is reached, validation stops early instead of continuing to accumulate errors. + +```csharp +builder.SetMaxAllowedValidationErrors(5); +``` + +### Introspection Depth + +Introspection queries with recursive fields like `__Type.ofType` or `__Type.fields` can be used to construct expensive queries that consume significant server resources. The concern is not schema discovery (the schema is available at `/graphql/schema.graphql` by default when using `MapGraphQL`, computed once with no performance impact), but resource consumption from deeply recursive introspection operations. + +```csharp +builder.SetIntrospectionAllowedDepth( + maxAllowedOfTypeDepth: 8, + maxAllowedListRecursiveDepth: 1); +``` + +### Disable Introspection + +Introspection is disabled in non-development environments by default as part of the default security policy. This prevents clients from running expensive introspection queries against production systems. To disable it unconditionally (including in development): + +```csharp +builder.DisableIntrospection(); +``` + +## Operation Planner Guardrails + +Before execution, the gateway plans how to distribute the query across subgraphs. Complex queries can cause expensive planning. These guardrails prevent planning from consuming excessive resources. + +```csharp +builder + .ModifyPlannerOptions(o => + { + o.MaxPlanningTime = TimeSpan.FromSeconds(5); + o.MaxExpandedNodes = 10_000; + o.MaxQueueSize = 5_000; + }); +``` + +| Option | Default | Description | +| -------------------------------- | -------- | ---------------------------------------------------------------------------- | +| `MaxPlanningTime` | Disabled | Maximum wall-clock time allowed for generating a query plan. | +| `MaxExpandedNodes` | Disabled | Maximum number of planner nodes that may be expanded during plan generation. | +| `MaxQueueSize` | Disabled | Maximum size of the planner's internal work queue. | +| `MaxGeneratedOptionsPerWorkItem` | Disabled | Maximum number of options the planner generates per work item. | + +## Execution Limits + +### Timeout + +Requests are aborted after 30 seconds by default. The timeout covers the entire request including all subgraph calls. It is not enforced when a debugger is attached. + +```csharp +builder + .ModifyRequestOptions(o => + { + o.ExecutionTimeout = TimeSpan.FromSeconds(10); + }); +``` + +### HTTP Request Size + +The maximum HTTP request body size defaults to approximately 20 MB: + +```csharp +services.AddGraphQLGatewayServer(maxAllowedRequestSize: 5 * 1000 * 1024); // 5 MB +``` + +### Server Options + +Control which HTTP methods and features are available: + +```csharp +builder + .ModifyServerOptions(o => + { + o.EnableMultipartRequests = false; + }); +``` + +## Next Steps + +- **"I need to secure my gateway."** [Authentication and Authorization](/docs/fusion/v16/authentication-and-authorization) covers JWT validation, header propagation, and subgraph-level authorization. +- **"I need to tune transport performance."** [Performance Tuning](/docs/fusion/v16/performance-tuning) covers HTTP/2, request deduplication, and concurrency limiting. +- **"I need CDN and HTTP response caching behavior."** [Cache Control](/docs/fusion/v16/cache-control) covers `@cacheControl`, composition merge behavior, and gateway response headers. diff --git a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md index 62a4b0d3c98..d0083340fbf 100644 --- a/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md +++ b/website/src/docs/hotchocolate/v16/migrating/migrate-from-15-to-16.md @@ -1034,3 +1034,52 @@ var app = builder.Build(); - await app.RunWithGraphQLCommandsAsync(args); + return await app.RunWithGraphQLCommandsAsync(args); ``` + +## Parser recursion depth limit + +The parser now enforces a maximum recursion depth of **200** by default. Deeply nested selection sets, list values, object values, or type references that exceed this depth are rejected with a `SyntaxException` instead of causing a stack overflow. If your queries legitimately exceed this depth, increase the limit: + +```csharp +builder.Services + .AddGraphQLServer() + .ModifyParserOptions(o => + { + o.MaxAllowedRecursionDepth = 500; + }); +``` + +## Parser directive limit + +The parser now limits the number of directives per location (field, operation, fragment definition) to **4** by default. Documents with more directives on a single location are rejected at parse time. If you use more than 4 directives per location, increase the limit: + +```csharp +builder.Services + .AddGraphQLServer() + .ModifyParserOptions(o => + { + o.MaxAllowedDirectives = 8; + }); +``` + +## Fragment visit budget + +Validation now caps the total number of fragment visits per operation at **1,000** by default. Each time a fragment spread is entered during validation counts as one visit. Queries with deeply nested or heavily reused fragment spreads that exceed this budget will have remaining fragments skipped during validation. If you have complex queries with many fragment spreads, increase the limit: + +```csharp +builder.Services + .AddGraphQLServer() + .ModifyValidationOptions(o => + { + o.MaxAllowedFragmentVisits = 5_000; + }); +``` + +## Field merge comparison budget + +The overlapping-fields-can-be-merged validation rule now caps comparison work at **100,000** by default. Queries that exceed this budget are rejected. If you have very complex queries that trigger this limit, increase it: + +```csharp +builder.Services + .AddGraphQLServer() + .SetMaxAllowedFieldMergeComparisons(200_000); +``` diff --git a/website/src/docs/hotchocolate/v16/securing-your-api/index.md b/website/src/docs/hotchocolate/v16/securing-your-api/index.md index 91533f88f38..bf220c6b6f6 100644 --- a/website/src/docs/hotchocolate/v16/securing-your-api/index.md +++ b/website/src/docs/hotchocolate/v16/securing-your-api/index.md @@ -44,54 +44,11 @@ Authorization controls what an authenticated user can access. Hot Chocolate prov [Learn more about authorization](/docs/hotchocolate/v16/securing-your-api/authorization) -## Execution Depth +## Request Limits -Limit how deeply nested a query can be: +Hot Chocolate enforces limits at every stage of request processing -- parsing, validation, and execution -- to keep resource consumption bounded. This includes limits on fields, directives, nesting depth, execution depth, timeouts, and more. -```csharp -// Program.cs -builder.Services - .AddGraphQLServer() - .AddMaxExecutionDepthRule(5); -``` - -## Execution Timeout - -GraphQL requests are automatically aborted after 30 seconds by default. Override this setting to match your requirements: - -```csharp -// Program.cs -builder.Services - .AddGraphQLServer() - .ModifyRequestOptions(o => - { - o.ExecutionTimeout = TimeSpan.FromSeconds(60); - }); -``` - -The timeout is not enforced when a debugger is attached. - -## Validation Error Limit - -To protect against payloads designed to generate excessive validation errors, Hot Chocolate limits validation errors to 5 by default. Once the limit is reached, validation stops and the collected errors are returned. - -```csharp -// Program.cs -builder.Services - .AddGraphQLServer() - .SetMaxAllowedValidationErrors(10); -``` - -## Nodes Batch Size - -The `nodes(ids: [ID])` field in Relay-compliant schemas allows fetching multiple nodes at once. To prevent abuse, the batch size is limited to 50 by default: - -```csharp -// Program.cs -builder.Services - .AddGraphQLServer() - .AddGlobalObjectIdentification(o => o.MaxAllowedNodeBatchSize = 100); -``` +[Learn more about request limits](/docs/hotchocolate/v16/securing-your-api/request-limits) ## Introspection diff --git a/website/src/docs/hotchocolate/v16/securing-your-api/introspection.md b/website/src/docs/hotchocolate/v16/securing-your-api/introspection.md index 139c2030794..1b81ef8e821 100644 --- a/website/src/docs/hotchocolate/v16/securing-your-api/introspection.md +++ b/website/src/docs/hotchocolate/v16/securing-your-api/introspection.md @@ -58,7 +58,9 @@ While these fields can be useful to you directly, they are mainly intended for d # Disabling Introspection -While introspection is a powerful feature that can improve your development workflow, it can also be used as an attack vector. A malicious user could request all details about all types in your GraphQL server. Depending on the number of types, this can degrade performance. If your API should not be browsable by other developers, you have the option to disable introspection. +While introspection is a powerful feature that can improve your development workflow, it can also be used as an attack vector. Recursive introspection queries (e.g., deeply nested `__Type.ofType` or `__Type.fields` chains) can consume significant server resources. + +Note that disabling introspection is not about hiding your schema. When using `MapGraphQL`, the schema is available as a static file at `/graphql/schema.graphql`. This file is computed once and has no performance impact. The purpose of disabling introspection is to prevent expensive recursive queries against production systems. Disable introspection by calling `AllowIntrospection()` with a `false` argument on the `IRequestExecutorBuilder`: diff --git a/website/src/docs/hotchocolate/v16/securing-your-api/request-limits.md b/website/src/docs/hotchocolate/v16/securing-your-api/request-limits.md new file mode 100644 index 00000000000..09b88240a3c --- /dev/null +++ b/website/src/docs/hotchocolate/v16/securing-your-api/request-limits.md @@ -0,0 +1,189 @@ +--- +title: "Request Limits" +--- + +Unlike REST, where each endpoint has a predictable cost, a single GraphQL request can trigger unbounded work through deep nesting, alias amplification, or fragment expansion. Hot Chocolate enforces limits at every stage of request processing (parsing, validation, and execution) to keep resource consumption bounded even under adversarial workloads. + +# Parser Limits + +Parser limits stop malicious payloads before the AST is fully constructed. Because parsing happens before validation, these limits are your first line of defense. + +Configure parser limits with `ModifyParserOptions`: + +```csharp +builder.Services + .AddGraphQLServer() + .ModifyParserOptions(o => + { + o.MaxAllowedFields = 1024; + o.MaxAllowedDirectives = 4; + o.MaxAllowedRecursionDepth = 100; + }); +``` + +| Option | Default | Description | +| -------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------ | +| `MaxAllowedFields` | `2048` | Maximum number of fields allowed in a query document. | +| `MaxAllowedDirectives` | `4` per location | Maximum number of directives allowed on a single location (field, operation, or fragment definition). | +| `MaxAllowedRecursionDepth` | `200` | Maximum nesting depth the parser allows for selection sets, list values, object values, and type references. | +| `MaxAllowedNodes` | Unlimited | Maximum number of AST nodes the parser produces from a document. Defaults to unlimited. | +| `MaxAllowedTokens` | Unlimited | Maximum number of tokens the lexer processes. Defaults to unlimited. | + +# Validation Limits + +After parsing, the validation layer checks the document against your schema. Some validation rules can become expensive on adversarial inputs. + +## Execution Depth + +Limits how deeply nested a query can be: + +```csharp +builder.Services + .AddGraphQLServer() + .AddMaxExecutionDepthRule(10); +``` + +This prevents queries that traverse deep relationship chains (e.g., `user.friends.friends.friends...`). Unlike the parser recursion depth (which prevents stack overflows), execution depth measures the logical nesting of field selections against your schema. + +You can skip introspection fields from the depth count and allow per-request overrides: + +```csharp +builder.Services + .AddGraphQLServer() + .AddMaxExecutionDepthRule( + maxAllowedExecutionDepth: 10, + skipIntrospectionFields: true, + allowRequestOverrides: true); +``` + +## Fragment Visits + +Each time a visitor enters a fragment spread counts as one visit. Queries with deeply nested or repeated fragment spreads can cause exponential visitor work. Hot Chocolate caps the total number of fragment visits per operation at **1,000** by default: + +```csharp +builder.Services + .AddGraphQLServer() + .ModifyValidationOptions(o => + { + o.MaxAllowedFragmentVisits = 1_000; + }); +``` + +## Field Merge Comparisons + +The "overlapping fields can be merged" validation rule checks that fields with the same response name have compatible types and arguments. On adversarial inputs with deeply nested fragments, this check can become expensive. + +Hot Chocolate caps the comparison budget at **100,000** by default. Queries exceeding this budget are rejected: + +```csharp +builder.Services + .AddGraphQLServer() + .SetMaxAllowedFieldMergeComparisons(50_000); +``` + +## Field Coordinate Cycles + +Some schemas contain self-referential relationships. For example, a `User` type with a `friends` field that returns `[User]`. Without a limit, a client can nest this relationship arbitrarily deep, causing resolver fan-out that grows exponentially with each level. + +The field cycle depth rule tracks how many times each schema coordinate (e.g., `User.friends`) appears on the query path: + +```csharp +builder.Services + .AddGraphQLServer() + .AddMaxAllowedFieldCycleDepthRule(defaultCycleLimit: 3); +``` + +With a limit of 3, the following query is valid: + +```graphql +{ + user { + friends { + # User.friends — cycle 1 + friends { + # User.friends — cycle 2 + friends { + # User.friends — cycle 3 + name + } + } + } + } +} +``` + +Adding a fourth level of `friends` would be rejected. + +You can override the limit for specific coordinates if some relationships are safe to traverse more deeply than others: + +```csharp +builder.Services + .AddGraphQLServer() + .AddMaxAllowedFieldCycleDepthRule( + defaultCycleLimit: 3, + coordinateCycleLimits: + [ + (new SchemaCoordinate("Category", "parent"), 10), + ]); +``` + +This rule is enabled by default in non-development environments as part of the default security policy. You can remove it with `RemoveMaxAllowedFieldCycleDepthRule()` if your schema does not contain self-referential relationships. + +## Validation Errors + +Documents designed to generate excessive validation errors can consume memory accumulating error objects. The default limit is **5**. When the limit is reached, validation stops early instead of continuing to accumulate errors. + +```csharp +builder.Services + .AddGraphQLServer() + .SetMaxAllowedValidationErrors(5); +``` + +## Introspection Depth + +Introspection queries with recursive fields like `__Type.ofType` or `__Type.fields` can be used to construct expensive queries that consume significant server resources. The concern is not schema discovery (the schema is available at `/graphql/schema.graphql` by default when using `MapGraphQL`, computed once with no performance impact), but resource consumption from deeply recursive introspection operations. + +Recursive introspection queries are limited by default: + +- **`ofType` chain:** 16 levels +- **List fields** (`fields`, `inputFields`, `interfaces`, `possibleTypes`): 1 level of recursion + +```csharp +builder.Services + .AddGraphQLServer() + .SetIntrospectionAllowedDepth( + maxAllowedOfTypeDepth: 8, + maxAllowedListRecursiveDepth: 1); +``` + +# Execution Limits + +## Timeout + +Requests are aborted after 30 seconds by default. The timeout is not enforced when a debugger is attached. + +```csharp +builder.Services + .AddGraphQLServer() + .ModifyRequestOptions(o => + { + o.ExecutionTimeout = TimeSpan.FromSeconds(10); + }); +``` + +## Nodes Batch Size + +The `nodes(ids: [ID!]!)` field allows fetching multiple entities at once. The default batch limit is **50**: + +```csharp +builder.Services + .AddGraphQLServer() + .AddGlobalObjectIdentification(o => o.MaxAllowedNodeBatchSize = 25); +``` + +# Next Steps + +- **Need cost analysis?** See [Cost Analysis](/docs/hotchocolate/v16/securing-your-api/cost-analysis). +- **Need trusted documents?** See [Trusted Documents](/docs/hotchocolate/v16/performance/trusted-documents). +- **Need to control introspection?** See [Introspection](/docs/hotchocolate/v16/securing-your-api/introspection). +- **Back to overview?** See [Securing Your API](/docs/hotchocolate/v16/securing-your-api).