Skip to content

Commit 7b7f208

Browse files
committed
Add recursion depth limit to prevent stack overflow
The parser uses recursive descent with no depth limit. A deeply nested payload crashes PHP via uncatchable SIGSEGV before any validation runs. Instrument the three recursive entry points (parseSelectionSet, parseValueLiteral, parseTypeReference) with a shared depth counter that throws SyntaxError when exceeded. Default limit is 256, configurable via the recursionLimit parser option (0 disables). Fixes GHSA-r7cg-qjjm-xhqq 🤖 Generated with Claude Code
1 parent 8d0f1c5 commit 7b7f208

3 files changed

Lines changed: 187 additions & 74 deletions

File tree

docs/class-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1157,7 +1157,8 @@ Parses string containing GraphQL query language or [schema definition language](
11571157
noLocation?: bool,
11581158
allowLegacySDLEmptyFields?: bool,
11591159
allowLegacySDLImplementsInterfaces?: bool,
1160-
experimentalFragmentVariables?: bool
1160+
experimentalFragmentVariables?: bool,
1161+
recursionLimit?: int
11611162
}
11621163
```
11631164

src/Language/Parser.php

Lines changed: 107 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@
6565
* noLocation?: bool,
6666
* allowLegacySDLEmptyFields?: bool,
6767
* allowLegacySDLImplementsInterfaces?: bool,
68-
* experimentalFragmentVariables?: bool
68+
* experimentalFragmentVariables?: bool,
69+
* recursionLimit?: int
6970
* }
7071
*
7172
* - **noLocation**:
@@ -317,6 +318,10 @@ public static function __callStatic(string $name, array $arguments)
317318

318319
private Lexer $lexer;
319320

321+
private int $recursionDepth = 0;
322+
323+
private int $recursionLimit;
324+
320325
/**
321326
* @param Source|string $source
322327
*
@@ -328,6 +333,7 @@ public function __construct($source, array $options = [])
328333
? $source
329334
: new Source($source);
330335
$this->lexer = new Lexer($sourceObj, $options);
336+
$this->recursionLimit = $options['recursionLimit'] ?? 256;
331337
}
332338

333339
/**
@@ -343,6 +349,16 @@ private function loc(Token $startToken): ?Location
343349
return null;
344350
}
345351

352+
/** @throws SyntaxError */
353+
private function increaseRecursionDepth(): void
354+
{
355+
if ($this->recursionLimit > 0 && $this->recursionDepth >= $this->recursionLimit) {
356+
throw new SyntaxError($this->lexer->source, $this->lexer->token->start, "Recursion depth limit of {$this->recursionLimit} exceeded");
357+
}
358+
359+
++$this->recursionDepth;
360+
}
361+
346362
/** Determines if the next token is of a given kind. */
347363
private function peek(string $kind): bool
348364
{
@@ -701,18 +717,24 @@ private function parseVariable(): VariableNode
701717
*/
702718
private function parseSelectionSet(): SelectionSetNode
703719
{
704-
$start = $this->lexer->token;
720+
$this->increaseRecursionDepth();
705721

706-
return new SelectionSetNode(
707-
[
708-
'selections' => $this->many(
709-
Token::BRACE_L,
710-
fn (): SelectionNode => $this->parseSelection(),
711-
Token::BRACE_R
712-
),
713-
'loc' => $this->loc($start),
714-
]
715-
);
722+
try {
723+
$start = $this->lexer->token;
724+
725+
return new SelectionSetNode(
726+
[
727+
'selections' => $this->many(
728+
Token::BRACE_L,
729+
fn (): SelectionNode => $this->parseSelection(),
730+
Token::BRACE_R
731+
),
732+
'loc' => $this->loc($start),
733+
]
734+
);
735+
} finally {
736+
--$this->recursionDepth;
737+
}
716738
}
717739

718740
/**
@@ -911,67 +933,73 @@ private function parseFragmentName(): NameNode
911933
*/
912934
private function parseValueLiteral(bool $isConst): ValueNode
913935
{
914-
$token = $this->lexer->token;
915-
switch ($token->kind) {
916-
case Token::BRACKET_L:
917-
return $this->parseArray($isConst);
918-
919-
case Token::BRACE_L:
920-
return $this->parseObject($isConst);
921-
922-
case Token::INT:
923-
$this->lexer->advance();
936+
$this->increaseRecursionDepth();
924937

925-
return new IntValueNode([
926-
'value' => $token->value,
927-
'loc' => $this->loc($token),
928-
]);
938+
try {
939+
$token = $this->lexer->token;
940+
switch ($token->kind) {
941+
case Token::BRACKET_L:
942+
return $this->parseArray($isConst);
929943

930-
case Token::FLOAT:
931-
$this->lexer->advance();
944+
case Token::BRACE_L:
945+
return $this->parseObject($isConst);
932946

933-
return new FloatValueNode([
934-
'value' => $token->value,
935-
'loc' => $this->loc($token),
936-
]);
947+
case Token::INT:
948+
$this->lexer->advance();
937949

938-
case Token::STRING:
939-
case Token::BLOCK_STRING:
940-
return $this->parseStringLiteral();
950+
return new IntValueNode([
951+
'value' => $token->value,
952+
'loc' => $this->loc($token),
953+
]);
941954

942-
case Token::NAME:
943-
if ($token->value === 'true' || $token->value === 'false') {
955+
case Token::FLOAT:
944956
$this->lexer->advance();
945957

946-
return new BooleanValueNode([
947-
'value' => $token->value === 'true',
958+
return new FloatValueNode([
959+
'value' => $token->value,
948960
'loc' => $this->loc($token),
949961
]);
950-
}
951962

952-
if ($token->value === 'null') {
963+
case Token::STRING:
964+
case Token::BLOCK_STRING:
965+
return $this->parseStringLiteral();
966+
967+
case Token::NAME:
968+
if ($token->value === 'true' || $token->value === 'false') {
969+
$this->lexer->advance();
970+
971+
return new BooleanValueNode([
972+
'value' => $token->value === 'true',
973+
'loc' => $this->loc($token),
974+
]);
975+
}
976+
977+
if ($token->value === 'null') {
978+
$this->lexer->advance();
979+
980+
return new NullValueNode([
981+
'loc' => $this->loc($token),
982+
]);
983+
}
953984
$this->lexer->advance();
954985

955-
return new NullValueNode([
986+
return new EnumValueNode([
987+
'value' => $token->value,
956988
'loc' => $this->loc($token),
957989
]);
958-
}
959-
$this->lexer->advance();
960990

961-
return new EnumValueNode([
962-
'value' => $token->value,
963-
'loc' => $this->loc($token),
964-
]);
991+
case Token::DOLLAR:
992+
if (! $isConst) {
993+
return $this->parseVariable();
994+
}
965995

966-
case Token::DOLLAR:
967-
if (! $isConst) {
968-
return $this->parseVariable();
969-
}
996+
break;
997+
}
970998

971-
break;
999+
throw $this->unexpected();
1000+
} finally {
1001+
--$this->recursionDepth;
9721002
}
973-
974-
throw $this->unexpected();
9751003
}
9761004

9771005
/**
@@ -1108,27 +1136,33 @@ private function parseDirective(bool $isConst): DirectiveNode
11081136
*/
11091137
private function parseTypeReference(): TypeNode
11101138
{
1111-
$start = $this->lexer->token;
1139+
$this->increaseRecursionDepth();
11121140

1113-
if ($this->skip(Token::BRACKET_L)) {
1114-
$type = $this->parseTypeReference();
1115-
$this->expect(Token::BRACKET_R);
1116-
$type = new ListTypeNode([
1117-
'type' => $type,
1118-
'loc' => $this->loc($start),
1119-
]);
1120-
} else {
1121-
$type = $this->parseNamedType();
1122-
}
1141+
try {
1142+
$start = $this->lexer->token;
11231143

1124-
if ($this->skip(Token::BANG)) {
1125-
return new NonNullTypeNode([
1126-
'type' => $type,
1127-
'loc' => $this->loc($start),
1128-
]);
1129-
}
1144+
if ($this->skip(Token::BRACKET_L)) {
1145+
$type = $this->parseTypeReference();
1146+
$this->expect(Token::BRACKET_R);
1147+
$type = new ListTypeNode([
1148+
'type' => $type,
1149+
'loc' => $this->loc($start),
1150+
]);
1151+
} else {
1152+
$type = $this->parseNamedType();
1153+
}
11301154

1131-
return $type;
1155+
if ($this->skip(Token::BANG)) {
1156+
return new NonNullTypeNode([
1157+
'type' => $type,
1158+
'loc' => $this->loc($start),
1159+
]);
1160+
}
1161+
1162+
return $type;
1163+
} finally {
1164+
--$this->recursionDepth;
1165+
}
11321166
}
11331167

11341168
/**

tests/Language/ParserTest.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -713,4 +713,82 @@ public function testPartiallyParsesSource(): void
713713
$this->expectException(SyntaxError::class);
714714
Parser::constValueLiteral('$foo');
715715
}
716+
717+
public function testRejectsDeeplyNestedLists(): void
718+
{
719+
$depth = 257;
720+
$query = '{ field(arg: ' . str_repeat('[', $depth) . '1' . str_repeat(']', $depth) . ') }';
721+
722+
$this->expectException(SyntaxError::class);
723+
$this->expectExceptionMessage('Recursion depth limit of 256 exceeded');
724+
Parser::parse($query);
725+
}
726+
727+
public function testRejectsDeeplyNestedObjects(): void
728+
{
729+
$depth = 257;
730+
$query = '{ field(arg: ' . str_repeat('{a:', $depth) . '1' . str_repeat('}', $depth) . ') }';
731+
732+
$this->expectException(SyntaxError::class);
733+
$this->expectExceptionMessage('Recursion depth limit of 256 exceeded');
734+
Parser::parse($query);
735+
}
736+
737+
public function testRejectsDeeplyNestedSelections(): void
738+
{
739+
$depth = 257;
740+
$query = str_repeat('{ a ', $depth) . str_repeat('}', $depth);
741+
742+
$this->expectException(SyntaxError::class);
743+
$this->expectExceptionMessage('Recursion depth limit of 256 exceeded');
744+
Parser::parse($query);
745+
}
746+
747+
public function testRejectsDeeplyNestedTypes(): void
748+
{
749+
$depth = 257;
750+
$query = 'query ($var: ' . str_repeat('[', $depth) . 'Int' . str_repeat(']', $depth) . ') { field }';
751+
752+
$this->expectException(SyntaxError::class);
753+
$this->expectExceptionMessage('Recursion depth limit of 256 exceeded');
754+
Parser::parse($query);
755+
}
756+
757+
public function testCustomRecursionLimit(): void
758+
{
759+
$depth = 6;
760+
$query = str_repeat('{ a ', $depth) . str_repeat('}', $depth);
761+
762+
$this->expectException(SyntaxError::class);
763+
$this->expectExceptionMessage('Recursion depth limit of 5 exceeded');
764+
Parser::parse($query, ['recursionLimit' => 5]);
765+
}
766+
767+
public function testRecursionLimitZeroDisablesCheck(): void
768+
{
769+
$depth = 500;
770+
$query = 'query ($var: ' . str_repeat('[', $depth) . 'Int' . str_repeat(']', $depth) . ') { field }';
771+
772+
Parser::parse($query, ['recursionLimit' => 0]);
773+
$this->expectNotToPerformAssertions();
774+
}
775+
776+
public function testQueryAtExactLimitPasses(): void
777+
{
778+
$depth = 5;
779+
$query = str_repeat('{ a ', $depth) . str_repeat('}', $depth);
780+
781+
Parser::parse($query, ['recursionLimit' => $depth]);
782+
$this->expectNotToPerformAssertions();
783+
}
784+
785+
public function testSiblingBranchesDontAccumulate(): void
786+
{
787+
$limit = 5;
788+
$innerSelection = str_repeat('{ a ', $limit - 1) . str_repeat('}', $limit - 1);
789+
$query = "{ a {$innerSelection} b {$innerSelection} }";
790+
791+
Parser::parse($query, ['recursionLimit' => $limit]);
792+
$this->expectNotToPerformAssertions();
793+
}
716794
}

0 commit comments

Comments
 (0)