Divulgación · Exposición de datos en API

El campo de API que nunca debió ser público

Read in English

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.
← Volver a Seguridad