Skip to content

security: fix organization takeover exploit chain#280

Open
TerminalsandCoffee wants to merge 1 commit intoabhi1693:masterfrom
TerminalsandCoffee:fix/critical-org-takeover-exploit-chain
Open

security: fix organization takeover exploit chain#280
TerminalsandCoffee wants to merge 1 commit intoabhi1693:masterfrom
TerminalsandCoffee:fix/critical-org-takeover-exploit-chain

Conversation

@TerminalsandCoffee
Copy link

Summary

Fixes a critical 3-vulnerability exploit chain that allows any authenticated admin to fully take over any organization:

  • Invite token leak: OrganizationInviteRead returned the raw token field in every API response (create, revoke, list). Replaced with has_token: bool computed via a Pydantic model_validator(mode='wrap').
  • Email validation bypass: accept_org_invite short-circuited the email check when either invited_email or auth.user.email was None/empty, allowing anyone with the token to accept regardless of intended recipient. Now explicitly rejects when either email is missing.
  • Role escalation: No role hierarchy enforcement on create_org_invite or update_org_member — admins could grant owner role. Added ROLE_RANK comparison that blocks granting roles at or above the caller's own rank.

Files Changed

  • backend/app/schemas/organizations.pytoken: strhas_token: bool + wrap validator
  • backend/app/api/organizations.py — email validation fix + role hierarchy checks on invite create and member update

CVSS

9.8 Critical — Network / Low complexity / Low privilege / No interaction / Scope changed

Test plan

  • Create an invite and verify the response contains has_token: true but no raw token value
  • Attempt to accept an invite with a mismatched email — expect 403
  • Attempt to accept an invite when authenticated user has no email — expect 403
  • As admin, attempt to create an invite with role: "owner" — expect 403
  • As admin, attempt to PATCH a member's role to "owner" — expect 403
  • As owner, create an invite with role: "admin" — expect success
  • List invites and verify no token values are exposed

🤖 Generated with Claude Code

…+ email bypass + role escalation)

Three chained vulnerabilities allow any authenticated admin to fully take
over any organization:

1. Invite token leaked in API responses — OrganizationInviteRead included
   the raw token field. Replaced with has_token boolean using a wrap
   model_validator to compute from the ORM field without exposure.

2. Email validation bypass — the accept_org_invite endpoint short-circuited
   the email check when either invited_email or user email was None/empty.
   Now rejects acceptance when either email is missing.

3. No role hierarchy enforcement — admins could create invites with
   role=owner or promote members above their own level. Added ROLE_RANK
   comparison to both create_org_invite and update_org_member that blocks
   granting roles at or above the caller's rank.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
updated_at: datetime

@model_validator(mode="wrap")
@classmethod
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove unrelated changes

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Addresses a critical organization-takeover exploit chain in the backend org invite/membership flows by preventing invite-token exposure, tightening invite acceptance validation, and adding role hierarchy enforcement.

Changes:

  • Replace OrganizationInviteRead.token with has_token computed at validation time to avoid leaking raw invite tokens.
  • Enforce strict email-binding in accept_org_invite by rejecting invites when either the invite email or the authenticated user email is missing, and rejecting mismatches.
  • Add role hierarchy checks to block privilege escalation during org invite creation and member role updates.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
backend/app/schemas/organizations.py Removes raw invite token from read payloads and adds computed has_token flag.
backend/app/api/organizations.py Adds role hierarchy enforcement for invites/membership updates and hardens invite acceptance email validation.

Comment on lines +121 to 123
has_token: bool = False
created_by_user_id: UUID | None = None
accepted_by_user_id: UUID | None = None
Comment on lines +641 to +648
# Prevent granting roles at or above the caller's level.
requested_role = normalize_role(payload.role)
caller_rank = ROLE_RANK.get(ctx.member.role, 0)
requested_rank = ROLE_RANK.get(requested_role, 0)
if requested_rank >= caller_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot grant a role equal to or above your own",
Comment on lines +485 to +494
new_role = normalize_role(updates["role"])
# Prevent granting roles at or above the caller's level.
caller_rank = ROLE_RANK.get(ctx.member.role, 0)
new_rank = ROLE_RANK.get(new_role, 0)
if new_rank >= caller_rank:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot grant a role equal to or above your own",
)
updates["role"] = new_role
Comment on lines +725 to 735
# Require both emails to be present — reject if either is missing to
# prevent bypassing the email-binding check via None/empty values.
if not invite.invited_email or not auth.user.email:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invite requires a matching email address",
)
if normalize_invited_email(invite.invited_email) != normalize_invited_email(
auth.user.email
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
Comment on lines +642 to +646
requested_role = normalize_role(payload.role)
caller_rank = ROLE_RANK.get(ctx.member.role, 0)
requested_rank = ROLE_RANK.get(requested_role, 0)
if requested_rank >= caller_rank:
raise HTTPException(
Comment on lines 112 to +121
class OrganizationInviteRead(SQLModel):
"""Organization invite payload returned from read endpoints."""

id: UUID
organization_id: UUID
invited_email: str
role: str
all_boards_read: bool
all_boards_write: bool
token: str
has_token: bool = False
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants