Three Small Bugs, One Account Takeover: OAuth Account-Link CSRF in the Mattermost Confluence Plugin
By Kavennesh Balachandar · Bugcrowd · Mattermost Bug Bounty Program
# TL;DR
The Confluence plugin's OAuth2 callback can be triggered cross-site as a
logged-in victim, binding the attacker's Atlassian identity to the victim's
Mattermost account. Three separate defects line up: the one-time state is
stored in a global KV keyed by its own value (so the attacker knows it), it's
never deleted on verify (reusable for the expiry window), and the callback only
checks that the state exists — never that it belongs to the requesting user.
Because Mattermost forwards cookie-authenticated plugin GET requests without a
CSRF token, a cross-site GET runs as the victim. Severity: Medium (P3), CVSS
~4.2. A clean example of how three individually-minor issues chain into real
impact.
# The three defects
1. The state is attacker-known. The one-time state is stored in a global KV
namespace keyed by the state value itself (server/store/store.go:141). Anyone
who runs the connect flow plants a state value they already hold.
2. The state is reusable. It's never deleted on verification
(server/store/store.go:148 — no KVDelete), so it stays valid for the full
expiry window.
3. The callback doesn't bind state to a user. CompleteOAuth2
(server/user.go:136) only checks the state exists. It never compares the state
to the requesting user, and it stores the exchanged token under the user ID read
from the Mattermost-User-Id request header (server/user.go:114,177-181).
And the enabler: Mattermost forwards cookie-authenticated plugin GET requests
without a CSRF token (server/channels/app/plugin_requests.go:278-282 returns
true for GET, then sets Mattermost-User-Id). The callback is a GET — so a
cross-site GET executes as the victim.
# Exploitation
-
The attacker (any account on the configured Atlassian instance; for Confluence Cloud, any Atlassian account) starts the plugin connect flow and follows the Atlassian authorize step until Atlassian redirects back with a fresh, unexchanged
code. The attacker records bothcodeandstate. Thatstateis now planted in Mattermost's KV. -
While the victim is logged into Mattermost, the attacker makes the victim's browser issue (auto-navigation,
<img>, or hidden form):GET https://<mm-host>/plugins/com.mattermost.confluence/api/v1/oauth2/complete.html?code=<ATTACKER_CODE>&state=<ATTACKER_STATE> -
Mattermost sets
Mattermost-User-Idto the victim, the state check passes, the attacker'scodeis exchanged, and the resulting Atlassian token is stored as the victim's Confluence connection.
# Impact
The victim's Mattermost-to-Confluence connection now authenticates as the attacker's Atlassian identity. Content the victim views or searches through the plugin is served from the attacker's Confluence — a persistent way to deliver attacker-controlled "Confluence" content and links inside Mattermost — and the victim's plugin-mediated queries run under the attacker's token and surface in the attacker's Confluence. The victim does nothing beyond being logged in and loading a page.
Severity: Medium — CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N (~4.2) / P3.
Honest floor: a strict triager may rate P4 given the limited per-user impact; the
counter is the no-victim-action-beyond-page-load precondition and the persistent
phishing surface it creates.
# Remediation
Bind the state to the initiating user and make it single-use: store
state → mattermostUserID (or embed the user ID and HMAC-sign it), KVDelete on
verify, and require the stored user ID to equal Mattermost-User-Id in
CompleteOAuth2. This is the pattern the msteams plugin already implements
correctly (random state + PKCE + identity match).
Hardening note (same file, fix together): connectUser also writes every
user's connection into a shared "admin" sentinel slot. That slot is currently
never read (latent today), but if any future code path loads the "admin"
connection, this same CSRF would graft the workspace's privileged connection.
Worth fixing alongside.
# Disclosure timeline
- 2026-06-26 — Reported to Mattermost via Bugcrowd.
- Closed as duplicate / not applicable.
- Public disclosure pending program approval.