← All posts

Auditamos nuestro propio servidor de autenticación antes del lanzamiento. Los fallos tenían una forma.

Authagonal·September 3, 2026
securityauditsamloidcmfascimssrfwar-story

Antes de pedirle a nadie que nos confiara sus inicios de sesión, repasamos nuestro propio servidor de autenticación como lo haría un atacante: no ejecutándolo, sino leyéndolo. Lo que salió fue una revisión escrita de toda la superficie: dieciséis secciones numeradas, 261 líneas, desde SAML hasta la federación OIDC, SCIM, la API de administración, MFA y las herramientas de copia de seguridad, con cada hallazgo acompañado de una severidad y una ubicación en el código. Ocho quedaron marcados como arreglar primero. Arreglamos esos ocho en una noche y el resto de la lista media hora después. Dos semanas y media más tarde repetimos el mismo ejercicio sobre el plano de control multiinquilino y salieron cinco más, cerrados la noche antes de salir a producción.

Este es el número que se nos quedó grabado. La suite de tests reportaba 367 de 367 en verde antes de esa noche, y 367 de 367 después. Nada se puso en rojo, porque nada de la suite apuntaba a ninguno de esos casos. Los tests demuestran lo que se te ocurrió probar, y a nadie se le había ocurrido probar si quitar un atributo XML que la firma no cubre encaminaría una aserción reproducida por una rama más amable.

Leída como ocho fallos sin relación, una lista así no le sirve de nada a nadie más. Leída como formas, son tres hábitos, repetidos en varios subsistemas, en código escrito por gente que se había leído las especificaciones.

El client_id, la cabecera reenviada, el InResponseTo y el claim de email

El primer hábito es tratar un valor que escribe quien llama como un hecho sobre quien llama. Los dos que la revisión marcó como Críticos eran cada uno una sola entrada: un parámetro de consulta y un claim de email.

La política de MFA se derivaba del client_id extraído del returnUrl del formulario de inicio de sesión, y /connect/authorize no lo volvía a comprobar nunca. Apunta el inicio de sesión a un cliente con MFA desactivado y la sesión salía sin segundo factor, y luego se gastaba perfectamente en el cliente que sí lo exigía. Ese tiene su propio artículo.

Los límites por IP se basaban en la dirección remota, y las cabeceras reenviadas se aceptaban desde 0.0.0.0/0. Un valor de X-Forwarded-For por petición convertía cualquier control por IP en algo meramente orientativo, y de propina reescribía la columna de IP del registro de auditoría. Los detalles están aquí.

El consumidor de aserciones SAML elegía entre la ruta iniciada por el SP (consumir el id de la petición, un solo uso) y la ruta iniciada por el IdP (consultar una caché de ids de aserción) basándose únicamente en si la respuesta llevaba un atributo InResponseTo. Ese atributo va en el elemento <Response>, que no queda cubierto cuando solo se firma la aserción, que es el caso habitual. Quítalo y una respuesta capturada tomaba la rama cuya caché de repetición el inicio de sesión original nunca había rellenado, con todas las firmas verificando igual. Por qué la firma nunca fue el problema.

Y los usuarios federados que volvían se resolvían por el email de la aserción, así que cualquier conexión podía afirmar la dirección de otra persona y aterrizar en su cuenta. Ese también está contado.

La API de administración era una fábrica de su propia credencial

La autorización de administración dependía de exactamente una cosa: un token cuyo claim scope llevaba el scope de administración. Dos endpoints de administración metían ese scope en un token nuevo.

POST /api/v1/token, el endpoint de suplantación que usan las herramientas de soporte, dividía su parámetro de consulta scopes y copiaba el resultado tal cual en un access token y en un refresh token, sin comprobarlo contra los AllowedScopes registrados del cliente. Una sola credencial de administración, por efímera que fuese, acuñaba por tanto un refresh token de administración de larga vida para cualquier usuario y cualquier cliente: privilegio que sobrevive a rotar la credencial que te preocupaba. La creación de clientes, por su parte, enlazaba un objeto cliente en bruto directamente desde el cuerpo de la petición, ClientSecretHashes incluido, así que un administrador podía registrar un cliente client_credentials con el scope de administración y un hash de secreto que ya conocía. El listado y la consulta devolvían todos los hashes almacenados.

