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.

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.
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
endO 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.

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.
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 libSe 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.
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)
endPara 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.