From n00b to ZeroCool / El lado oscuro

API Security: cuando el backend confía demasiado

Guía defensiva para cortar la confianza excesiva en APIs: auth vs authz, validación estricta, logs útiles, rate limits y tests anti-IDOR.

Lo que vale la pena leer aquí

El frontend juraba que no. “La app ni tiene esa pantalla”. El móvil decía “imposible”. Y el backend… el backend confiaba.

Intro con gancho

Viernes, 6:40 pm. Ya tenía la laptop medio cerrada (de esas que ya suenan raro el ventilador) cuando cayó el Slack del jefe: “Oye… ¿por qué un usuario está viendo pedidos de otra cuenta?”.

El frontend juraba que no. “La app ni tiene esa pantalla”. El móvil decía “imposible”. Y el backend… el backend confiaba.

Confiaba en que el cliente no miente. En que si llega userId, viene “bien”. En que “nadie va a adivinar ese endpoint”. En que “si no está en la UI, no existe”.

Spoiler: sí existe. Y si existe, alguien lo va a tocar.

Qué te vas a llevar

  • Cómo se ve la confianza excesiva en una API (señales reales de production).
  • Diferencia práctica entre autenticación (quién eres) y autorización (qué puedes hacer) aterrizada a endpoints.
  • Un threat model express para APIs sin ceremonias ni PowerPoints.
  • Un workflow defensivo: validación, controles de acceso, rate limits, logs útiles y pruebas que sí cachan el bug.
  • Errores típicos del jale y cómo corregirlos sin romper el deploy ni pedir rollback a media noche.

Contexto práctico: el lado oscuro de “mi backend ya valida”

Muchos backends en LatAm crecen a empujones: primero el MVP, luego llega un cliente grande, luego una integración “para ayer”, luego un cambio “rápido” porque hay deadline… y terminas con:

  • Un monolito con endpoints viejos que nadie quiere voltear a ver.
  • Un API gateway a medias (porque el presupuesto se fue en features).
  • Un Android con contrato viejo porque el update se quedó atorado.
  • Y un backend que aceptó “temporalmente” que el cliente mande role, price o userId.

La combinación pega duro porque una API es un contrato expuesto. Aunque le pongas “privada”, si una app la consume, alguien la puede observar. No se necesita ser villano de película: con DevTools, Postman o un proxy en el cel, sobra.

La mentalidad de The Hacker Ethic va por ahí: pensar como atacante sin pasarte de lanza. Entender cómo se rompen las cosas para que no te revienten a ti en guardia.

Paso a paso: hardening defensivo para APIs que confían demasiado

1) Ubica dónde tu backend está “creyendo” (inventario de confianza)

Antes de meter mano al código, ubica dónde estás aceptando “verdades” del cliente.

Checklist rápido:

  • ¿Recibes userId, accountId, tenantId en body o query?
  • ¿Recibes role, isAdmin, price, discount, status desde el cliente?
  • ¿Permisos dependen de headers custom raros?
  • ¿Hay endpoints “internos” accesibles desde Internet porque la app los llama?

Ejercicio de 10 minutos que sí rinde: busca en tu repo req.body.userId, req.query.userId, accountId, tenantId, role.

Decisión práctica: si el backend usa IDs del cliente para decidir acceso, ya estás rozando un bug clásico: BOLA/IDOR (Broken Object Level Authorization / Insecure Direct Object References). Traducción al español del incidente: “vi pedidos que no eran míos”.

2) Deja de mezclar autenticación con autorización

Autenticación: validar token/sesión. “Sí eres tú.”

Autorización: validar permiso sobre un recurso. “Sí puedes ver ese pedido.”

La confianza excesiva se cuela cuando haces:

  • “Trae JWT válido, pásale” (auth) sin validar ownership/tenant/scope del recurso (authz).

Ejemplo de toda la vida:

  • GET /orders/12345 responde para cualquiera logueado, aunque el pedido sea de otra cuenta.

Regla de oro: el backend deriva el sujeto (usuario/tenant) del token o sesión, y con eso filtra/autoriza. No al revés.

3) Threat model express (modo: no-consultor)

Esta versión “de banqueta” es suficiente para que el equipo la adopte:

  • Actores: usuario normal, usuario curioso, integraciones, soporte con tooling.
  • Activos: PII, facturación, inventario, tokens, logs.
  • Rutas críticas: “ver”, “editar”, “pagar”, “reembolsar”, “admin”, “exportar”.
  • Abusos probables: cambiar id en URL, mandar campos extra, spam/repetición, scraping, enumeración.

