Una verificación de autorización puede ejecutarse, negar el acceso y aun así permitir que ocurra la operación protegida. Ese fue exactamente el comportamiento encontrado en graphql-ruby cuando una aplicación combinaba el runner Execution::Next con resolvers cuyo hook authorized? lanzaba GraphQL::UnauthorizedError. El error era capturado, pero el estado interno cambiaba para representar una autorización concedida. Después, el framework llamaba a resolve normalmente.
El advisory del mantenedor, GHSA-j7xr-4g94-r9h3, clasifica la falla como High y no la asocia con un CVE. El análisis técnico de GitHub Security Lab usa el identificador GHSL-2026-152. El rango afectado confirmado es graphql-ruby 2.5.23 hasta 2.6.5, y la corrección se publicó en la versión 2.6.6 el 21 de julio de 2026. GHSL publicó su divulgación técnica detallada el 8 de agosto.

El alcance exacto de la falla
No era un bypass genérico de todos los mecanismos de autorización de GraphQL. La aplicación tenía que usar GraphQL::Execution::Next y el campo debía pasar por GraphQL::Schema::Resolver o una subclase, como Mutation, RelayClassicMutation o Subscription. Dentro de ese resolver, authorized? tenía que negar el acceso lanzando GraphQL::UnauthorizedError. Según el advisory, los demás mecanismos de autorización seguían aplicándose correctamente.
- Versiones afectadas: graphql-ruby >= 2.5.23 y <= 2.6.5
- Versión corregida: graphql-ruby 2.6.6 o posterior
- Configuración necesaria: use GraphQL::Execution::Next
- Ruta vulnerable: authorized? lanza GraphQL::UnauthorizedError en un Resolver o subclase
- Impactos posibles: lecturas indebidas, cambios de datos, llamadas externas y escalamiento de privilegios
Una excepción convertida en autorización
En la ruta vulnerable de Resolver#call, el retorno normal de authorized? se dividía en un booleano y un valor alternativo. Cuando el hook lanzaba GraphQL::UnauthorizedError, el bloque rescue llamaba a schema.unauthorized_object, cuyo comportamiento predeterminado devuelve nil, pero marcaba el booleano de autorización como true. La siguiente condición veía true y llamaba a call_resolve. El código de negocio no sabía que la autorización había fallado.
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
endEl ejemplo es deliberadamente pequeño, pero el impacto real crece con el resolver. Una query puede devolver datos restringidos. Una mutación puede escribir en la base, borrar un objeto o llamar a un proveedor externo. Una subscription puede abrir un flujo de eventos que el usuario no debería observar. Incluso cuando la respuesta final parece vacía, un efecto secundario dentro de resolve ya puede haber ocurrido.

Cómo confirmar exposición sin atacar producción
El triaje comienza en el código y en el lockfile. Primero confirmamos la versión de la gem realmente resuelta en ejecución, no solo la restricción escrita en el Gemfile. Después buscamos la activación de Execution::Next, llamadas a execute_next e implementaciones de authorized? que lanzan GraphQL::UnauthorizedError. Esa combinación reduce mucho el universo de revisión antes de enviar cualquier solicitud 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 libSi la combinación existe, la reproducimos en una suite de pruebas o en un entorno aislado con un usuario explícitamente sin permiso. La evidencia debe registrar tres hechos: el hook negó el acceso, el campo no devolvió los datos y el efecto secundario no ocurrió. Verificar solo el estado HTTP es insuficiente en GraphQL, donde los errores de campo suelen viajar dentro de una respuesta 200.
La prueba de regresión que cierra la brecha
La mejor prueba no depende solo del contenido de la respuesta. Observa el estado antes y después de la mutación. Así, un cambio futuro en el formato del error no oculta una regresión que vuelva a ejecutar resolve. El ejemplo siguiente es intencionalmente genérico y debe adaptarse a los nombres del schema y a las factories del proyecto.
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, la misma idea se aplica con rastreo del resolver y una aserción sobre la ausencia de datos. Para integraciones externas, sustituimos la llamada real por un spy y exigimos que no sea invocado. El objetivo es probar la propiedad de seguridad, una decisión negativa detiene la ejecución, en lugar de probar solo la apariencia de la respuesta.
Corrección y contención
La corrección recomendada es actualizar graphql-ruby a la versión 2.6.6 o posterior. El proyecto ya publicó versiones más nuevas, pero 2.6.6 es el primer piso corregido confirmado por el advisory. Como contención temporal, GHSL señala que una implementación personalizada de unauthorized_object que también lanza una excepción interrumpe la ruta vulnerable. Esto reduce la exposición, pero no debe sustituir la actualización ni las pruebas de regresión.
- Actualice la gem y confirme GraphQL::VERSION en el proceso desplegado
- Ejecute la suite de autorización con el mismo runner usado en producción
- Revise primero las mutaciones que escriben, borran, envían mensajes o llaman APIs externas
- Busque en los logs operaciones sensibles realizadas por identidades sin el rol necesario
- Si hay indicios de abuso, trate la actualización como contención y preserve evidencias para la respuesta a incidentes
La lección para un pentest GraphQL
Una prueba superficial confirma que la API produjo un error de autorización. Un pentest útil verifica si la ejecución realmente se detuvo. En GraphQL, eso significa observar resolvers, cambios de estado, colas, webhooks y llamadas externas, además del JSON devuelto al cliente. GHSA-j7xr-4g94-r9h3 muestra por qué el control de acceso debe probarse como flujo de ejecución, no como mensaje de error.