Trello Butler Automation — Executing Another User's Private Board Buttons

DP
Deev Pal
Dec 10, 2024 18 min read
This vulnerability was reported to Atlassian via Bugcrowd, accepted as P4, fixed, and rewarded $300. Disclosure: Bugcrowd CrowdStream.

TL;DR

Butler for Trello lets users create board buttons — one-click automation triggers that sit at the top of a board. These buttons have two sharing modes: Local (private to the creator) and Global (shared with the workspace via an explicit "Anyone in Workspace can enable this button" toggle).

The vulnerability: any board member can execute another user's Local (private) board button by sending a crafted POST request to app.butlerfortrello.com/api/powerup-run-command. The API does not verify that the requesting user is the button's owner. All identifiers needed to construct the request — the owner's user ID, the command ID, and the board ID — are visible to any board member through normal API responses.

This is especially interesting because Butler correctly enforces authorization on card buttons — attempting the same unauthorized execution on a card button returns 401/404. The inconsistency between card button and board button authorization proves the bug is a missing check, not a design decision.

Background — What is Butler?

Butler is Trello's built-in automation engine. If you've ever used Trello, you may have seen it — it lets you set up "if this, then that" rules to automate repetitive tasks.

Think of Butler like this: Imagine you're using a shared whiteboard (Trello board) with sticky notes (cards). Butler is like a helpful assistant who watches the board and performs actions automatically. You can tell it things like: "When a card is moved to the 'Done' list, add a green label and post a comment." Or you can create a button that, when clicked, does a specific action — like moving all cards to a specific list or creating a batch of new cards.

Butler offers two types of clickable automation triggers:

  • Board buttons — appear in the board header bar at the top. They perform board-level operations (e.g., "archive all cards in list X," "create a summary card," "sort cards by due date").
  • Card buttons — appear on individual cards. They perform card-level operations (e.g., "move this card to the top of the list," "set due date to next Friday").

Butler is deeply integrated into Trello but runs on a separate backend — app.butlerfortrello.com — with its own API layer and its own authentication mechanism. This is a critical architectural detail: Butler's authorization checks are independent of Trello's core authorization layer.

Why does the separate backend matter? When a feature runs on a different server (even if it's built by the same company), it has its own security code. Just because Trello's main app checks permissions correctly doesn't mean Butler's separate backend also does. Each system needs to independently verify that a user is allowed to do what they're asking. This is where the bug hides — Butler's backend checks permissions for some actions but not others.

Board Buttons vs Card Buttons

Both button types use the same API endpoint (/api/powerup-run-command) and the same request structure. The critical difference is in how they enforce permissions — which is what makes this finding so interesting.

Property Board Buttons Card Buttons
Location Board header bar Individual card actions
Scope Board-wide operations Single-card operations
Sharing model Local (private) or Global (shared) Local (private) or Global (shared)
Unauthorized execution Allowed (vulnerable) Blocked (401/404)
API endpoint /api/powerup-run-command /api/powerup-run-command

Same API endpoint. Same request structure. Same authentication header. But different authorization outcomes. Card buttons check ownership; board buttons don't.

Local vs Global — The Sharing Model

When a user creates a board button in Butler, they choose a sharing mode:

Butler Board buttons configuration page showing a button named OwnerBoardBu with the command create a new card. The sharing toggle Anyone in Workspace can enable this button is unchecked (Local mode)
The sharing toggle: The owner has created a board button called "OwnerBoardBu..." that creates a new card. Notice the checkbox at the bottom right: "Anyone in Workspace can enable this button" is unchecked — meaning this button is in Local (private) mode. Only the creator should be able to see and execute it.
  • Local (default) — the button is private. Only the creator can see and execute it. Other board members don't see it in their board header. This is the setting shown in the screenshot above (checkbox unchecked).
  • Global — the button is shared. The creator explicitly enables "Anyone in Workspace can enable this button", making it visible to all workspace members who can then add it to their own boards.
Why would someone make a button Local? Maybe a team lead created a button that archives all completed cards — they want to run it at the end of each week, but they don't want junior team members accidentally clicking it and archiving everything mid-sprint. Or maybe a developer created a button that posts a status update to an external tool — they don't want other people triggering status updates on their behalf. Local mode means "this is my personal automation, for my use only."

The security contract is clear: Local buttons are private. If a user creates a Local board button, only that user should be able to trigger it. Other board members shouldn't even know it exists (it doesn't appear in their UI), and they certainly shouldn't be able to execute it.

