¿Por qué las acciones del servidor de Next.js necesitan autorización dentro del cuerpo de cada función?
Un caso detallado de apropiación de cuentas muestra cómo las acciones del servidor Next.js sin autenticación exponen operaciones con privilegios, y dónde debe realizarse la verificación de autorización para evitarlo.
Fuentes: Unauthenticated Next.js Server Actions Leading to Zero Click Account Takeover
Las acciones del servidor de Next.js permiten que un componente React llame directamente a una función del servidor, sin necesidad de una ruta API intermedia. Esa comodidad elimina el único lugar al que muchas equipos suelen recurrir para realizar verificaciones de autorización: el manejador de rutas. Cuando dichas verificaciones no se incluyen dentro del cuerpo de la función junto con la lógica, cualquiera que pueda encontrar su identificador puede ejecutarla. El caso que se describe a continuación ilustra exactamente ese fallo en un portal de suscripciones real, donde dos acciones sin protección se encadenaron para lograr una toma de control de cuenta sin necesidad de hacer clic, y luego se presentan las reglas defensivas que podrían haber evitado esto.
Cómo se envían realmente las acciones del servidor
Bajo App Router, una Acción de Servidor es una función del servidor que se invoca directamente desde un componente. El framework asigna a cada acción un ID hexadecimal de 40 caracteres, lo incluye en el paquete del cliente y convierte la invocación en una solicitud POST que lleva un encabezado Next-Action. El detalle crucial es que las claves de envío provienen de ese encabezado y no del camino de la URL, y el cuerpo de la solicitud es simplemente los argumentos de la función pasados por posición.
Ese diseño tiene una consecuencia que es fácil pasar por alto. No existe un archivo de rutas al que aplicar middleware, por lo que no hay un punto crítico natural donde se ejecute una protección a nivel de framework antes de que se ejecute tu código. Si la verificación de autorización no está escrita dentro de la función misma, entonces no hay ninguna verificación en absoluto. Cualquiera que extraiga los IDs de acción del paquete enviado y reconstruya la estructura de los argumentos a partir de las llamadas minificadas puede reenviar la solicitud sin incluir ninguna cookie de sesión.
La primera acción: un correo entrante, un registro con privilegios saliente
En el portal en cuestión, una solicitud enviada sin cookies, sin encabezado de autorización y sin nada más que pudiera identificar al remitente llegó a la primera acción. El cuerpo de la solicitud contenía únicamente una dirección de correo electrónico y un valor booleano.
POST /particuliers/integrale-web-mensuel-2116 HTTP/2
Host: subdomain.target.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Next-Action: 42a24b711d12f695d798b371e38b584c551ebb6a
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
["victim@gmail.com",true]
La respuesta devolvió el registro completo de la cuenta correspondiente a ese correo electrónico.
{
"user_id": "auth0|000000000000000000000001",
"email": "researcher@example.com",
"identities": [
{ "provider": "auth0", "connection": "www-target-com" }
],
"app_metadata": {
"subscriber_number": "900000000",
"custom_user_id": "00000000–0000–4000–8000–000000000000"
}
}
La forma de ese payload es la pista clave. Campos como identities y app_metadata no son proporcionados por la API pública de autenticación de un proveedor de identidades; provienen de su API de administración, la interfaz administrativa a la que se accede con un token máquina-a-máquina que incluye permisos como leer y actualizar usuarios. Obtenerlos a través de una llamada sin autenticación implica que había un token con privilegios en el servidor, y una función anónima lo estaba utilizando silenciosamente. Además de ser un hallazgo en sí mismo, la respuesta también entregó los dos valores necesarios para la siguiente acción: un ID de usuario y un nombre de conexión.
La segunda acción: establecer la contraseña de cualquier usuario
La segunda acción aceptaba un objeto y una cadena de texto. El objeto contenía una contraseña y una conexión; la cadena era un ID de usuario. Al enviarla contra una cuenta de prueba, aún sin autenticación, se veía así.
POST / HTTP/2
Host: subdomain.target.com
Next-Action: 89abcdef0123456789abcdef0123456789abcdef
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
[{"password":"<REDACTED>","connection":"www-target-com"},"auth0|000000000000000000000001"]
La solicitud no demostró nada: no había cookie de sesión, ni conocimiento de la contraseña existente, ni token de restablecimiento ni confirmación por correo electrónico, y tampoco se mostró ningún mensaje de advertencia. El servidor respondió indicando éxito junto con el objeto de usuario actualizado. El defecto decisivo radica en la fuente del ID de usuario: es el segundo argumento, proporcionado por quien llama a la función, en lugar de leerse de una sesión o del claim de sujeto de un token firmado. La función modifica el cuenta que se le indique.
Misma acción, campo diferente, ruta de recuperación secuestrada
Dado que el primer argumento se pasa directamente a la función de actualización de usuarios del proveedor de identidad, el campo que se incluya en él es el que se modifica. Al intercambiar la contraseña por una dirección de correo electrónico, en su lugar se actualiza el correo del cuenta.
POST / HTTP/2
Host: subscribe.target.com
Next-Action: 89abcdef0123456789abcdef0123456789abcdef
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
[{"email":"attacker@example.com","connection":"www-target-com"},"auth0|000000000000000000000001"]
El resultado fue el mismo éxito, con la misma ausencia de cualquier verificación de propiedad. Esta variante es peor de lo que parece a primera vista: la dirección de una cuenta es donde se envían las restablecimientos de contraseña, por lo que una vez que pertenece a un atacante, el flujo de recuperación del verdadero propietario ya no lo lleva de vuelta a él. Dado que la contraseña se establece directamente en lugar de tomar prestada una sesión, el acceso resultante no vence por sí solo.
Las lecciones que enseña este caso
Las acciones del servidor son puntos finales de backend disfrazados de funciones, y merecen la misma desconfianza. Incluya la verificación de autorización en cada acción que manipule datos o estados privilegiados, y confirme al remitente antes de realizar cualquier otra acción. Obtenga la identidad del usuario que realiza la acción a partir de la sesión o de un token verificado, nunca a partir de un argumento controlado por el cliente; de este modo, una solicitud no puede indicar el ID de una víctima y esperar que el servidor obedezca. Mantenga los tokens de proveedores privilegiados alejados de rutas de código a las que se pueda acceder sin autenticación. Además, trate los IDs de las acciones como públicos, ya que se envían al navegador; la opacidad no constituye un control de acceso. Cualquier función que el cliente pueda nombrar es una función que un atacante puede llamar, y lo único que se interpone entre ellos y sus datos es la verificación que usted implementó en el código.
Lecturas relacionadas
- useOptimistic Rollback: Cinco modos de fallo en Next.js Server Actions — Aprenda por qué useOptimistic reversiona silenciosamente la interfaz de usuario sin explicar los fallos a los usuarios, a través de cinco modos de fallo probados en las acciones del servidor y una solución funcional.
- Depuración de Next.js Server Actions: Errores de despliegue, CORS y límites de carga — Una guía práctica para solucionar problemas que explica por qué las acciones del servidor y rutas API de Next.js fallan silenciosamente o generan errores enigmáticos, con soluciones concretas para cada caso.