VORVEX

GHSA-j7xr: quando negar autorização ainda executa a mutation

O hook de autorização negava o acesso, mas o resolver ainda podia executar, devolver dados e produzir efeitos colaterais. Entenda o bypass corrigido no graphql-ruby 2.6.6.

Por Equipe Vorvex4 min de leitura

Uma checagem de autorização pode executar, negar o acesso e ainda assim deixar a operação protegida acontecer. Foi exatamente esse o comportamento encontrado no graphql-ruby quando uma aplicação combinava o runner Execution::Next com resolvers cujo hook authorized? levantava GraphQL::UnauthorizedError. O erro era capturado, mas o estado interno passava a representar autorização concedida. Em seguida, resolve era chamado normalmente.

O advisory do mantenedor, GHSA-j7xr-4g94-r9h3, classifica a falha como High e não associa um CVE. A análise técnica do GitHub Security Lab usa o identificador GHSL-2026-152. O intervalo afetado confirmado é graphql-ruby 2.5.23 até 2.6.5, e a correção foi publicada na 2.6.6 em 21 de julho de 2026. A divulgação técnica detalhada do GHSL saiu em 8 de agosto.

Technical flow diagram showing a denied GraphQL authorization check incorrectly continuing into resolver execution and a database write
A decisão de negar existia, mas o fluxo de controle a convertia em autorização antes de chamar o resolver.

O recorte exato da falha

Não era um bypass genérico de toda autorização do GraphQL. A aplicação precisava usar GraphQL::Execution::Next e o campo precisava passar por GraphQL::Schema::Resolver ou uma subclasse, como Mutation, RelayClassicMutation ou Subscription. Dentro desse resolver, authorized? precisava negar o acesso levantando GraphQL::UnauthorizedError. Outros mecanismos de autorização continuavam sendo aplicados corretamente, segundo o advisory.

  • Versões afetadas: graphql-ruby >= 2.5.23 e <= 2.6.5
  • Versão corrigida: graphql-ruby 2.6.6 ou posterior
  • Configuração necessária: use GraphQL::Execution::Next
  • Caminho vulnerável: authorized? levanta GraphQL::UnauthorizedError em um Resolver ou subclasse
  • Impactos possíveis: leitura indevida, alteração de dados, chamadas externas e elevação de privilégio

Uma exceção transformada em autorização

No caminho vulnerável de Resolver#call, o retorno normal de authorized? era separado em um booleano e um valor alternativo. Quando o hook levantava GraphQL::UnauthorizedError, o rescue chamava schema.unauthorized_object, cujo comportamento padrão retorna nil, mas marcava o booleano de autorização como true. A próxima condição via true e chamava call_resolve. O código de negócio não sabia que a autorização havia falhado.

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
Padrão mínimo que merece revisão. Na faixa vulnerável, a exceção do hook podia não impedir a execução de resolve.

O exemplo é deliberadamente pequeno, mas o impacto real cresce com o resolver. Uma query pode devolver dado restrito. Uma mutation pode gravar no banco, apagar um objeto ou chamar um provedor externo. Uma subscription pode abrir um fluxo de eventos que o usuário não deveria acompanhar. Mesmo quando a resposta final parece vazia, um efeito colateral dentro de resolve já pode ter ocorrido.

Split technical diagram comparing a vulnerable GraphQL authorization branch with a corrected fail-closed branch
Na correção, negar autorização encerra o caminho antes do resolver. O teste precisa provar também que nenhum efeito colateral ocorreu.

Como confirmar exposição sem atacar produção

A triagem começa no código e no lockfile. Primeiro confirmamos a versão realmente resolvida da gem, não apenas a restrição escrita no Gemfile. Depois procuramos a ativação de Execution::Next, chamadas a execute_next e implementações de authorized? que levantam GraphQL::UnauthorizedError. Essa combinação reduz muito o universo de revisão antes de qualquer requisição dinâmica.

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
Triagem local e somente leitura para confirmar versão e localizar a combinação de runner e hook afetada.

Se a combinação existir, reproduzimos em uma suíte de teste ou ambiente isolado com um usuário explicitamente sem permissão. A evidência precisa registrar três coisas: o hook negou, o campo não retornou o dado e o efeito colateral não aconteceu. Conferir apenas o status HTTP é insuficiente em GraphQL, onde erros de campo costumam viajar dentro de uma resposta 200.

O teste de regressão que fecha o buraco

O melhor teste não depende só do conteúdo da resposta. Ele observa o estado antes e depois da mutation. Assim, uma mudança futura no formato de erro não mascara uma regressão que voltou a executar resolve. O exemplo abaixo é intencionalmente genérico e deve ser adaptado aos nomes do schema e às factories do projeto.

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
Teste defensivo: a autorização negada precisa impedir o efeito colateral, não apenas esconder o retorno do campo.

Para queries, a mesma ideia vale com rastreamento do resolver e asserção sobre ausência de dados. Para integrações externas, substituímos a chamada real por um spy e exigimos que ele não seja acionado. O objetivo é testar a propriedade de segurança, uma decisão negativa interrompe a execução, em vez de testar apenas a aparência da resposta.

Correção e contenção

A correção recomendada é atualizar graphql-ruby para 2.6.6 ou posterior. O projeto já publicou versões posteriores, mas 2.6.6 é o primeiro piso corrigido confirmado pelo advisory. Como contenção temporária, o GHSL observa que uma implementação customizada de unauthorized_object que também levanta uma exceção interrompe o caminho vulnerável. Isso reduz exposição, mas não deve substituir a atualização e os testes de regressão.

  • Atualize a gem e confirme GraphQL::VERSION no processo implantado
  • Execute a suíte de autorização com o mesmo runner usado em produção
  • Revise mutations com escrita, deleção, envio de mensagem ou chamada de API externa primeiro
  • Procure logs de operações sensíveis feitas por identidades sem o papel necessário
  • Se houver indício de abuso, trate a atualização como contenção e preserve evidências para resposta a incidente

A lição para um pentest GraphQL

Um teste superficial valida que a API produziu um erro de autorização. Um pentest útil verifica se a execução realmente parou. Em GraphQL, isso significa observar resolvers, alterações de estado, filas, webhooks e chamadas externas, além do JSON devolvido ao cliente. GHSA-j7xr-4g94-r9h3 mostra por que o controle de acesso precisa ser testado como fluxo de execução, não como mensagem de erro.

← Voltar ao blog

Quer essa mesma profundidade aplicada ao seu ambiente?

Conte o que você precisa validar e a equipe monta um escopo de pentest sob medida.

Falar no WhatsApp

Pronto para avaliar o risco da sua empresa?