webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).
webonyx/graphql-php has unbounded recursion in parser that causes stack overflow on crafted nested input
8.2
/ 10
High
Network
Low
None
None
Unchanged
None
Low
High
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:
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
// 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).
The smallest reliable crashing payload is vector C (nested list values) at approximately 74 KB. All four vectors stay under any field, complexity, or depth validation rule because they crash at parse time, before validation runs.
Standard error contains no PHP error message, no stack trace, no log entry. The process is killed by the kernel via SIGSEGV. In a php-fpm deployment, the FPM master logs WARNING: [pool www] child 12345 exited on signal 11 (SIGSEGV) and respawns the worker, dropping any in-flight requests on that worker.
zend.max_allowed_stack_size does not help
PHP 8.3 introduced zend.max_allowed_stack_size (default 0 = auto-detect from pthread_attr_getstacksize) to detect userland recursion overflow and raise a catchable Stack overflow detected error. In testing against graphql-php v15.31.4, this protection does not prevent the segfault:
Every configuration tested produces SIGSEGV. The runtime check is not catching this overflow class, possibly because the per-frame stack consumption is below the per-call check granularity, or because the auto-detection of the available stack diverges from the actual ulimit -s in containerized environments. Either way, default PHP 8.3 configurations as shipped by official Docker images do not protect against this.
Why try/catch cannot help
PHP's try { Parser::parse($q); } catch (\Throwable $e) { ... } cannot catch SIGSEGV. The signal is delivered by the kernel after the C stack pointer crosses the guard page; the PHP runtime never gets a chance to raise a userland exception. The process exits with status 139 and the catch block is never entered.
Impact
Process termination: a single 74 KB POST kills the entire PHP process that handles it. In php-fpm, the worker is killed and respawned by the master; every other in-flight request on that worker is dropped. In long-running PHP runtimes (Swoole, RoadRunner, ReactPHP), the entire daemon dies.
Pre-validation: Parser::parse is invoked before any validation rule. Field count caps, complexity analyzers, persisted query allow-lists, and all custom validators run after parsing and therefore cannot intercept the crash.
No catchable error: unlike a slow query, a memory_limit exceeded, or a parse error, SIGSEGV cannot be intercepted by PHP. There is no error log entry from the PHP application; only the FPM master log shows child exited on signal 11.
Tiny payload: 74 KB is well below every common HTTP body size limit. The query is also extremely compressible: [ repeated 37,500 times compresses to a few hundred bytes via gzip, bypassing nginx client_max_body_size, AWS ALB body-size caps, and WAF inspection of the encoded payload.
Ecosystem reach: webonyx/graphql-php is the parser used by Lighthouse (Laravel), Overblog/GraphQLBundle (Symfony), wp-graphql (WordPress), Drupal GraphQL module, and the majority of PHP GraphQL servers. Any of these is exposed unless the front layer rejects the decompressed payload before reaching the parser.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all four vectors confirmed by direct measurement.
The recursive descent design has been unchanged since the parser was rewritten in v15.x. Earlier 15.x and 14.x releases share the same code path and are believed vulnerable but were not retested individually.
Remediation
Option 1 -- Add a parser-level recursion depth counter (recommended)
Add a recursionDepth and maxRecursionDepth field to GraphQL\Language\Parser. Increment at the entry of each recursive method, decrement on return, and throw a SyntaxError when it exceeds the configured limit. A sensible default is 256: well above any realistic legitimate query, and approximately 100x below the smallest current crash threshold.
// src/Language/Parser.php
class Parser
{
private int $recursionDepth = 0;
private int $maxRecursionDepth;
public function __construct($source, array $options = [])
{
$this->maxRecursionDepth = $options['maxRecursionDepth'] ?? 256;
// ... existing body ...
}
private function parseValueLiteral(bool $isConst): ValueNode
{
if (++$this->recursionDepth > $this->maxRecursionDepth) {
throw new SyntaxError(
$this->lexer->source,
$this->lexer->token->start,
"Document exceeds maximum allowed recursion depth of {$this->maxRecursionDepth}."
);
}
try {
// ... existing body ...
} finally {
--$this->recursionDepth;
}
}
}
Apply the same pattern to parseSelectionSet, parseObject, parseObjectField, parseList, parseTypeReference, parseInlineFragment, and parseField. The thrown SyntaxError is a normal PHP exception and is fully catchable by user code.
Option 2 -- Iterative parsing for the deepest call chains
Rewrite parseValueLiteral / parseObject / parseList and parseTypeReference using an explicit work stack instead of mutual recursion. This removes the call frame budget entirely for those vectors but does not address parseSelectionSet, which is harder to convert.
Option 3 -- Recommend a token limit option in addition to depth
graphql-php currently has no token limit option at all. Adding a maxTokens parser option would provide defense in depth even if the recursion limit is misconfigured.
The strongest fix is Option 1 with a non-zero default for maxRecursionDepth.
Audit status of related parser-side findings on graphql-php 15.31.4
The following two related parser/validator findings were tested against webonyx/graphql-php@v15.31.4.
Token-limit comment bypass
Not applicable: graphql-php exposes no maxTokens option of any kind. The bypass class does not apply because there is no token counter to bypass. However, this is itself a finding worth documenting: graphql-php has no parser-side resource limits whatsoever. A 391 KB comment-padded payload (100,000 comment lines) is parsed in 193 ms with a +18.4 MB heap delta, with no upper bound. Operators relying on graphql-php as their primary line of defense have no parser-level mitigation against query-size DoS, only the global memory_limit and PHP's post_max_size.
The Lexer::lookahead method does loop while $token->kind === Token::COMMENT (so comments are silently consumed before any per-token check would run), but in graphql-php this is moot because there is no maxTokens counter to bypass in the first place.
OverlappingFieldsCanBeMerged validation DoS
graphql-php is vulnerable. src/Validator/Rules/OverlappingFieldsCanBeMerged.php:311 contains an O(n^2) pairwise loop, and inline fragments are flattened into the same $astAndDefs map at line 266 (case $selection instanceof InlineFragmentNode: $this->internalCollectFieldsAndFragmentNames(... $astAndDefs ...)), bypassing the named-fragment cache. Measured validation cost: 117 seconds for a 364 KB query (200 outer x 100 inner inline fragments). This is documented in a separate advisory: see GHSA_REPORT_GRAPHQL_PHP_15-31-4_OVERLAPPING_FIELDS.md.
This audit covers the three parser-side and validation-side findings tracked together for this implementation. The parser stack overflow documented above and the OverlappingFieldsCanBeMerged validation DoS are exploitable on graphql-php 15.31.4; the token-limit comment bypass is not applicable because there is no token limit option to bypass (which is itself a defense-in-depth gap).
Linux signal(7) -- SIGSEGV (signal 11) is delivered by the kernel for invalid memory access; PHP cannot intercept it
Companion advisory for this implementation: OverlappingFieldsCanBeMerged quadratic validation DoS via flattened inline fragments (Quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments).
webonyx/graphql-php has quadratic validation cost in OverlappingFieldsCanBeMerged via inline fragments
7.5
/ 10
High
Network
Low
None
None
Unchanged
None
None
High
Summary
OverlappingFieldsCanBeMerged validation rule has O(n^2 x m^2) worst case via flattened inline fragments. The CVE-2023-26144 named-fragment cache does not cover inline fragments. A 364 KB query (200 outer x 100 inner inline fragments) consumes 117 seconds of CPU per request, with no comparison budget and no validation timeout.
graphql-php is a PHP port of graphql-js and inherits the same OverlappingFieldsCanBeMerged algorithm. The rule performs an explicit O(n^2) pairwise comparison loop over fields collected for each response name (collectConflictsWithin), and recurses into sub-selections via findConflict. When the rule receives a query in which several inline fragments select the same response name at multiple nesting levels, the cost compounds to O(n^2 x m^2) where n and m are the number of inline fragments at the outer and inner levels respectively.
graphql-php includes a comparedFragmentPairs PairSet cache (the same class of memoization fix tracked under CVE-2023-26144 / GHSA-9pv7-vfvm-6vr7), but it is keyed by named fragment identity. Inline fragments have no name; they are flattened into the parent $astAndDefs map by the case $selection instanceof InlineFragmentNode branch starting at OverlappingFieldsCanBeMerged.php:266, so they are never observed by the cache. Every pair must be re-compared from scratch on every nesting level.
This finding has been tested against the latest stable release webonyx/graphql-php@v15.31.4 running on PHP 8.3.30.
Root Cause
1. Pairwise O(n^2) loop (collectConflictsWithin)
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:306
$fieldsLength = count($fields);
if ($fieldsLength > 1) {
for ($i = 0; $i < $fieldsLength; ++$i) { // line 311
for ($j = $i + 1; $j < $fieldsLength; ++$j) { // line 312
$conflict = $this->findConflict(
$context,
$parentFieldsAreMutuallyExclusive,
$responseName,
$fields[$i],
$fields[$j]
);
// ...
}
}
}
count($fields) grows without bound when multiple inline fragments select the same response name in the same parent selection set.
2. Inline fragment flattening (internalCollectFieldsAndFragmentNames)
N inline fragments selecting the same response name produce N entries in $astAndDefs[$responseName], which then trigger N*(N-1)/2findConflict calls.
3. The named-fragment cache does not cover this code path
// src/Validator/Rules/OverlappingFieldsCanBeMerged.php:41
protected PairSet $comparedFragmentPairs;
// :54 (in __construct)
$this->comparedFragmentPairs = new PairSet();
PairSet is keyed by (fragmentName1, fragmentName2). Inline fragments have no name; they are folded into the parent selection set before the cache is even consulted. The CVE-2023-26144 fix has zero effect on this code path.
4. No comparison budget, no validation timeout
There is no counter shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. The rule runs to completion regardless of cost. graphql-php exposes no validate_timeout equivalent.
Proof of Concept
<?php
// composer require webonyx/graphql-php:v15.31.4
require __DIR__.'/vendor/autoload.php';
use GraphQL\Language\Parser;
use GraphQL\Validator\DocumentValidator;
use GraphQL\Utils\BuildSchema;
$schema = BuildSchema::build('type Query { field: Node } type Node { f: Node, g: Node, x: String }');
function gen(int $n, int $m): string {
$inner = implode(' ', array_fill(0, $m, '... on Node { x }'));
$outer = implode(' ', array_fill(0, $n, "... on Node { f { $inner } }"));
return "{ field { $outer } }";
}
echo " N M | size | validate ms | errors\n";
echo "---------|-----------|-------------|--------\n";
foreach ([[20,20],[50,50],[100,50],[100,100],[150,100],[200,100]] as [$n, $m]) {
$q = gen($n, $m);
$doc = Parser::parse($q);
$t0 = microtime(true);
$errors = DocumentValidator::validate($schema, $doc);
$elapsed = round((microtime(true) - $t0) * 1000);
printf("%4d %4d | %7dB | %10d | %d\n", $n, $m, strlen($q), $elapsed, count($errors));
}
Measured output on webonyx/graphql-php@v15.31.4, PHP 8.3.30, Linux x86_64
The growth confirms O(N^2) outer scaling: doubling N from 100 to 200 (with M=100 fixed) increases validation time from 29,660 ms to 117,082 ms, a factor of approximately 4. A single 364 KB query consumes 117 seconds of CPU on one PHP worker with no errors emitted, no timeout, and no remediation.
Impact
Default-on rule: OverlappingFieldsCanBeMerged is part of the rules registered by DocumentValidator::defaultRules() and is enabled by default in DocumentValidator::validate(). Every Lighthouse, Overblog/GraphQLBundle, wp-graphql, and Drupal GraphQL module application using the standard validation pipeline is exposed.
Pre-execution: the cost is in the validation phase. QueryComplexity and QueryDepth rules cannot help: the example query has depth 3 and complexity 1.
PHP max_execution_time hits the wall too late: a default Lighthouse/Laravel deployment ships with max_execution_time = 30 seconds. A single 100x100 request takes 29.6 seconds in graphql-php, just inside the limit. A 150x100 request takes 66 seconds and will be killed by max_execution_time, but the worker has already burned 30 seconds of CPU per request before being killed; an attacker can sustain that load with low-RPS traffic.
Body-size and WAF bypass via gzip: the payload is the same string repeated N times. A 364 KB raw payload compresses to a few kilobytes via gzip. Any graphql-php deployment behind nginx, Apache, or a CDN with default body-size handling will accept the compressed request and decompress it before reaching the validator.
php-fpm worker pool exhaustion: each request consumes one full PHP worker process. A typical php-fpm pool has 5-50 workers; an attacker firing a handful of parallel requests pins the entire pool for the duration of the validation.
Existing CVE-2023-26144 fix is insufficient: the published PairSet cache only memoizes named-fragment comparisons, not the inline-fragment flattening path.
This is the same vulnerability class as CVE-2023-26144 (partially fixed by named-fragment memoization only) and CVE-2023-28867 (fully fixed via the Adameit algorithm). Both fixes pre-date this finding.
Affected Versions
webonyx/graphql-php@v15.31.4 (latest stable as of 2026-04-08): all measurements above were collected on this version with no custom configuration.
All versions of webonyx/graphql-php that ship OverlappingFieldsCanBeMerged (effectively all 15.x and 14.x stable releases). They share the same code path and are believed vulnerable but were not retested individually.
Remediation
Three options ordered from best to minimal:
Option 1 -- Adopt the Adameit algorithm
Replace pairwise comparison with the uniqueness-check algorithm designed by Simon Adameit, used today by graphql-java (post CVE-2023-28867) and Sangria. The algorithm transforms conflict-freedom into a uniqueness requirement and runs in O(n log n) instead of O(n^2). See graphql/graphql-js issue #2185 for the design discussion and the Sangria PR #12 for the original implementation.
Option 2 -- Comparison budget
Add a comparison counter on OverlappingFieldsCanBeMerged shared across collectConflictsWithin, collectConflictsBetween, and the recursive findConflict calls. Throw a Error after a configurable threshold (for example 10,000 comparisons by default). This is the approach graphql-java implemented after CVE-2023-28867.
Option 3 -- Cap inline-fragment flattening
In internalCollectFieldsAndFragmentNames, cap count($astAndDefs[$responseName]) at a configurable limit (for example 1,000) and emit a validation error if exceeded. This is a narrower fix that targets the specific bypass path but does not address other potential O(n^2) surfaces.
Companion advisory for this implementation: GraphQL\Language\Parser parser stack overflow via deeply nested queries (Unbounded recursion in parser causes stack overflow on crafted nested input).
graphql-php is affected by a Denial of Service via quadratic complexity in OverlappingFieldsCanBeMerged validation
Medium
Network
Low
None
None
The OverlappingFieldsCanBeMerged validation rule exhibits quadratic time complexity when processing queries with many repeated fields sharing the same response name. An attacker can send a crafted query like { hello hello hello ... } with thousands of repeated fields, causing excessive CPU usage during validation before execution begins.
This is not mitigated by existing QueryDepth or QueryComplexity rules.
Observed impact (tested on v15.31.4):
1000 fields: ~0.6s
2000 fields: ~2.4s
3000 fields: ~5.3s
5000 fields: request timeout (>20s)
Root cause:collectConflictsWithin() performs O(n²) pairwise comparisons of all fields with the same response name. For identical repeated fields, every comparison returns "no conflict" but the quadratic iteration count causes resource exhaustion.
Fix: Deduplicate structurally identical fields before pairwise comparison, reducing the complexity from O(n²) to O(u²) where u is the number of unique field signatures (typically 1 for this attack pattern).