Where
-Infinity
0
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary

The OverlappingFieldsCanBeMerged validation rule can be driven into quadratic — and in the worst case O(n²·m²) — running time by a small, highly compressible query. A single 364 KB document can occupy one CPU core for over five minutes during validation, before any resolver runs. Because the rule is part of specifiedrules and therefore enabled by default, every GraphQL-core based server is affected at default settings.

Details

OverlappingFieldsCanBeMerged compares every pair of fields that share a response name and recurses into their sub-selections. The memoization added for CVE-2023-26144 (GHSA-9pv7-vfvm-6vr7) is keyed by named fragment identity, so it does not cover this path: inline fragments are flattened into the parent selection set before the cache is consulted, and therefore every pair is re-compared from scratch at every nesting level.

Repeating an inline fragment that selects the same response name at two nesting levels multiplies the two effects. There was no comparison budget and no validation timeout, so the rule ran to completion regardless of cost.

The cost is incurred in the validation phase, which is what makes this hard to defend against from outside the library: query-depth limiters, complexity analyzers and cost estimators all operate on the parsed document after validation, and the malicious document is trivially shallow (depth 3, complexity 1). The payload is also one short string repeated many times, so it compresses to a few kilobytes and slips past body-size limits and WAF inspection that only see the compressed request.

Proof of concept

python import time from graphql import buildschema, parse, validate

schema = buildschema( "type Query { field: Node } type Node { f: Node, g: Node, x: String }" )

def gen(n: int, m: int) -> str: inner = " ".join(["... on Node { x }"] m) outer = " ".join([f"... on Node {{ f {{ {inner} }} }}"] n) return f"{{ field {{ {outer} }} }}"

query = gen(200, 100) # 364 KB, gzips to a few KB document = parse(query) start = time.perfcounter() validate(schema, document) # returns no errors — it just burns CPU print(f"{(time.perfcounter() - start):.0f}s")

Measured on GraphQL-core 3.2.8 / CPython 3.12 / Linux x8664:

| n | m | query size | validation time | |---|---|---|---| | 20 | 20 | 7.6 KB | 0.16 s | | 50 | 50 | 46 KB | 5.2 s | | 100 | 100 | 182 KB | 81 s | | 200 | 100 | 364 KB | 319 s |

Doubling n at fixed m quadruples the time, as expected for a quadratic outer loop driving a quadratic inner walk.

Impact

Denial of service. One request pins one worker process for minutes; because CPython holds the GIL for this pure-CPU work, a handful of concurrent requests can saturate a typical multi-worker deployment. No authentication is required, and no data is disclosed or modified.

Patches

Fixed in 3.2.12 and 3.3.0rc1.

Validation now enforces a per-document comparison budget. Once MAXFIELDCOMPARISONS (default 250000) pairwise comparisons have been made, the rule aborts and reports a GraphQLError instead of running to completion:

Query is too complex to validate for overlapping fields: exceeded 250000 field comparisons. Validation was aborted.

The default is far above what any realistic query reaches. It is a module-level constant and can be raised or lowered:

python from graphql.validation.rules import overlappingfieldscanbemerged

overlappingfieldscanbemerged.MAXFIELDCOMPARISONS = 1000000

Note the behaviour change: a pathological query that previously validated — slowly — now produces a validation error.

Workarounds

If you cannot upgrade, reject oversized documents before they reach validate(). Note that a request body-size limit must be applied to the decompressed body to be effective here, since the payload is highly compressible. Removing OverlappingFieldsCanBeMergedRule from the rule set is not a safe workaround: it is a correctness rule required by the specification.

A body-size limit remains worthwhile after upgrading — the comparison budget bounds this specific rule, not overall request cost.

Credits

Reported by @BZHunt, who also reviewed the fixes before release. In memory of ZaX, who was the driving force behind this discovery.

Independently found and diagnosed by @bcmyguest, who also proposed a fix.

1 / 2
Source: GitHub
First published (updated )
Severity
7.5
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Summary

The parser's maxtokens limit — the documented defence against oversized GraphQL documents — did not count comment tokens. A document padded with comments could therefore pass a maxtokens check of any size while still allocating one Token object per comment, all of which stay reachable for the lifetime of the parsed document. A 38 MB comment-padded payload allocated roughly 92 MB of additional resident memory with maxtokens=5 in force.

Details

Lexer.lookahead() reads tokens one at a time and skips over comments, returning only the first non-comment token. Each skipped comment is nevertheless fully tokenized, allocated as a Token object, and linked into the persistent token list via token.next / token.prev — the chain that powers source-location reporting, and which is retained as long as the document is.

Parser.advancelexer() incremented the token counter only for the tokens the lexer actually returned. Comments were therefore invisible to the accounting, and maxtokens bounded only the non-comment tokens.

The practical consequence is a gap between what users reasonably believe maxtokens protects and what it did protect. The limit was not broken for ordinary documents — real tokens were still capped correctly — so the shortfall was not observable without specifically testing comment padding.

As with the companion issue, the payload compresses extremely well (# c\n repeated), so body-size limits and WAF inspection applied to the compressed request do not see its true size, while the framework decompresses it in memory before the parser is reached.

Proof of concept

python import gc import resource from graphql import parse

100,000 comments, a 5-token query, and maxtokens=5 — accepted. query = "# c\n" 100000 + "{ a }" parse(query, maxtokens=5) # no error raised

Memory amplification. big = "# padding comment used to amplify memory per token\n" 500000 + "{ a }" gc.collect() before = resource.getrusage(resource.RUSAGESELF).rumaxrss parse(big, maxtokens=5) # ~38 MB source after = resource.getrusage(resource.RUSAGESELF).rumaxrss print(f"RSS delta: +{(after - before) / 1024:.0f} MB")

Measured on GraphQL-core 3.2.8 / CPython 3.12 / Linux x8664: the 391 KB payload parsed in 127 ms, and the 38.1 MB payload produced an RSS delta of +92 MB — about 2.4× the source size, on top of the source string itself.

Impact

Denial of service through memory exhaustion. A modest number of concurrent requests can push a worker past a typical 256 MB or 512 MB container limit and trigger an OOM kill. No authentication is required, and no data is disclosed or modified.

Depth limiters and complexity analyzers do not help: they run on the AST after parsing has already succeeded, and the document contains only five real tokens.

Patches

Fixed in 3.2.12 and 3.3.0rc1.

Parser.advancelexer now counts every token consumed, including the comment tokens the lexer skips between the previously focused token and the new one, and including the advance onto EOF so that trailing comments are covered. maxtokens therefore now bounds every Token the parser allocates.

Note the behaviour change: comments now count toward maxtokens. A comment-heavy document that was previously accepted may now be rejected. If you parse documents containing substantial comments — SDL files with documentation comments, for instance — and you set maxtokens, you may need to raise the limit. The tokencount attribute of a parsed document likewise now includes comments.

Workarounds

If you cannot upgrade, apply a request body-size limit to the decompressed body before the document reaches parse().

Such a limit is worth keeping after upgrading as well: maxtokens is one guard among several and bounds the token count, not the overall cost of a request.

References

- GraphQL specification § 2.1.4 — Comments

Credits

Reported by @BZHunt, who also reviewed the fixes before release. In memory of ZaX, who was the driving force behind this discovery.

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203