Skip to content

Title: Organization invitation can be redeemed by any account, not just the invited email (unauthorized org join with the invited role, including Admin) #3430

Description

@noobx123

Severity: Medium
CVSS: 6.3 (CVSS:3.1/AV:N/AC:H/PR:L/UI:R/S:U/C:H/I:H/A:N)
Endpoint: POST https://api.vibekanban.com/v1/invitations/{token}/accept (also GET /v1/invitations/{token})
Affected: Vibe Kanban Cloud / Remote, server version 0.1.27

Title: Organization invitation can be redeemed by any account, not just the invited email
(unauthorized org join with the invited role, including Admin)

Summary:
When an organization invitation is accepted, the server binds membership to whichever user is
currently authenticated and grants them the role stored on the invitation — it never verifies
that the authenticated user's email matches the address the invitation was sent to. Any
authenticated Vibe Kanban user who obtains a pending invitation token can therefore join the
target organization, with the role chosen by the inviter (which may be Admin). The token is
distributed only inside the invitation email, but invitation links are routinely
forwarded/mis-sent/leaked (mailing lists, helpdesk tickets, proxy and referer logs, screenshots),
and the standard control against exactly this — binding redemption to the invited email — is
absent. Additionally, the invitation lookup endpoint is unauthenticated and discloses the target
organization and the granted role for any token before it is accepted.

Root cause (open-source, github.com/BloopAI/vibe-kanban, matches live v0.1.27):

  • crates/remote/src/db/invitations.rs ~193 accept_invitation(token, user_id): selects the
    invitation by token AND status='pending', then checks only: not a personal org, not expired,
    and the caller is not already a member. It then calls
    add_member(org, user_id, invitation.role) for the authenticated user_id. The invitation row
    has an email column, but it is never compared to the accepting user's email.
  • crates/remote/src/routes/organization_members.rs ~274 accept_invitation handler: passes
    ctx.user.id straight through; no email check.
  • crates/remote/src/routes/organization_members.rs ~205 get_invitation: registered in the PUBLIC
    (unauthenticated) router and returns organization_slug, organization_name, role and expires_at
    for any token.

Steps / PoC:
Preconditions: an org admin has invited some address (e.g. alice@victim-org.com) as a member or
Admin (this creates a pending invitation with a token sent in the email). The attacker has any
ordinary Vibe Kanban account and has obtained the token (e.g. a forwarded invite link).

  1. (Optional, unauthenticated) Confirm the token and see what it grants, before accepting:
    curl https://api.vibekanban.com/v1/invitations/
    -> 200 {"id":"...","organization_slug":"victim-org","organization_name":"Victim Org",
    "role":"admin","expires_at":"..."}

  2. As the attacker (any account, email = attacker@example.com), accept it:
    curl -X POST https://api.vibekanban.com/v1/invitations//accept
    -H "Authorization: Bearer <attacker_access_token>"
    -> 200 {"organization_id":"","organization_slug":"victim-org","role":"admin"}

    The attacker is now a member (here, Admin) of the victim organization despite the invitation
    having been addressed to alice@victim-org.com. With Admin they can read all projects/issues,
    manage members, manage the org's GitHub App installation, and access billing settings.

Verification status:

  • The public GET /v1/invitations/{token} and the POST .../accept routes are live and reachable on
    api.vibekanban.com (a non-existent token returns 404; the routes are registered as shown).
  • The missing email-binding is a deterministic authorization omission in the deployed open-source
    code (paths cited; live x-server-version = 0.1.27 matches the repo). This logic is always active
    (not behind any feature flag). End-to-end execution against a real victim org was not performed
    because creating a separate authenticated cloud account requires interactive Google/GitHub OAuth
    (the cloud has no programmatic signup), but the code path is unambiguous and unconditional.

Impact:
Unauthorized access to an organization's data and (when the invite role is Admin) full
administrative control, granted to an account that was never invited. This breaks the tenant
trust boundary whenever an invitation token reaches anyone other than the intended recipient —
a common real-world occurrence — and the unauthenticated invitation-lookup endpoint makes a
leaked token immediately actionable by revealing the org and role it confers.

Fix:
In accept_invitation, load the accepting user's email and require a case-insensitive match against
invitation.email before calling add_member; reject with 403 otherwise. Consider requiring the
invited account to exist/verify that email at the identity layer. Restrict GET /v1/invitations/
{token} to disclose the minimum necessary, and rate-limit it. Expire/rotate tokens on first view
where feasible.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions