Skip to content
This page has been auto-translated and may contain errors.View in English

Identificación de clientes

Un límite de cinco solicitudes por minuto no significa nada hasta que dices cinco por qué.

La limitación de tasa solo funciona si puedes identificar de forma consistente quién hace cada solicitud, y el identificador que elijas decide cuatro cosas a la vez:

  • Equidad. ¿Estás limitando a la entidad correcta?
  • Efectividad. ¿Alguien puede evadirlo?
  • Experiencia. ¿Se está castigando a usuarios legítimos?
  • Seguridad. ¿Se puede abusar del propio identificador?

La pregunta detrás de todo límite de tasa: ¿a quién o qué estás tratando de limitar realmente?

Dirección IP

La opción por defecto, la que obtienes sin pedirla. express-rate-limit usa la dirección del cliente a menos que le indiques lo contrario, que es justo lo que ha estado haciendo el limitador del capítulo anterior.

Sirve para endpoints públicos sin inicio de sesión, prevención básica de abuso, y frenar inundaciones simples.

Su atractivo es que siempre está disponible. No necesita autenticación, funciona con tráfico anónimo, es simple, y un usuario común no puede cambiarla a voluntad.

El problema es que las direcciones se comparten y se mueven. Cientos de empleados detrás de una misma conexión de oficina parecen un solo cliente, igual que todos los usuarios de una biblioteca y grandes cantidades de usuarios móviles detrás de un mismo operador.

Mientras tanto, un usuario cuya dirección cambia recibe un presupuesto nuevo cada vez que se mueve, y IPv6 entrega muchas direcciones dentro de una sola subred.

Para hacer explícita esta elección en vez de heredarla:

js
import { rateLimit, ipKeyGenerator } from 'express-rate-limit'

const limiter = rateLimit({
  limit: 5,
  windowMs: 60000,
  keyGenerator: (req, res) => ipKeyGenerator(req.ip),
})

keyGenerator devuelve la cadena contra la que se cuenta una solicitud. Ese es el mecanismo que usa todo lo que sigue.

JunoDirección IP La trampa es que una dirección se siente como una persona, pero no lo es. Identifica una conexión a internet, y una conexión puede llevar a una sola persona o a todo un edificio.

Todos los problemas de esta estrategia vienen de esa brecha.

JunoDirección IP Detrás de un proxy o un balanceador de carga, req.ip es la dirección del proxy, así que todas las solicitudes parecen venir de un solo cliente y tu límite termina aplicándose al mundo entero a la vez.

Express necesita app.set('trust proxy', ...) para leer en su lugar la dirección reenviada. Configúralo con el número de proxies que realmente tienes, porque confiar en el encabezado sin verificar le permite a un cliente falsificar cualquier dirección que quiera y evadir el límite por completo.

JunoDirección IP IPv6 hace que limitar por dirección sea casi inútil si se aplica de forma ingenua, porque a un solo suscriptor normalmente se le asigna un /64, que son más direcciones que todo el internet IPv4 junto. Limitar por dirección significa limitar por una dirección que todavía no han usado.

La respuesta es limitar por prefijo. express-rate-limit 8 expone ipv6Subnet para esto, con un valor por defecto de 56, donde los números más bajos agrupan de forma más agresiva.

El helper ipKeyGenerator existe por la misma razón: normaliza las direcciones para que los clientes IPv4 e IPv6 se identifiquen de forma consistente. Escribir req.ip directamente en la clave es justo lo que deja de funcionar silenciosamente el día que llega tráfico IPv6.

ID de usuario autenticado

Una vez que alguien inicia sesión, sabes exactamente quién es, ya sea a partir de una sesión, un claim de JWT o una consulta a la base de datos.

Sirve para endpoints autenticados, cuotas por usuario, y planes por niveles.

Cada usuario real tiene su propio presupuesto, así que nadie interfiere con nadie más. Los sigue a través de dispositivos y redes, y cambiar de dirección no cambia nada, porque la identidad no es la conexión.

Lo que no puede hacer es proteger nada antes del inicio de sesión. El registro, el restablecimiento de contraseña y los endpoints públicos ocurren todos antes de que exista un ID de usuario, y esos son justo los endpoints donde el abuso suele aparecer.