El endpoint de suplantación ahora rechaza el scope de administración y acota el resto con los propios AllowedScopes del cliente, la creación y la actualización enlazan un DTO sin campo de hash de secreto, y ninguna respuesta lleva un hash. Un permiso que puede emitirse a sí mismo no es un permiso.

Un guion, un grupo sin dueño y un email haciendo el trabajo de una identidad

El segundo hábito es dejar que algo que contiene un nombre haga las veces de una identidad.

El email era la clave de unión del inicio de sesión federado: global, presente en muchos proveedores, y lo que diga que es la conexión que hace la aserción. El arreglo fue resolver por el par de proveedor y sujeto, que ninguna otra conexión puede acuñar.

En el plano de control el nombre era el slug del inquilino. El router que convierte un slug en un prefijo de tabla de almacenamiento eliminaba los guiones, así que acme-corp y acmecorp producían el mismo prefijo, lo que significaba el mismo conjunto de tablas y la misma clave de firma de Vault. Dos inquilinos registrables por separado compartiendo un conjunto de tablas y una clave de firma. Registra el segundo y estás mirando a los usuarios del primero. El arreglo no fue una función de prefijo más ingeniosa. Fue prohibir los guiones en los slugs para que la correspondencia sea inyectiva, y merece la versión larga.

SCIM fue el que más escoció, porque la mitad estaba bien. Cada lectura y cada escritura de usuario volvía a comprobar que el recurso lo hubiera aprovisionado el cliente que llamaba. Los grupos, en el archivo de al lado, no tenían dueño ninguno: la creación nunca lo estampaba, el listado pasaba null como filtro de dueño y devolvía todos los grupos del entorno, y la consulta, el reemplazo, el patch y el borrado aceptaban cualquier id. Conocer el id de un grupo era toda la autorización, y donde varios clientes de aprovisionamiento comparten un entorno eso es lectura y escritura entre clientes de la pertenencia a grupos, que es como se conceden los roles. Los grupos ahora llevan el cliente que los creó, y cada lectura y cada escritura queda acotada a él.

El tercer hábito es hacer lo irreversible antes de ejecutar la comprobación.

El caso más claro es una funcionalidad que los clientes compran: un inquilino registra un webhook al que se le pregunta "¿se permite este inicio de sesión?" en el momento de la autenticación, y puede configurarlo para que la respuesta sea vinculante. El hook se ejecutaba en todas las rutas que establecen sesión, correctamente. Solo que se ejecutaba después de SignInAsync. Así que un rechazo lanzaba una excepción, la petición devolvía un 500, y el navegador ya tenía en la mano una cookie de sesión firmada. Cualquier cliente que ignorara el error quedaba dentro, bloqueado o no. El webhook que bloquea inicios de sesión no bloqueaba.

El arreglo es una línea movida, seis veces: ejecutar el hook antes de SignInAsync en contraseña, OIDC, SAML, verificación de MFA y configuración de MFA, y antes de acuñar tokens en el endpoint de token. El cambio en la biblioteca y el cambio en el plano de control aterrizaron con dos minutos de diferencia. Un rechazo es ahora un 403 limpio sin Set-Cookie, y un test de extremo a extremo comprueba que un inicio de sesión denegado no emite ninguna .AspNetCore.Cookies.

El mismo orden apareció en tres sitios más discretos. Los códigos de autorización se leían y luego se borraban como dos operaciones, así que dos canjes simultáneos de un mismo código podían pasar los dos la lectura y obtener tokens los dos; el almacén de grants expone ahora TryConsumeAsync, un borrado condicional que devuelve true solo a quien de verdad eliminó la fila. La restauración hacía upsert de las entidades antes de verificar nada: el campo FileHashes estaba documentado como verificado durante la restauración cuando nunca se rellenaba ni se comprobaba, lo que convierte una copia de seguridad manipulada en una primitiva de escritura contra producción. Y el webhook de Stripe actuaba sobre los eventos antes de registrar que los había visto, así que una reentrega at-least-once podía hacer oscilar el plan y el estado de un inquilino o reenviar los avisos de impago. Los tres comprueban primero ahora.

Validar la URL cuando se guarda no demuestra nada cuando se marca