The distinction between Local and Global is the entire point of the sharing model. If any board member can execute any Local button, the concept of "private automation" is meaningless — it's just a cosmetic difference in the UI, not a real security boundary.

What the Owner Sees vs What Others See

To make this concrete, let me show you the difference between the owner's view and another board member's view:

Trello board from the owner's perspective showing the OwnerBoardButton visible in the board header, with cards in DeevList001
Owner's view: The board owner (Deev Pal, visible in the top right) sees the "OwnerBoardButton" in the board header (highlighted in green at the top). The owner can click this button to trigger the automation. The board has cards in DeevList001.
Same Trello board from another user's perspective (Techy Coder) showing no board button in the header area, with a red box highlighting the empty space where the button would be
Other member's view: A different board member (Techy Coder, visible in the top right) looks at the same board. The board header where the button should appear is empty (highlighted with a red box). The button is invisible to this user — as intended for a Local button. But invisible in the UI doesn't mean inaccessible via the API.

Attack Surface Analysis

The attack surface emerges from three architectural properties of Butler:

1. Predictable Identifiers

Every Butler command has a cmd_id in the format {OwnerID}-{IncrementalID}. The owner's Trello user ID is the first component — it's the same ID visible in any Trello API response involving that user. The incremental ID is a simple counter (1, 2, 3...). This means the command ID structure is not a secret; it's derivable from publicly available information.

In plain English: Every automation command has a unique ID, but that ID is basically the creator's user ID plus a number. If you know someone's Trello user ID (which any board member can look up), you can guess their command IDs by trying their ID followed by -1, -2, -3, etc. It's like a locker combination where the first number is your employee badge number — anyone who knows your badge number can start guessing.

2. Visible Board Membership

Any board member can enumerate all other members of the board, including their Trello user IDs. The board_id is in the URL. The uid (user ID) of any member is in the API responses. Every parameter needed for the exploit request is already visible to any board member.

3. Separate Authentication Layer

Butler uses its own authentication header — X-Butler-Trello-Token — which is tied to the requesting user's Trello session. The critical question is: does Butler verify that the X-Butler-Trello-Token belongs to the same user as the cmd_id's owner? For board buttons, the answer is no.

Reconnaissance — Dissecting the API

Using Burp Suite as a proxy, I intercepted the request generated when a user clicks their own board button. The request goes to Butler's backend, not Trello's:

HTTP Request — Legitimate Board Button Execution
POST /api/powerup-run-command HTTP/1.1
Host: app.butlerfortrello.com
Content-Type: application/json
X-Butler-Trello-Token: <OWNER-TOKEN>
Origin: https://trello.com
Referer: https://trello.com/

{
  "cmd_id": "<OwnerUserID>-<IncrementalID>",
  "uid": "<OwnerUserID>",
  "board_id": "<BoardID>"
}

Let's break down every field so it's clear what each piece does:

Field What it is Can any board member get this?
cmd_id Unique ID for the automation command. Format: {uid}-{number} Yes — the uid is public, the number is guessable (1, 2, 3...)
uid Trello user ID of the command owner Yes — visible via GET /1/boards/{id}/members
board_id The board where the command runs Yes — it's in the board URL
X-Butler-Trello-Token Butler authentication token Each user has their own — the attacker uses their own token, not the owner's

The key observation: the X-Butler-Trello-Token authenticates the requesting user, but the cmd_id and uid reference the command owner. The question is whether Butler checks that these match — does it verify that the person sending the request is the same person who created the command?