js
const limiter = rateLimit({
  limit: 3,
  windowMs: 10000,
  keyGenerator: (req) => req.user.id,
})

app.get('/api/data', authenticate, limiter, handler)

El orden importa. Express ejecuta el middleware de izquierda a derecha, así que la autenticación tiene que establecer req.user antes de que el limitador lo lea. Si pones el limitador primero, lee undefined para todos, y termina agrupando todas las solicitudes en el mismo bucket.

JunoID de usuario autenticado Prueba con tres usuarios contra un límite de tres cada diez segundos y el efecto es inmediato. Cada uno tiene sus propios tres, y que uno choque contra el límite no afecta a los demás.

Eso es la equidad funcionando. Con direcciones, tres personas en la misma oficina estarían compartiendo un solo presupuesto de tres.

JunoID de usuario autenticado El orden del middleware es el error que hay que anticipar aquí, y falla silenciosamente. Un limitador que lee req.user.id antes de que se ejecute la autenticación, o lanza un error por undefined, o agrupa a todos bajo el mismo valor, lo que se traduce en un solo límite compartido para toda tu base de usuarios.

Ninguno de los dos casos aparece en una prueba del camino feliz, porque con un solo usuario en la prueba no hay diferencia que notar.

JunoID de usuario autenticado Los límites basados en usuario mueven el abuso un paso antes: si las cuentas son gratuitas y el registro es ilimitado, el identificador es tan barato de conseguir como una dirección. Proteger el registro con un límite basado en dirección es lo que cierra ese hueco, y es un buen argumento para usar ambos en vez de elegir uno.

Los límites también se convierten en parte del producto una vez que son por usuario. Los niveles gratuito y de pago con presupuestos distintos necesitan que el límite sea una función de la solicitud, no una constante, y la búsqueda del nivel del usuario termina en la ruta crítica de cada solicitud. Cachéala.

API key e ID de sesión

Dos identificadores más, cada uno adecuado para un tipo distinto de llamador.

API keyID de sesión
Sirve paraIntegraciones de terceros, APIs de negocio, planes por nivelesAplicaciones web con sesiones, carritos, checkout y flujos de formularios
FortalezasSe vincula a una aplicación específica, admite niveles, se puede revocar si hay abuso, es simple de rastrearFunciona tanto para usuarios con sesión como invitados, es más persistente que una dirección, sigue todo un recorrido
DebilidadesLas claves se comparten y se filtran, una sola clave puede servir a miles de usuarios finales, y requiere gestión de clavesBorrar las cookies la reinicia, aplica la fijación de sesión (session fixation), y no existe para APIs sin estado

Una API key hace que los niveles surjan naturalmente, porque limit puede ser una función de la solicitud:

js
import { getUserTier } from './services/billing.js'

const limiter = rateLimit({
  windowMs: 60000,
  keyGenerator: (req) => req.headers['x-api-key'],
  limit: (req) => (getUserTier(req.headers['x-api-key']) === 'pro' ? 1000 : 100),
})

Un ID de sesión se lee de la misma manera, desde req.session.id.

JunoAPI key e ID de sesión Cuatro identificadores, y ninguno es el correcto por sí solo. Cada uno identifica un tipo distinto de llamador.

Una API key identifica una aplicación. Un ID de usuario identifica a una persona. Una sesión identifica una visita. Una dirección identifica una conexión. Elige el que corresponda con lo que estás limitando.

JunoAPI key e ID de sesión La debilidad de la API key sorprende a la gente: una clave identifica la integración, no a la persona que la usa.

Un socio con cincuenta mil usuarios finales tiene una sola clave, así que un usuario malintencionado consume el presupuesto de todos y termina limitando la integración entera.

Si eso importa, la clave necesita un presupuesto propio más otro por usuario final dentro de ella, lo que implica pasar un identificador de usuario final a través de la integración.

JunoAPI key e ID de sesión Todo lo que un cliente controla se puede rotar, y los IDs de sesión son el peor caso: borrar las cookies no cuesta nada y otorga un presupuesto nuevo, así que los límites basados en sesión disuaden accidentes, no atacantes.

