Divulgación · Exposición de datos en API
El campo de API que nunca debió ser público
Un cliente —una plataforma con publicaciones y usuarios— tenía una trampa elegante de GraphQL: definís un tipo una vez y lo reutilizás en todos los contextos. Si ese tipo incluye un campo sensible, el campo empieza a filtrarse en lugares donde nunca lo pensaste.
El problema en una línea
El tipo UserProfile —el perfil público de quien interactúa— incluía
un campo phoneNumber sin ningún control de acceso.
Como la consulta del recurso es pública por diseño, cualquiera podía pedir el
teléfono de todos los participantes sin estar autenticado.
Cómo se explotaba
No hacía falta cuenta, token ni herramienta especial. Un único curl:
curl -X POST https://api.<dominio>/graphql \
-H "Content-Type: application/json" \
-d '{"query":"{ item(id: \"ID-DEL-RECURSO\") {
offers { amount profile { username phoneNumber } }
} }"}'
La respuesta traía, junto a cada oferta, el teléfono real del participante:
{
"data": { "item": { "offers": [
{ "amount": 1987, "profile": { "username": "[redactado]", "phoneNumber": "+54 9 XX XXXX-XXXX" } },
{ "amount": 2087, "profile": { "username": "[redactado]", "phoneNumber": "+54 9 XX XXXX-XXXX" } }
] } }
}
El fallo no distinguía entre recursos activos y cerrados: el historial
completo, de meses atrás, seguía devolviendo los teléfonos. Bastaba iterar
los slugs del sitemap.xml para extraer los teléfonos de toda la
plataforma en minutos.
Por qué ocurre: endpoint vs. campo
El error conceptual es proteger endpoints en lugar de
campos. La consulta del recurso debe ser pública
—mostrar publicaciones y ofertas es el negocio—, pero eso no significa que todos los
campos del tipo deban serlo. El resolver devolvía phoneNumber
porque el campo existía en el tipo, sin preguntarse quién preguntaba.
El mismo tipo UserProfile se usaba en el perfil propio
(autenticado), en el perfil público y en las ofertas. Un campo sensible en un
tipo reutilizado se filtra por defecto en el contexto más abierto.
La corrección
El control tiene que ir a nivel de campo, en el resolver:
// UserProfile.phoneNumber
phoneNumber: (parent, args, context) => {
if (context.userId && context.userId === parent.userId) {
return parent.phoneNumber; // solo el dueño lo ve
}
return null; // para el resto, no existe
}
Mejor aún: sacarlo del tipo público y moverlo a un tipo privado
(PrivateUserProfile) accesible solo por el propio usuario. Así el
campo no puede aparecer "de casualidad" en ningún contexto nuevo.
La lección
En GraphQL, la superficie de exposición no es el endpoint: es cada campo. Un tipo reutilizado es una promesa de que todos sus campos son seguros en todos sus contextos. Si uno no lo es, hay que sacarlo del tipo.