VORVEX

GHSA-j7xr: when denying authorization still runs the mutation

The authorization hook denied access, but the resolver could still execute, return data, and cause side effects. Understand the bypass fixed in graphql-ruby 2.6.6.

By Equipe Vorvex4 min read

An authorization check can run, deny access, and still allow the protected operation to happen. That was exactly the behavior found in graphql-ruby when an application combined the Execution::Next runner with resolvers whose authorized? hook raised GraphQL::UnauthorizedError. The error was caught, but the internal state was changed to represent granted authorization. The framework then called resolve as usual.

The maintainer advisory, GHSA-j7xr-4g94-r9h3, rates the flaw High and does not associate it with a CVE. GitHub Security Lab's technical analysis uses the identifier GHSL-2026-152. The confirmed affected range is graphql-ruby 2.5.23 through 2.6.5, and the fix was released in 2.6.6 on July 21, 2026. GHSL published its detailed technical disclosure on August 8.

Technical flow diagram showing a denied GraphQL authorization check incorrectly continuing into resolver execution and a database write
The deny decision existed, but control flow converted it into authorization before calling the resolver.

The exact scope of the flaw

This was not a generic bypass of every GraphQL authorization mechanism. The application had to use GraphQL::Execution::Next, and the field had to go through GraphQL::Schema::Resolver or a subclass such as Mutation, RelayClassicMutation, or Subscription. Inside that resolver, authorized? had to deny access by raising GraphQL::UnauthorizedError. According to the advisory, other authorization mechanisms were still applied correctly.

  • Affected versions: graphql-ruby >= 2.5.23 and <= 2.6.5
  • Patched version: graphql-ruby 2.6.6 or later
  • Required configuration: use GraphQL::Execution::Next
  • Vulnerable path: authorized? raises GraphQL::UnauthorizedError in a Resolver or subclass
  • Possible impact: unauthorized reads, data changes, external calls, and privilege escalation

An exception turned into authorization

In the vulnerable Resolver#call path, the normal return from authorized? was split into a boolean and an alternate value. When the hook raised GraphQL::UnauthorizedError, the rescue block called schema.unauthorized_object, whose default behavior returns nil, but set the authorization boolean to true. The next condition saw true and called call_resolve. Business code had no indication that authorization had failed.

ruby
class DeleteAccount < GraphQL::Schema::Mutation
  type Boolean, null: false

  def authorized?(account_id:)
    raise GraphQL::UnauthorizedError unless context[:current_user]&.admin?
    true
  end

  def resolve(account_id:)
    Account.find(account_id).destroy!
    true
  end
end

class ApiSchema < GraphQL::Schema
  use GraphQL::Execution::Next
end
A minimal pattern worth reviewing. In the vulnerable range, the hook exception might not prevent resolve from running.

The example is deliberately small, but real impact grows with the resolver. A query can return restricted data. A mutation can write to the database, delete an object, or call an external provider. A subscription can open an event stream the user should not observe. Even when the final response looks empty, a side effect inside resolve may already have happened.

Split technical diagram comparing a vulnerable GraphQL authorization branch with a corrected fail-closed branch
In the fix, denying authorization ends the path before the resolver. Testing must also prove that no side effect occurred.

How to confirm exposure without attacking production

Triage starts in the code and lockfile. First, we confirm the gem version actually resolved at runtime, not only the constraint written in the Gemfile. Then we look for Execution::Next activation, execute_next calls, and authorized? implementations that raise GraphQL::UnauthorizedError. That combination sharply narrows the review before any dynamic request is sent.

bash
bundle exec ruby -e 'require "graphql"; puts GraphQL::VERSION'

rg -n 'GraphQL::Execution::Next|execute_next' app lib config
rg -n 'def authorized\?|GraphQL::UnauthorizedError' app lib
Local, read-only triage to confirm the version and locate the affected runner and hook combination.

If the combination exists, we reproduce it in a test suite or isolated environment with an explicitly unauthorized user. Evidence must capture three facts: the hook denied access, the field did not return the data, and the side effect did not happen. Checking only the HTTP status is insufficient in GraphQL, where field errors commonly travel inside a 200 response.

The regression test that closes the gap

The best test does not rely only on response content. It observes state before and after the mutation. This way, a future change to the error format cannot hide a regression that executes resolve again. The example below is intentionally generic and should be adapted to the project's schema names and factories.

ruby
it "does not execute a denied mutation" do
  account = create(:account)
  denied_user = create(:user, admin: false)

  expect {
    ApiSchema.execute_next(
      "mutation($id: ID!) { deleteAccount(accountId: $id) }",
      variables: { id: account.id },
      context: { current_user: denied_user }
    )
  }.not_to change(Account, :count)

  expect(Account.exists?(account.id)).to be(true)
end
Defensive test: denied authorization must prevent the side effect, not merely hide the field's return value.

For queries, the same idea applies with resolver tracing and an assertion that data is absent. For external integrations, replace the real call with a spy and require that it is not invoked. The goal is to test the security property, a negative decision stops execution, instead of testing only how the response looks.

Fix and containment

The recommended fix is upgrading graphql-ruby to 2.6.6 or later. The project has already published newer releases, but 2.6.6 is the first fixed floor confirmed by the advisory. As temporary containment, GHSL notes that a custom unauthorized_object implementation that also raises an exception interrupts the vulnerable path. This reduces exposure, but it should not replace the update and regression tests.

  • Update the gem and confirm GraphQL::VERSION in the deployed process
  • Run the authorization suite with the same runner used in production
  • Review mutations that write, delete, send messages, or call external APIs first
  • Search logs for sensitive operations performed by identities without the required role
  • If abuse is suspected, treat the update as containment and preserve evidence for incident response

The lesson for a GraphQL pentest

A shallow test confirms that the API produced an authorization error. A useful pentest checks whether execution actually stopped. In GraphQL, that means observing resolvers, state changes, queues, webhooks, and external calls in addition to the JSON returned to the client. GHSA-j7xr-4g94-r9h3 shows why access control must be tested as execution flow, not as an error message.

← Back to blog

Want that same depth applied to your environment?

Tell us what you need to validate and the team scopes a penetration test to match.

Talk on WhatsApp

Ready to assess your company's risk?