An analogy: Imagine a vending machine where you swipe your badge (authentication) and enter a product code (cmd_id). The machine checks that your badge is valid (you're a real employee), but it doesn't check whether the product code belongs to a snack you purchased. You could enter anyone's product code and get their snack. Authentication (who are you?) works correctly; authorization (what are you allowed to do?) is missing.

Exploitation — Triggering Someone Else's Private Button

Prerequisites

  • Attacker: Any member of the target board (normal member, not admin)
  • Victim: Board owner or another member who has created a Local (private) board button
  • Target: The victim's Local board button (not shared, not visible to attacker in UI)

Obtaining the Required Parameters

The attacker needs four values. All are obtainable without any privilege escalation — just normal board member access:

1. Victim's User ID (uid) — obtainable by listing board members:

HTTP Request — Enumerate Board Members
GET /1/boards/{boardId}/members HTTP/1.1
Host: trello.com
Cookie: <ATTACKER-COOKIES>

Response:
[
  {"id": "5f8a1b2c3d4e5f6a7b8c9d0e", "fullName": "Victim User", ...},
  {"id": "6a7b8c9d0e1f2a3b4c5d6e7f", "fullName": "Attacker", ...}
]

2. Command ID (cmd_id) — format is {VictimUserID}-{IncrementalID}. The incremental ID starts at small numbers and can be brute-forced trivially. A user's first board button will typically be 5f8a1b2c3d4e5f6a7b8c9d0e-1.

3. Board ID (board_id) — visible in the board URL.

4. Attacker's Butler Token (X-Butler-Trello-Token) — the attacker's own token, obtained by intercepting any Butler request from their own session.

The Exploit Request

HTTP Request — Unauthorized Board Button Execution
POST /api/powerup-run-command HTTP/1.1
Host: app.butlerfortrello.com
Content-Type: application/json
X-Butler-Trello-Token: <ATTACKER-OWN-TOKEN>
Origin: https://trello.com
Referer: https://trello.com/

{
  "cmd_id": "5f8a1b2c3d4e5f6a7b8c9d0e-1",
  "uid": "5f8a1b2c3d4e5f6a7b8c9d0e",
  "board_id": "67602d9ba379b5827eb9c49c"
}
Burp Suite showing the POST request to app.butlerfortrello.com/api/powerup-run-command with a 200 OK response containing success true
Exploit succeeds: Burp Suite shows the full request and response. The attacker sent the POST request using their own X-Butler-Trello-Token but with the victim's cmd_id and uid. The response is 200 OK with "success": true — the victim's private automation command executed successfully. The created card appears on the board as evidence.
Response: 200 OK — the victim's private automation command executes. The attacker's token was accepted for running someone else's command. No ownership verification occurred for board buttons.

Two Attack Scenarios

Scenario 1: Revoked sharing

The victim initially shared a board button as Global. The attacker (or any workspace member) captured the cmd_id from a legitimate execution. The victim later disabled sharing, converting it back to Local. The attacker can still execute the command — the cmd_id hasn't changed, and Butler doesn't re-check sharing status at execution time.

In plain English: It's like sharing a Google Doc link, then changing it back to "private." If someone saved the link before you made it private, they can still access it because the link (cmd_id) didn't change — the system just stopped showing it in the directory, without actually revoking access.

Scenario 2: Constructed from scratch

The victim never shared the button. The attacker constructs the request entirely from information available to any board member: the victim's user ID (from the members API), a guessed incremental command ID, and the board ID (from the URL). No prior knowledge of the button is needed — the attacker can discover it purely by guessing.

The Inconsistency That Proves the Bug

To confirm this was a bug and not a design decision, I tested the same attack against card buttons. This comparison is the strongest evidence in the entire report.

First, let me show the card button setup — it mirrors the board button setup exactly:

Butler Card buttons configuration page showing a card button named OwnerCardButton with the sharing toggle unchecked, similar to the board button setup
Card button setup (mirrors board buttons): The owner has created a Local card button called "OwnerCardButton..." with the "Anyone in Workspace can enable this button" toggle unchecked — identical to the board button configuration. Card buttons and board buttons use the same sharing model.

Just like with board buttons, the card button is visible to the owner but hidden from other members:

Card detail view from the owner's perspective showing the OwnerCardButton visible in the card's button section
Owner sees the card button: When the board owner opens a card, the "OwnerCardButton..." automation button is visible (highlighted in red at the bottom right). The owner can click it to run the automation on this specific card.
Card detail view from another user's perspective showing no card button visible, with a red box highlighting the empty button area
Other member doesn't see it: When a different board member opens the same card, the card button area is empty (highlighted with a red box). The Local button is correctly hidden from the UI — just like the board button was hidden earlier.

Now here's the critical comparison. When the attacker sends the exact same type of exploit request for a card button:

Burp Suite showing the POST request for card button execution returning HTTP 401 Unauthorized
Card button: 401 Unauthorized. The attacker sends a POST to /api/powerup-run-command with the victim's card button cmd_id and the attacker's own token. Butler correctly rejects the request with 401 Unauthorized. The ownership check works here.
Burp Suite showing the POST request for card button execution returning HTTP 404 Not Found
Card button: 404 Not Found. Another test with a different card button also fails — this time with 404 Not Found, meaning Butler doesn't even acknowledge the command exists to unauthorized users. This is the correct behavior.
HTTP Request — Unauthorized Card Button Execution (Blocked)
POST /api/powerup-run-command HTTP/1.1
Host: app.butlerfortrello.com
Content-Type: application/json
X-Butler-Trello-Token: <ATTACKER-OWN-TOKEN>
Origin: https://trello.com

{
  "cmd_id": "5f8a1b2c3d4e5f6a7b8c9d0e-3",
  "uid": "5f8a1b2c3d4e5f6a7b8c9d0e",
  "card_id": "67602e1fa379b5827eb9c5a1",
  "board_id": "67602d9ba379b5827eb9c49c"
}

Response: 401 Unauthorized (or 404 Not Found)

Same endpoint. Same auth. Same parameter structure. Card buttons return 401 or 404. Board buttons return 200.

Why this inconsistency matters so much: It proves that the authorization check exists in the codebase — it's just not applied to board button execution. The fix isn't a new feature; it's extending an existing check to a code path that was missed. When a vendor sees that one code path correctly enforces authorization and a parallel code path doesn't, the gap is unambiguous. There's no room for "working as intended."

Full Attack Chain

Attack Flow
1. Attacker is a normal member of a Trello board
   └── Victim has created Local (private) board buttons

2. Attacker enumerates board members
   └── GET /1/boards/{boardId}/members
   └── Obtains victim's user ID: 5f8a1b2c3d4e5f6a7b8c9d0e

3. Attacker constructs cmd_id
   └── Format: {VictimUserID}-{IncrementalID}
   └── Try: 5f8a1b2c3d4e5f6a7b8c9d0e-1, -2, -3, ...

4. Attacker obtains their own Butler token
   └── Intercept any Butler request from their own browser session
   └── Extract X-Butler-Trello-Token header value

5. Attacker sends: POST /api/powerup-run-command
   └── Host: app.butlerfortrello.com
   └── X-Butler-Trello-Token: {attacker's own token}
   └── Body: {"cmd_id": "{victim}-1", "uid": "{victim}", "board_id": "{board}"}

6. Butler executes the victim's private automation command
   └── Response: 200 OK with "success": true
   └── Command runs with the automation's configured actions
   └── Victim receives no notification of unauthorized execution

7. Comparison: same attack against card buttons
   └── Response: 401 Unauthorized — correctly blocked
   └── Proves the board button gap is a bug, not a design choice

Root Cause Analysis

The vulnerability exists because Butler's /api/powerup-run-command endpoint has two separate authorization code paths — one for card buttons and one for board buttons — and only the card button path includes ownership verification.

Pseudocode — Butler Authorization
// POST /api/powerup-run-command
function runCommand(request) {
    // Step 1: Authenticate the requesting user
    const user = authenticateToken(request.headers["X-Butler-Trello-Token"]);
    if (!user) return 401;  // "Who are you?" — checked ✓

    // Step 2: Look up the command
    const command = getCommand(request.body.cmd_id);

    // Step 3: Authorization — DIFFERENT PATHS
    if (command.type === "card_button") {
        // Card buttons: ownership check ✓
        if (command.owner !== user.id && !command.isGlobal) {
            return 401; // "You're not the owner and it's not shared" — blocked ✓
        }
    }

    if (command.type === "board_button") {
        // Board buttons: NO ownership check ✗
        // Only checks: is user a member of the board?
        if (!isBoardMember(user.id, request.body.board_id)) {
            return 401;
        }
        // Missing: && (command.owner === user.id || command.isGlobal)
        // ↑ This check exists for card buttons but not board buttons
    }

    // Step 4: Execute
    return executeCommand(command); // 200 OK
}
What's happening here? The code correctly identifies who the user is (authentication — Step 1). For card buttons, it then checks whether that user has the right to run this specific command (authorization — Step 3). But for board buttons, it only checks whether the user is a member of the board — it doesn't check whether they own the command or whether the command has been shared with them. Being a board member is a much weaker requirement than being the command owner.

This is a classic broken object-level authorization (BOLA) vulnerability — also known as IDOR (Insecure Direct Object Reference). The API authenticates the user but doesn't authorize them for the specific resource (automation command) they're trying to access.

BOLA/IDOR explained simply: Imagine a hotel where you have a valid room key card (authentication). The key card proves you're a guest at the hotel. But the key card should only open your room (authorization). A BOLA vulnerability is like a hotel where any valid key card opens any room — the hotel verifies you're a guest but doesn't check which room is yours.

Impact

CVSS 3.0: AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N — 5.4 (Medium)

  • Confidentiality: Low — attacker can trigger automation commands that may reveal board state
  • Integrity: Low — attacker can trigger automation commands that modify board state (move cards, change labels, post comments, archive lists)
  • Availability: None directly — though destructive automations could impact board usability

Attack scenarios by automation type:

  • Data manipulation: If the victim's button moves all cards to a specific list or sorts cards — the attacker can trigger these board-wide changes at will, disrupting project workflows
  • Information disclosure: If the victim's button generates a board summary and posts it as a comment — the attacker can trigger it to see information they might not otherwise have access to
  • Privilege escalation chain: If the victim is a workspace admin and their button performs admin-level actions (modifying board settings, managing members), a normal member can effectively execute admin actions by proxy
  • Revoked access persistence: If a button was temporarily shared and then un-shared, previous users retain execution ability — the revocation of sharing is not enforced
The core security contract violation: the "Local" sharing mode promises that only the creator can execute the button. This promise is broken at the API level, making the sharing model purely cosmetic for board buttons.

Remediation

Atlassian resolved this by extending the ownership verification from the card button code path to also cover board buttons. The fix ensures that /api/powerup-run-command checks both:

  1. Board membership: Is the requesting user a member of the board? (existing check)
  2. Command authorization: Is the requesting user the owner of the command, OR is the command marked as Global? (new check — was already enforced for card buttons)

Defensive pattern: When the same API endpoint handles multiple resource types (card buttons, board buttons), ensure authorization checks are applied uniformly across all types. A single endpoint with inconsistent authorization across resource types is a strong indicator of a missed check.

The lesson for developers: When you add security checks to one part of your API, audit all similar code paths to make sure the same check is applied everywhere. In this case, the card button code had the correct authorization check, but the board button code — written as a parallel implementation — was missing it. Code review should specifically flag any conditional authorization logic that differs by resource type.

Timeline

  • 2024-12-10 — Vulnerability discovered and reported via Bugcrowd
  • 2024-12-10 — Triaged as P4 (Broken Access Control > IDOR)
  • 2025-01-09 — Fix deployed, vulnerability resolved
  • 2025-01-09 — $300 bounty awarded
  • 2025-01-09 — Disclosure approved

Takeaways for Hunters

  1. Test authorization consistency across resource types. When a single API endpoint handles multiple resource types, test each type independently. If one type enforces authorization and another doesn't, you've found a bug. In this case, card buttons were protected but board buttons weren't — same endpoint, different outcomes.
  2. UI-only restrictions are not restrictions. If a button doesn't appear in your UI, that doesn't mean the API won't accept your request. Always test directly at the API level — the UI is a suggestion, not an enforcement boundary. Many developers assume "if users can't see it, they can't interact with it," but the API doesn't care about UI visibility.
  3. Third-party integrations have independent auth layers. Butler runs on app.butlerfortrello.com, separate from Trello's core API at trello.com. Each backend has its own authorization logic. Being authorized in one doesn't mean you're authorized in the other — and the boundary between systems is where checks get missed.
  4. Predictable identifiers + missing auth = BOLA. The cmd_id format ({uid}-{n}) makes command IDs trivially guessable. Combined with missing ownership checks, this creates a textbook BOLA/IDOR — any authenticated user can access any board button command by guessing the ID.
  5. Test permission revocation. If a feature can be shared and un-shared, verify that un-sharing actually revokes access at the API level. In this case, converting a Global button back to Local didn't prevent previously-aware users from continuing to execute it. Permission revocation failures are a common and underexplored bug class.