Hay dos cosas que vale la pena hacer sin importar en qué basas la clave. Nunca pongas un secreto sin procesar en la clave, ya que las claves del limitador terminan en logs y volcados del almacén, así que aplica un hash a la API key primero.

Y decide qué pasa cuando falta el identificador, porque si keyGenerator devuelve undefined, todas esas solicitudes se agrupan en un mismo bucket. Eso puede ser un comodín útil o un límite compartido por accidente, y debería ser el que tú elegiste.

Combinándolos

Los sistemas reales rara vez eligen uno solo. Lo habitual es armar una cadena con alternativas de respaldo, empezando por la más específica:

  1. ID de usuario autenticado, cuando alguien inició sesión.
  2. API key, para integraciones.
  3. ID de sesión, para invitados con una sesión.
  4. Dirección IP, como último recurso.

Cada escalón hacia abajo es menos preciso y más disponible, así que una solicitud siempre se cuenta contra el mejor identificador que realmente tiene.

JunoCombinándolos El orden va de lo más específico a lo más disponible, y vale la pena leerlo en ese sentido.

Una persona con sesión iniciada es lo más claro que puedes identificar. Una dirección es lo más vago, y lo único que siempre está presente.

JunoCombinándolos Una cadena de respaldo dentro de un mismo limitador tiene una falla que vale la pena conocer: todos los que terminan cayendo hasta la dirección comparten el presupuesto de ese nivel entre ellos, así que el tráfico anónimo compite consigo mismo.

Suele ser mejor usar limitadores separados con sus propios presupuestos: uno estricto para el tráfico anónimo, uno generoso para los usuarios autenticados, en vez de un solo limitador que cambia de clave.

JunoCombinándolos Ponle a la clave un prefijo con la estrategia que la produjo: user:4821, nunca un simple 4821. Sin eso, un ID de usuario y un ID de sesión pueden coincidir en la misma cadena y dos clientes sin relación terminan compartiendo un presupuesto.

Lo otro que esconde la cadena es que es una ruta de escalamiento: un atacante opera en el nivel que le resulte más barato de conseguir.

Así que la cadena es tan fuerte como su eslabón más débil, y los límites deberían volverse más estrictos a medida que los identificadores se vuelven más anónimos, en vez de mantenerse uniformes a lo largo de la lista.

Ponlo a prueba

Una API limita 100 solicitudes por minuto por dirección IP. Llegan tres quejas.

  1. Una empresa con 400 empleados dice que la aplicación deja de funcionar todas las tardes.
  2. Un usuario móvil dice que el límite nunca parece aplicarle.
  3. Alguien está scrapeando el catálogo público a un ritmo de aproximadamente 10.000 solicitudes por hora y nada lo detiene.
Compara tus respuestas
#Qué está pasandoSolución
1400 personas comparten una sola dirección de salida, así que 400 personas comparten 100 solicitudes por minutoBasar la clave en el ID de usuario autenticado, dejando el límite por dirección solo para el tráfico anónimo
2Su operador los mueve entre direcciones, así que cada cambio les da un presupuesto nuevoLa misma solución. Una identidad que sigue a la persona no se puede eludir reconectándose
3El catálogo es público, así que no hay usuario contra quién basar la clave, y las direcciones le cuestan casi nada al scraperLímites anónimos más estrictos, basados en un prefijo IPv6 en vez de una sola dirección, además de algo aguas arriba. Este caso se reduce en vez de resolverse

El uno y el dos son el mismo defecto visto desde ambos lados: la dirección es demasiado amplia para la oficina y demasiado estrecha para el usuario móvil.

El tres es el límite honesto de toda la técnica. La limitación de tasa hace que el abuso tenga un costo, y contra un endpoint público ese costo nunca es más alto que el de tu identificador más barato.

Hacia dónde va esto ahora

El limitador cuenta correctamente y contra lo correcto. Lo que todavía le queda es el estallido en el borde de la ventana fija, que deja pasar el doble del límite alrededor de un reinicio.

Algoritmos de ventana deslizante resuelve eso, midiendo contra una ventana que se mueve con la solicitud en vez de un bloque fijo en el reloj.