Critical and High-Severity GraphQL CVEs in GitLab: Code Injection and CSRF via One Directive

Two flaws in GitLab’s GraphQL API: one lets any user wipe or alter public projects and user data, the other quietly runs changes using a logged-in user’s own permissions. Self-managed instances from 18.2 through 19.2 need to upgrade now.

Overview

On August 17, 2026, GitLab published 19.2.4, 19.1.6, 19.0.8, and 18.11.11, fixing two critical GraphQL vulnerabilities.

CVE-2026-19478 (CVSS 9.4) is a code injection flaw in how GitLab handles a GraphQL input. Under certain conditions, it allows a remote, unauthenticated user to modify or delete public projects and user data.

CVE-2026-19650 (CVSS 7.1) is a cross-site request forgery issue in the GraphQL API. Improper request validation meant state-changing mutations could be executed via GET requests.

Only self-managed GitLab instances need to patch this; GitLab.com and GitLab Dedicated are already patched.

Affected Versions

Affected Product(s)

Version(s)

Patched Version

GitLab CE/EE

18.2 – 18.11.10

18.11.11

GitLab CE/EE

19.0 – 19.0.7

19.0.8

GitLab CE/EE

19.1 – 19.1.5

19.1.6

GitLab CE/EE

19.2 – 19.2.3

19.2.4

Vulnerability Details

CVE: CVE-2026-19478

Description: A code injection issue reachable through a GraphQL directive. Under certain conditions, an unauthenticated remote user can modify or delete public projects and user data.

CWE: CWE-94: Improper Control of Generation of Code (‘Code Injection’)

CVSS Source: GitLab

CVSS Base score: 9.4 (Critical)

CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:H

CVE: CVE-2026-19650

Description: Improper request validation in GraphQL multiplex query handling allowed an unauthenticated user to execute mutations via GET requests.

CWE: CWE-352: Cross-Site Request Forgery (CSRF)

CVSS Source: GitLab

CVSS Base score: 7.1 (High)

CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:L

Timeline

  • CVE-2026-19478 reported by hiimguardian through GitLab’s HackerOne bug bounty program

  • CVE-2026-19650 reported by kreep through the same program

  • Aug 17, 2026 – Critical patch release: 19.2.4, 19.1.6, 19.0.8, 18.11.11

Recommended Actions

  • Upgrade now to 18.11.11, 19.0.8, 19.1.6 or 19.2.4, matching your branch. This is the only remediation; there is no documented configuration workaround.

  • Validate exposure while you patch. Confirm which instances expose GraphQL to the internet, whether public projects are enabled, and whether a WAF or reverse proxy sits in front of /api/graphql.

Technical Analysis

CVE-2026-19478

Mechanism

GitLab ships a “@gl_introduced(version:)” GraphQL directive so that during a rolling deploy, a client can request fields that only exist on newer instances without triggering a schema error. “FutureFieldFilter” strips any node whose version is ahead of the running instance and records “contain_future_fields,” then “IntroducedTracer” restores the original document at execution time and sets “context[:contain_future_fields] = true” so the stripped fields resolve to null instead of erroring.

That flag is what reaches the vulnerable code. During execution, “FutureFieldFallback#get_field” intercepts every field lookup, and when the flag is set and the requested field does not exist in the schema, it synthesizes a field on the fly out of the attacker’s own field name. The synthesized “GraphQL::Schema::Field” carried no resolver, and in graphql-ruby a field without a resolver resolves by calling the method of the same name on the underlying object. Since the field name comes straight from the query document, marking any name with “@gl_introduced(version: “99.0”)” turns it into a method invocation against whatever object sits at that position in the graph — which is how a read-only query reaches state-changing methods on a model, and why the advisory describes remote modification and deletion of public projects and user data. Public projects are queryable without a session, and the directive is processed before field-level authorization runs, which is what makes it unauthenticated.

Vulnerable Code

