Disclosure · Data exposure in an API
The API field that should never have been public
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.