From 43824ebcda708a3fa40d7c2adb6bcb6d1e5b96ed Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:50:25 -0400 Subject: [PATCH] parser: bound recursion depth to prevent stack exhaustion on deeply nested input The recursive-descent parser had no limit on nesting depth. A document with deeply nested list values, object values, selection sets, or list types (for example "{f(a:[[[[...") drives parseValueLiteral/parseSelectionSet/parseType into unbounded recursion. On sufficiently nested input this exhausts the goroutine stack and aborts the process with a fatal "stack overflow", and well before that the repeated stack growth makes parsing time grow super-linearly. Since Parse operates on untrusted client-supplied documents, this lets a small request take down a server. Track the current nesting depth on the parser and return a syntax error once it exceeds a fixed limit that is far higher than any realistic document needs. --- language/parser/parser.go | 36 +++++++++++++++++++++++++++ language/parser/parser_depth_test.go | 37 ++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 language/parser/parser_depth_test.go diff --git a/language/parser/parser.go b/language/parser/parser.go index 4ae3dc335..6d5e076ba 100644 --- a/language/parser/parser.go +++ b/language/parser/parser.go @@ -51,6 +51,30 @@ type Parser struct { Options ParseOptions PrevEnd int Token lexer.Token + depth int +} + +// maxRecursionDepth bounds how deeply the parser will descend into nested +// constructs (selection sets, input values, and list types). Documents that +// nest beyond this limit are rejected with a syntax error rather than being +// allowed to exhaust the goroutine stack. The limit is far larger than any +// realistic GraphQL document requires. +const maxRecursionDepth = 500 + +// enter records descent into a nested construct and reports an error when the +// nesting exceeds maxRecursionDepth. Every call must be paired with leave. +func (parser *Parser) enter() error { + parser.depth++ + if parser.depth > maxRecursionDepth { + return gqlerrors.NewSyntaxError(parser.Source, parser.Token.Start, + fmt.Sprintf("Syntax Error: Document nests deeper than the maximum of %d levels", maxRecursionDepth)) + } + return nil +} + +// leave records the end of a nested construct entered with enter. +func (parser *Parser) leave() { + parser.depth-- } func Parse(p ParseParams) (*ast.Document, error) { @@ -315,6 +339,10 @@ func parseVariable(parser *Parser) (*ast.Variable, error) { * SelectionSet : { Selection+ } */ func parseSelectionSet(parser *Parser) (*ast.SelectionSet, error) { + if err := parser.enter(); err != nil { + return nil, err + } + defer parser.leave() start := parser.Token.Start selections := []ast.Selection{} if iSelections, err := reverse(parser, @@ -569,6 +597,10 @@ func parseFragmentName(parser *Parser) (*ast.Name, error) { * EnumValue : Name but not `true`, `false` or `null` */ func parseValueLiteral(parser *Parser, isConst bool) (ast.Value, error) { + if err := parser.enter(); err != nil { + return nil, err + } + defer parser.leave() token := parser.Token switch token.Kind { case lexer.BRACKET_L: @@ -771,6 +803,10 @@ func parseDirective(parser *Parser) (*ast.Directive, error) { * - NonNullType */ func parseType(parser *Parser) (ttype ast.Type, err error) { + if err = parser.enter(); err != nil { + return nil, err + } + defer parser.leave() token := parser.Token // [ String! ]! switch token.Kind { diff --git a/language/parser/parser_depth_test.go b/language/parser/parser_depth_test.go new file mode 100644 index 000000000..cda3a44ac --- /dev/null +++ b/language/parser/parser_depth_test.go @@ -0,0 +1,37 @@ +package parser + +import ( + "strings" + "testing" +) + +// A document that nests far deeper than any real query must be rejected with a +// syntax error rather than being allowed to exhaust the goroutine stack. +func TestParseDeeplyNestedInputDoesNotOverflowStack(t *testing.T) { + cases := map[string]string{ + "list value": "{f(a:" + strings.Repeat("[", 200000) + ")}", + "object value": "{f(a:" + strings.Repeat("{b:", 200000) + "}", + "selection set": strings.Repeat("{f", 200000), + "list type": "query($v:" + strings.Repeat("[", 200000) + "Int){f}", + } + for name, src := range cases { + t.Run(name, func(t *testing.T) { + _, err := Parse(ParseParams{Source: src}) + if err == nil { + t.Fatalf("expected a syntax error for deeply nested input, got nil") + } + if !strings.Contains(err.Error(), "nests deeper") { + t.Fatalf("expected depth-limit error, got: %v", err) + } + }) + } +} + +// Reasonably nested documents must still parse successfully. +func TestParseModeratelyNestedInputStillParses(t *testing.T) { + depth := 100 + src := "{f(a:" + strings.Repeat("[", depth) + strings.Repeat("]", depth) + ")}" + if _, err := Parse(ParseParams{Source: src}); err != nil { + t.Fatalf("moderately nested document should parse, got: %v", err) + } +}