lib/gitlab/graphql/version_filter/future_field_fallback.rb#L14-36 (v19.2.2-ee)

image

image

“future_field?” gates only on the context flag, the field being absent from the schema, and the name not starting with “__” — there is no check on what the name actually is. “fallback_field” then passes that raw name straight into a “GraphQL::Schema::Field” that specifies a type and a fallback value but no resolver, which is the omission that leaves graphql-ruby to resolve the field by calling the method of that name on the object.

The Patch

Commit e283c6ad – “Prevent calling object method when resolving fallback field”

image

image

The two lines that declared the field’s type and fallback value are replaced by a single “resolver_class.” Nothing else in the fallback path changes – the name is still whatever the attacker wrote, and the field is still synthesized on demand.

New file app/graphql/resolvers/nil_resolver.rb:

image

image

How it Mitigates

The fix does not try to sanitize or restrict the field name, which stays fully attacker-controlled. It removes the dispatch instead. By pinning the synthesized field to an explicit “resolver_class,” graphql-ruby resolves the field by invoking that resolver rather than falling back to calling a method of the field’s name on the underlying object, and “NilResolver#resolve” returns nil unconditionally regardless of what the attacker named. The fallback keeps its original purpose – a missing future field still resolves to null during a rolling deploy – while the path from a query string to an arbitrary method call on a server-side object no longer exists.

CVE-2026-19650

Mechanism

“IntroducedTracer” backs the “@gl_introduced” directive by holding onto the pre-filter query document, so that execution can fall back to null for fields the running version does not have. It kept that state in two plain instance variables on the tracer: “@original_query_document” and “@contain_future_fields.”

One instance variable holds one document, but a GraphQL multiplex request carries several queries through the same tracer. “parse” runs once per query in the batch, so each call overwrites the document stored by the previous one. By the time “execute_query” runs for any individual query, the stored document belongs to whichever query in the batch parsed last, and it is force-installed onto the executing query with “instance_variable_set(:@document, …)” followed by “prepare_ast.”

The consequence is that the document a query is validated as is not the document it executes as. That breaks the assumption behind GitLab’s GET protection: GraphQL over GET is allowed for read-only operations and mutations are rejected, but the rejection is decided from the parsed query while execution proceeds from the swapped-in document. A batch whose first query is a benign read and whose second carries a mutation marked with “@gl_introduced” passes the check and executes the mutation, yielding a cross-site request forgery primitive over GET — matching the “UI:R” in the vector, since the victim’s browser has to be induced to issue the request.

Vulnerable code

lib/gitlab/graphql/version_filter/introduced_tracer.rb#L8-28 (v19.2.2-ee)

image

image

“@original_query_document” is a single slot on the tracer that “parse” rewrites for every query it handles, and “execute_query” reads it without any check that the stored document belongs to the query in front of it. The “@contain_future_fields” flag has the same problem, so one query in a batch containing a future field switches the substitution on for all of them.

The Patch

Commit d2ea4b97 – “Prevent query swapping with multiplexed queries and gl-introduced”

image

image

“initialize” introduces a per-tracer hash, “parse” writes each query’s original document under the filtered document it produced, and “execute_query” retrieves the entry for “query.document” rather than reading a shared slot. The substitution is also wrapped in a conditional instead of an early return, so a query with no matching entry now falls through to normal execution.

How it Mitigates

The fix replaces the shared per-tracer state with “@introduced_tracer_data,” a hash keyed by the filtered document that “parse” produced for each query. “execute_query” then looks up “query.document” and recovers the original document belonging to the query actually executing, so a batch of any size no longer has queries overwriting each other’s state. Substitution is also narrowed: it happens only when a matching entry exists and that specific query contained future fields, and otherwise the query executes with the document it was parsed and validated as. Since every query now executes the document it was checked against, the mutation-over-GET rejection applies to what actually runs.

The post Critical and High-Severity GraphQL CVEs in GitLab: Code Injection and CSRF via One Directive appeared first on OX Security.

readers loved this