← All writeups
Medium Bugcrowd · Mattermost Duplicate

Three Small Bugs, One Account Takeover: OAuth Account-Link CSRF in the Mattermost Confluence Plugin

csrfoauthaccount-linkingmattermostsession-integrity

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

  1. 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 both code and state. That state is now planted in Mattermost's KV.

  2. 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>
    
  3. Mattermost sets Mattermost-User-Id to the victim, the state check passes, the attacker's code is 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.

# References