diff --git a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs index fea49bcc9a5..0e9df1a4785 100644 --- a/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs +++ b/src/HotChocolate/Core/src/Execution/DependencyInjection/RequestExecutorServiceCollectionExtensions.cs @@ -94,9 +94,12 @@ public static IServiceCollection AddGraphQLCore(this IServiceCollection services return new ParserOptions( noLocations: !options.IncludeLocations, + allowFragmentVariables: false, maxAllowedNodes: options.MaxAllowedNodes, maxAllowedTokens: options.MaxAllowedTokens, - maxAllowedFields: options.MaxAllowedFields); + maxAllowedFields: options.MaxAllowedFields, + maxAllowedDirectives: options.MaxAllowedDirectives, + maxAllowedRecursionDepth: options.MaxAllowedRecursionDepth); }); return services; diff --git a/src/HotChocolate/Core/src/Execution/Options/RequestParserOptions.cs b/src/HotChocolate/Core/src/Execution/Options/RequestParserOptions.cs index bdaf9e0aaa0..a243b6dcdb1 100644 --- a/src/HotChocolate/Core/src/Execution/Options/RequestParserOptions.cs +++ b/src/HotChocolate/Core/src/Execution/Options/RequestParserOptions.cs @@ -50,4 +50,17 @@ public sealed class RequestParserOptions /// as fields is an easier way to estimate query size for GraphQL requests. /// 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. + /// This prevents stack overflow from deeply nested queries. + /// + public int MaxAllowedRecursionDepth { get; set; } = 200; } diff --git a/src/HotChocolate/Language/src/Language.Utf8/ParserOptions.cs b/src/HotChocolate/Language/src/Language.Utf8/ParserOptions.cs index 1c5ebac76d5..f21045a3e4b 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/ParserOptions.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/ParserOptions.cs @@ -45,6 +45,51 @@ public ParserOptions( MaxAllowedTokens = maxAllowedTokens; MaxAllowedNodes = maxAllowedNodes; MaxAllowedFields = maxAllowedFields; + MaxAllowedDirectives = 4; + MaxAllowedRecursionDepth = 200; + } + + /// + /// Initializes a new instance of with security limits. + /// + /// + /// Defines that the parse shall not preserve syntax node locations. + /// + /// + /// Defines that the parser shall parse fragment variables. + /// + /// + /// The maximum number of nodes allowed within a document. + /// + /// + /// The maximum number of tokens allowed within a document. + /// + /// + /// The maximum number of fields allowed within a query document. + /// + /// + /// The maximum number of directives allowed per location (e.g. per field, + /// per operation, per fragment definition). + /// + /// + /// The maximum allowed recursion depth of a parsed document. + /// + public ParserOptions( + bool noLocations, + bool allowFragmentVariables, + int maxAllowedNodes, + int maxAllowedTokens, + int maxAllowedFields, + int maxAllowedDirectives, + int maxAllowedRecursionDepth) + { + NoLocations = noLocations; + Experimental = new(allowFragmentVariables); + MaxAllowedTokens = maxAllowedTokens; + MaxAllowedNodes = maxAllowedNodes; + MaxAllowedFields = maxAllowedFields; + MaxAllowedDirectives = maxAllowedDirectives; + MaxAllowedRecursionDepth = maxAllowedRecursionDepth; } /// @@ -86,6 +131,18 @@ 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. + /// + public int MaxAllowedRecursionDepth { get; } + /// /// Gets the experimental parser options /// which are by default switched of. 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 52181d1bd86..a59b05d96e3 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.Designer.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.Designer.cs @@ -224,5 +224,17 @@ internal static string Utf8GraphQLParser_Start_MaxAllowedFieldsReached { return ResourceManager.GetString("Utf8GraphQLParser_Start_MaxAllowedFieldsReached", resourceCulture); } } + + 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 feaa997ae7e..464706cef98 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.resx +++ b/src/HotChocolate/Language/src/Language.Utf8/Properties/LangUtf8Resources.resx @@ -207,4 +207,10 @@ 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 196b973ae3e..a3391286d6e 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Directives.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Directives.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using static HotChocolate.Language.Properties.LangUtf8Resources; namespace HotChocolate.Language; @@ -64,7 +65,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) { @@ -73,6 +74,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 bde32c0890a..6dfa9900c13 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); @@ -72,7 +72,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); @@ -99,7 +99,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 @@ -125,7 +125,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 a82e452346c..882a106ebb9 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); @@ -127,7 +127,7 @@ private VariableDefinitionNode ParseVariableDefinition() ? ParseValueLiteral(true) : null; var directives = - ParseDirectives(isConstant: true); + ParseDirectives(isConstant: true, isQueryLocation: true); var location = CreateLocation(in start); @@ -163,6 +163,7 @@ private VariableNode ParseVariable() /// private SelectionSetNode ParseSelectionSet() { + IncreaseDepth(); var start = Start(); if (_reader.Kind != TokenKind.LeftBrace) @@ -191,6 +192,7 @@ private SelectionSetNode ParseSelectionSet() var location = CreateLocation(in start); + DecreaseDepth(); return new SelectionSetNode( location, selections); @@ -240,7 +242,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.Types.cs b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Types.cs index 854e8da30b4..2d0b3da063a 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Types.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Types.cs @@ -12,6 +12,7 @@ public ref partial struct Utf8GraphQLParser /// private ITypeNode ParseTypeReference() { + IncreaseDepth(); ITypeNode type; Location? location; @@ -40,6 +41,7 @@ private ITypeNode ParseTypeReference() MoveNext(); location = CreateLocation(in start); + DecreaseDepth(); return new NonNullTypeNode ( location, @@ -50,6 +52,7 @@ private ITypeNode ParseTypeReference() Unexpected(TokenKind.Bang); } + DecreaseDepth(); return type; } diff --git a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Utilities.cs b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Utilities.cs index a7be817cc30..134ffe74639 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Utilities.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Utilities.cs @@ -24,6 +24,25 @@ private NameNode ParseName() [MethodImpl(MethodImplOptions.AggressiveInlining)] private bool MoveNext() => _reader.MoveNext(); + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void IncreaseDepth() + { + if (++_recursionDepth > _maxAllowedRecursionDepth) + { + throw new SyntaxException( + _reader, + string.Format( + Utf8GraphQLParser_Start_MaxAllowedRecursionDepthReached, + _maxAllowedRecursionDepth)); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void DecreaseDepth() + { + --_recursionDepth; + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private TokenInfo Start() { diff --git a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Values.cs b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Values.cs index 7ba6b89645a..c1400e282cf 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Values.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.Values.cs @@ -29,32 +29,37 @@ public ref partial struct Utf8GraphQLParser /// private IValueNode ParseValueLiteral(bool isConstant) { + IncreaseDepth(); + + IValueNode node; + if (_reader.Kind == TokenKind.LeftBracket) { - return ParseList(isConstant); + node = ParseList(isConstant); } - - if (_reader.Kind == TokenKind.LeftBrace) + else if (_reader.Kind == TokenKind.LeftBrace) { - return ParseObject(isConstant); + node = ParseObject(isConstant); } - - if (TokenHelper.IsScalarValue(ref _reader)) + else if (TokenHelper.IsScalarValue(ref _reader)) { - return ParseScalarValue(); + node = ParseScalarValue(); } - - if (_reader.Kind == TokenKind.Name) + else if (_reader.Kind == TokenKind.Name) { - return ParseEnumValue(); + node = ParseEnumValue(); } - - if (_reader.Kind == TokenKind.Dollar && !isConstant) + else if (_reader.Kind == TokenKind.Dollar && !isConstant) + { + node = ParseVariable(); + } + else { - return ParseVariable(); + throw Unexpected(_reader.Kind); } - throw Unexpected(_reader.Kind); + DecreaseDepth(); + return node; } [MethodImpl(MethodImplOptions.AggressiveInlining)] diff --git a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.cs b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.cs index 997d57029f0..b1f1b564200 100644 --- a/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.cs +++ b/src/HotChocolate/Language/src/Language.Utf8/Utf8GraphQLParser.cs @@ -10,10 +10,13 @@ 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; private int _parsedNodes; private int _parsedFields; + private int _recursionDepth; public Utf8GraphQLParser( ReadOnlySpan graphQLData, @@ -29,6 +32,8 @@ public Utf8GraphQLParser( _allowFragmentVars = options.Experimental.AllowFragmentVariables; _maxAllowedNodes = options.MaxAllowedNodes; _maxAllowedFields = options.MaxAllowedFields; + _maxAllowedDirectives = options.MaxAllowedDirectives; + _maxAllowedRecursionDepth = options.MaxAllowedRecursionDepth; _reader = new Utf8GraphQLReader(graphQLData, options.MaxAllowedTokens); _description = null; } @@ -47,6 +52,8 @@ internal Utf8GraphQLParser( _allowFragmentVars = options.Experimental.AllowFragmentVariables; _maxAllowedNodes = options.MaxAllowedNodes; _maxAllowedFields = options.MaxAllowedFields; + _maxAllowedDirectives = options.MaxAllowedDirectives; + _maxAllowedRecursionDepth = options.MaxAllowedRecursionDepth; _reader = reader; _description = null; } @@ -64,6 +71,7 @@ internal Utf8GraphQLParser( public DocumentNode Parse() { _parsedNodes = 0; + _recursionDepth = 0; var definitions = new List(); var start = Start(); diff --git a/src/HotChocolate/Language/test/Language.Tests/Parser/QueryParserTests.cs b/src/HotChocolate/Language/test/Language.Tests/Parser/QueryParserTests.cs index 1c0f32737ba..48014eb06d3 100644 --- a/src/HotChocolate/Language/test/Language.Tests/Parser/QueryParserTests.cs +++ b/src/HotChocolate/Language/test/Language.Tests/Parser/QueryParserTests.cs @@ -6,6 +6,209 @@ namespace HotChocolate.Language; public class QueryParserTests { + [Fact] + public void Default_MaxAllowedRecursionDepth_Is_200() + { + Assert.Equal(200, ParserOptions.Default.MaxAllowedRecursionDepth); + } + + [Fact] + public void Reject_Queries_Exceeding_Max_Recursion_Depth_Selection_Sets() + { + const int depth = 201; + var query = string.Concat(Enumerable.Repeat("{ a", depth)) + + string.Concat(Enumerable.Repeat(" }", depth)); + + Assert + .Throws(() => Utf8GraphQLParser.Parse(query)) + .Message + .MatchInlineSnapshot( + "Document exceeds the maximum allowed recursion depth of 200. Parsing aborted."); + } + + [Fact] + public void Reject_Queries_Exceeding_Max_Recursion_Depth_Object_Values() + { + const int depth = 201; + var query = "{ a(x: " + + string.Concat(Enumerable.Repeat("{a: ", depth)) + + "1" + + string.Concat(Enumerable.Repeat("}", depth)) + + ") }"; + + Assert + .Throws(() => Utf8GraphQLParser.Parse(query)) + .Message + .MatchInlineSnapshot( + "Document exceeds the maximum allowed recursion depth of 200. Parsing aborted."); + } + + [Fact] + public void Reject_Queries_Exceeding_Max_Recursion_Depth_List_Values() + { + const int depth = 201; + var query = "{ a(x: " + + string.Concat(Enumerable.Repeat("[", depth)) + + "1" + + string.Concat(Enumerable.Repeat("]", depth)) + + ") }"; + + Assert + .Throws(() => Utf8GraphQLParser.Parse(query)) + .Message + .MatchInlineSnapshot( + "Document exceeds the maximum allowed recursion depth of 200. Parsing aborted."); + } + + [Fact] + public void Reject_Queries_Exceeding_Max_Recursion_Depth_List_Types() + { + const int depth = 201; + var query = $"query($v: {string.Concat(Enumerable.Repeat("[", depth))}Int{string.Concat(Enumerable.Repeat("]", depth))}) {{ a }}"; + + Assert + .Throws(() => Utf8GraphQLParser.Parse(query)) + .Message + .MatchInlineSnapshot( + "Document exceeds the maximum allowed recursion depth of 200. Parsing aborted."); + } + + [Fact] + public void Allow_Queries_Within_Max_Recursion_Depth() + { + const int depth = 50; + var query = string.Concat(Enumerable.Repeat("{ a", depth)) + + string.Concat(Enumerable.Repeat(" }", depth)); + + var document = Utf8GraphQLParser.Parse(query); + + Assert.NotNull(document); + Assert.Single(document.Definitions); + } + + [Fact] + public void Reject_Queries_Exceeding_Custom_Recursion_Depth() + { + var options = new ParserOptions( + noLocations: false, + allowFragmentVariables: false, + maxAllowedNodes: int.MaxValue, + maxAllowedTokens: int.MaxValue, + maxAllowedFields: 2048, + maxAllowedDirectives: 4, + maxAllowedRecursionDepth: 10); + const int depth = 11; + var query = string.Concat(Enumerable.Repeat("{ a", depth)) + + string.Concat(Enumerable.Repeat(" }", depth)); + + Assert + .Throws(() => Utf8GraphQLParser.Parse(query, options)) + .Message + .MatchInlineSnapshot( + "Document exceeds the maximum allowed recursion depth of 10. Parsing aborted."); + } + + [Fact] + public void Allow_Queries_Within_Custom_Recursion_Depth() + { + var options = new ParserOptions( + noLocations: false, + allowFragmentVariables: false, + maxAllowedNodes: int.MaxValue, + maxAllowedTokens: int.MaxValue, + maxAllowedFields: 2048, + maxAllowedDirectives: 4, + maxAllowedRecursionDepth: 10); + const int depth = 10; + var query = string.Concat(Enumerable.Repeat("{ a", depth)) + + string.Concat(Enumerable.Repeat(" }", depth)); + + var document = Utf8GraphQLParser.Parse(query, options); + + Assert.NotNull(document); + Assert.Single(document.Definitions); + } + + [Theory] + [InlineData(20_000)] + [InlineData(50_000)] + public void Reject_Attack_Payload_Nested_Selection_Sets(int depth) + { + var query = string.Concat(Enumerable.Repeat("{ a", depth)) + + string.Concat(Enumerable.Repeat(" }", depth)); + + Assert.Throws(() => Utf8GraphQLParser.Parse(query)); + } + + [Theory] + [InlineData(20_000)] + [InlineData(50_000)] + public void Reject_Attack_Payload_Nested_List_Values(int depth) + { + var query = "{ a(x: " + + string.Concat(Enumerable.Repeat("[", depth)) + + "1" + + string.Concat(Enumerable.Repeat("]", depth)) + + ") }"; + + Assert.Throws(() => Utf8GraphQLParser.Parse(query)); + } + + [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() + { + Utf8GraphQLParser.Parse("{ a @d @d @d @d }"); + } + + [Fact] + public void Reject_Fields_Exceeding_Custom_Directive_Limit() + { + var options = new ParserOptions( + noLocations: false, + allowFragmentVariables: false, + maxAllowedNodes: int.MaxValue, + maxAllowedTokens: int.MaxValue, + maxAllowedFields: 2048, + maxAllowedDirectives: 2, + maxAllowedRecursionDepth: 200); + + 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 Allow_Fields_Within_Custom_Directive_Limit() + { + var options = new ParserOptions( + noLocations: false, + allowFragmentVariables: false, + maxAllowedNodes: int.MaxValue, + maxAllowedTokens: int.MaxValue, + maxAllowedFields: 2048, + maxAllowedDirectives: 2, + maxAllowedRecursionDepth: 200); + Utf8GraphQLParser.Parse("{ a @d @d }", options); + } + [Fact] public void Reject_Queries_With_More_Than_2048_Fields() {;