Los clientes nos entregan URLs: webhooks de autenticación, destinos de copia de seguridad, callbacks de retención. Rechazar una que resuelve a una dirección privada en el momento de guardarla es la protección obvia, y por sí sola no vale casi nada, porque el DNS no es una promesa. Un nombre que resuelve públicamente cuando lo validas puede resolver a 169.254.169.254 cuando te conectas.

Así que la comprobación que soporta el peso no vive en el manejador de ajustes. Es un callback de conexión en el propio handler HTTP. Cada llamada saliente a una URL proporcionada por un cliente resuelve el host, rechaza loopback, RFC1918, unique-local y todo el rango link-local 169.254.0.0/16 donde vive el servicio de metadatos de la nube, y después abre el socket contra esa dirección exacta ya validada en lugar de contra el nombre de host, de modo que no queda una segunda resolución que rebindear. Las redirecciones automáticas están desactivadas, porque un 302 es una oportunidad más de nombrar un objetivo interno. La comprobación al guardar se queda como mensaje de error para el cliente, que es para lo único que servía.

Dos endpoints y un archivo de copia de seguridad a los que solo protegía la red

/_internal/cluster/gossip estaba mapeado como anónimo, y su comprobación de secreto compartido existía como comentario en lugar de como código. /_internal/backchannel-logout estaba mapeado como anónimo con la antifalsificación desactivada en el listener público, aceptaba un cuerpo {"SubjectId": "..."} y revocaba todos los grants de ese sujeto. Ninguno de los dos tenía protección alguna en proceso. Lo único que había delante era la configuración del ingress y, dado el hallazgo sobre las cabeceras reenviadas de más arriba, cualquier defensa basada en IP era suplantable de todos modos. Los dos exigen ahora un secreto compartido comparado en tiempo constante, o una dirección de origen interna cuando no hay secreto configurado.

Las copias de seguridad tenían el mismo carácter. En los hosts que usan la fuente de claves local, la tabla de claves de firma guarda el escalar privado EC, y la copia serializaba cada columna tal cual en JSONL comprimido con gzip y en claro. Cualquiera que pudiera leer un archivo de copia podía falsificar tokens para el emisor del que procedía. Esa tabla ahora se excluye por defecto, con una activación explícita para destinos cifrados, y de cada archivo de datos se calcula un hash SHA-256 en el manifiesto que se verifica antes de que la restauración escriba una sola fila.

Dónde se ejecuta una comprobación es casi todo lo que vale la comprobación

Ninguna de estas era una comprobación ausente en el sentido de que nadie hubiera pensado en ella. MFA estaba implementado y probado. El hook del webhook se ejecutaba en todas las rutas. La validación de SSRF existía. Los usuarios de SCIM estaban correctamente acotados en cada verbo, en el archivo de al lado de aquel en el que los grupos no lo estaban. En casi todos los casos la lógica estaba escrita, era correcta y estaba instalada en un sitio donde no podía hacer su trabajo: sobre un valor que suministraba quien llamaba, en un paso que quien llamaba podía saltarse, después del estado que se suponía que debía evitar, o sobre el recurso de al lado.

Así que la pregunta útil en cada endpoint no es "¿validamos esto?". Son otras tres. ¿Qué demuestra de verdad esta petición, en lugar de limitarse a afirmarlo? ¿Es este identificador algo que quien llama puede nombrar, o algo que solo puede poseer quien corresponde? ¿Y ocurre algo irreversible antes de que llegue la respuesta? Una revisión escrita, trabajada sección a sección con una severidad en cada hallazgo, es sobre todo un mecanismo para meter esas tres preguntas en el código del que estás seguro. El nuestro lo estaba. Eran dieciséis secciones de certeza, más ocho cosas que habrían acabado siendo el incidente de alguien.

Nada de esto era alcanzable por un cliente, porque todavía no había clientes. Ese es el argumento para hacerlo antes del lanzamiento y no después del primer informe: la misma lista, escrita seis meses más tarde, es un calendario de divulgación en vez del trabajo de una noche. Si quieres la versión actual de las respuestas, nuestra página de seguridad explica cómo se aíslan los inquilinos, qué se cifra en reposo y qué contiene realmente una copia de seguridad de tus datos.