Si manejas multi-tenant (SaaS típico), el peor bug es cruzar tenants. Con uno solo, ya tienes incidente serio.

Decisión práctica: elige 3 endpoints de alto impacto y arréglalos primero. Intentar “asegurar todo” en una semana solo te deja quemado y con producción inestable.

4) Autorización a nivel objeto (BOLA) con patrones repetibles

Patrón A: “scoping” en la query (filtra por usuario/tenant)

En vez de buscar por ID a secas:

  • Mal: SELECT * FROM orders WHERE id = :orderId
  • Bien: SELECT * FROM orders WHERE id = :orderId AND user_id = :userId

El userId sale del token, no del body.

Patrón B: policy/middleware centralizado (para no olvidar rutas)

Si tu stack lo permite, haz una policy única por recurso.

Ejemplo conceptual (Node/Express, pseudocódigo):

// NOTA: ejemplo defensivo. No es para "burlar" nada.
function requireOrderAccess({ getOrderById }) {
  return async (req, res, next) => {
    const userId = req.auth.userId; // derivado del token
    const orderId = req.params.orderId;

    const order = await getOrderById(orderId);
    if (!order) return res.status(404).send({ error: "Not found" });

    if (order.userId !== userId && !req.auth.scopes.includes("orders:read:any")) {
      return res.status(403).send({ error: "Forbidden" });
    }

    req.order = order;
    next();
  };
}

Tradeoff real: centralizar te salva de inconsistencias, pero requiere disciplina (si alguien crea un route “rápido” sin el middleware, reabriste el hoyo).

Patrón C: permisos por acción (RBAC/ABAC simple)

  • orders:read → solo lo tuyo
  • orders:read:any → soporte/admin
  • orders:refund → finanzas

Ojo: no metas roles en el JWT “para siempre” si cambian seguido. Si cambian, valida contra una fuente actual (cache con TTL o introspección), o te quedas con permisos zombies.

API Security: cuando el backend confía demasiado - visual explicativa 1
Visual de apoyo: Intro con gancho

5) Validación estricta: bloquea “campos sorpresa”

La confianza excesiva también vive en “acepto lo que sea y lo guardo”.

Dos prácticas defensivas que pagan rápido:

  1. Validación estricta (tipo, longitud, rangos, formato)
  • Emails: longitud razonable
  • IDs: UUID/numérico según aplique
  • Montos: >= 0, <= límite, precisión
  1. Bloquear propiedades extra
    Bug clásico: el cliente manda {"isAdmin": true} y tu ORM hace merge como si nada.

Si usas JSON Schema (Ajv) o Zod, activa modo estricto.

Ejemplo (Zod, conceptual):

import { z } from "zod";

const UpdateProfile = z.object({
  displayName: z.string().min(1).max(60),
  phone: z.string().max(20).optional(),
}).strict(); // rechaza campos extra

Decisión práctica: en endpoints de update/patch, aplicar .strict() / additionalProperties: false te elimina una clase completa de bugs de mass assignment.

6) Rate limits y anti-enumeración (cuando tu API se deja “rascar”)

Si tu endpoint permite probar IDs en secuencia (/orders/1, /orders/2, …) sin fricción, te enumeran aunque haya auth.

Medidas defensivas:

  • Rate limit por IP + por usuario (no solo IP; acá es común que muchos salgan por la misma NAT en oficina/cowork).
  • Respuestas consistentes: no regales señales de si “existe” algo.
    • En recursos sensibles, 404 puede ser mejor que 403 si el usuario no debería inferir existencia.
  • IDs no triviales: UUID/ULID ayudan, pero no sustituyen autorización.

Tradeoff real: rate limiting mal puesto tumba clientes legítimos (apps con retry agresivo, integraciones con mala conexión). Ajusta con métricas, no con corazonadas.

7) Logs que sirven cuando truena (no para llenar disco)

Cuando el cliente dice “alguien vio pedidos ajenos”, necesitas trazabilidad, no poesía.

Log mínimo por request (sin datos sensibles):

  • request_id
  • user_id
  • tenant_id
  • route, method, status
  • resource_id (si aplica)
  • motivo de denegación (p.ej. authz_failed_order_owner_mismatch)

Si estás en equipo chico: un request_id que viaje del gateway al backend te ahorra horas de “¿en qué servidor pasó?” mientras tu internet anda fallando.

Escena real: el log “User unauthorized” sin contexto no ayuda cuando estás en guardia, con la banda pidiendo explicación y un deploy pendiente.

8) Pruebas que cachan BOLA/IDOR y mass assignment

No necesitas un pentest para lo básico. Necesitas tests con mentalidad adversaria.

Casos mínimos automatizables:

  • Usuario A intenta leer recurso de Usuario B → 403/404
  • Usuario A intenta actualizar campos no permitidos (role, isAdmin, status) → 400
  • Usuario sin scope intenta acción privilegiada → 403

Ejemplo conceptual (sin tooling ofensivo):

// pseudo-test
it("bloquea lectura cruzada de pedidos", async () => {
  const orderOfB = await seedOrder({ user: "B" });
  const tokenA = await loginAs("A");

  const res = await api.get(`/orders/${orderOfB.id}`, { token: tokenA });
  expect([403, 404]).toContain(res.status);
});

Si tu equipo es de los que “no hay tiempo para tests”, arranca con 3 para endpoints críticos. Tres. Lo demás se negocia después.

Screenshots sugeridos

  • Captura del panel de logs (Kibana/CloudWatch/Datadog) filtrando por request_id y user_id durante un incidente.
  • Captura de tu API gateway/WAF mostrando rate limiting por ruta.
  • Captura de una prueba automatizada fallando en CI por intento de acceso cruzado.
  • Captura del esquema/validador (Zod/JSON Schema) marcando campos extra rechazados.

Errores comunes + solución

  1. “El frontend no manda eso”
  • Problema: asumes que UI = única fuente.
  • Solución: backend deriva identidad del token y valida autorización por recurso.
  1. “Pero ya hay JWT”
  • Problema: confundes auth con authz.
  • Solución: policies/middlewares por endpoint que verifiquen ownership/scope.
  1. Mass assignment por ORM
  • Problema: update(req.body) o merge directo.
  • Solución: whitelist de campos y validación estricta; rechaza propiedades extra.
  1. Rate limiting solo por IP
  • Problema: NAT/Carrier-grade NAT; castigas a todos o no frenas al atacante autenticado.
  • Solución: limita por user + IP + ruta; monitorea falsos positivos.
  1. Logs con datos sensibles
  • Problema: terminas filtrando tokens, PII o payloads enteros.
  • Solución: redacta/mascara; loguea IDs y motivos, no secretos.
API Security: cuando el backend confía demasiado - visual explicativa 2
Visual de apoyo: Qué te vas a llevar

Checklist final

  • Identidad del usuario sale del token/sesión, no del body/query.
  • Cada endpoint crítico tiene autorización por objeto (ownership/tenant/scope).
  • Validación estricta de input (tipo/longitud/rangos) y bloqueo de campos extra.
  • Updates usan whitelist explícita de propiedades permitidas.
  • Rate limits por usuario + IP + ruta, con métricas y alertas.
  • Respuestas no filtran existencia de recursos sensibles.
  • Logs incluyen request_id, user_id, tenant_id, motivo de denegación.
  • Tests mínimos: lectura cruzada, update de campos prohibidos, scopes.

FAQ

1) ¿Si uso UUID ya estoy a salvo de IDOR?

Ayuda contra enumeración simple, pero no reemplaza autorización. Si alguien obtiene un UUID (por logs, referers, leaks o compartir links), sin authz igual entra.

2) ¿Qué es más sano: responder 403 o 404 cuando no tiene acceso?

Depende del riesgo de inferencia. En recursos sensibles (usuarios, facturas), 404 reduce “confirmación de existencia”. En paneles internos, 403 puede ser más claro para debugging. Define una política y sé consistente.

3) ¿Cómo manejo soporte que necesita ver datos de usuarios sin abrir un hoyo?

Usa scopes/roles explícitos (read:any), registra auditoría (quién vio qué y por qué) y aplica least privilege. Si puedes, agrega “break-glass” con expiración y justificación.

4) ¿Dónde pongo la autorización: middleware, service, DB?

Lo ideal es en capas: middleware/policy para consistencia + scoping en queries en DB para que el bug sea más difícil de reintroducir. Si solo eliges una, prioriza scoping en DB.

5) ¿Qué señales en monitoreo me dicen que alguien está probando mi API?

Picos de 404/403 en rutas específicas, patrones secuenciales en IDs, alta tasa de requests por usuario y repetición de acciones fallidas. Si además ves rotación rápida de resource_id, huele a enumeración.

Siguiente episodio

La confianza excesiva no solo es “dejar pasar cosas”. También es creer que lo que sale de tu API siempre se debe ver.

Siguiente: cómo evitar exposición excesiva de datos (la API que regresa de más y te mete en broncas).