Disclosure · Data exposure in an API

The API field that should never have been public

Leer en español

A client —a platform with listings and users— had an elegant GraphQL trap: you define a type once and reuse it in every context. If that type includes a sensitive field, the field starts leaking in places you never thought about.

The problem in one line

The UserProfile type —the public profile of a user— included a phoneNumber field with no access control. Since the resource query is public by design, anyone could request every user's phone number without being authenticated.

How it was exploited

No account, token or special tool needed. A single curl:

curl -X POST https://api.<domain>/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ item(id: \"RESOURCE-ID\") {
        offers { amount profile { username phoneNumber } }
      } }"}'

The response returned each offer together with the user's real phone number:

{
  "data": { "item": { "offers": [
    { "amount": 1987, "profile": { "username": "[redacted]", "phoneNumber": "+54 9 XX XXXX-XXXX" } },
    { "amount": 2087, "profile": { "username": "[redacted]", "phoneNumber": "+54 9 XX XXXX-XXXX" } }
  ] } }
}

The flaw didn't distinguish active from closed resources: the full history, months old, still returned the phone numbers. Iterating the slugs from sitemap.xml was enough to extract every phone number on the platform in minutes.

Why it happens: endpoint vs. field

The conceptual mistake is protecting endpoints instead of fields. The resource query must be public —showing listings and offers is the business— but that doesn't mean every field on the type must be too. The resolver returned phoneNumber because the field existed on the type, without asking who was asking.

The same UserProfile type was used for the owner's profile (authenticated), the public profile and the offers. A sensitive field on a reused type leaks by default in the most open context.

The fix

Access control belongs at the field level, in the resolver:

// UserProfile.phoneNumber
phoneNumber: (parent, args, context) => {
  if (context.userId && context.userId === parent.userId) {
    return parent.phoneNumber;   // only the owner sees it
  }
  return null;                   // for everyone else, it doesn't exist
}

Better yet: remove it from the public type and move it to a private type (PrivateUserProfile) reachable only by the user themselves. Then the field can't show up "by accident" in any new context.

The lesson

In GraphQL, the exposure surface isn't the endpoint: it's every field. A reused type is a promise that all of its fields are safe in all of its contexts. If one isn't, take it off the type.
← Back to Security