From aba9cebdc5fafa11e61df27f964d0ad5bfa4dc17 Mon Sep 17 00:00:00 2001 From: Rafael Martinez Date: Sun, 15 Mar 2026 00:58:16 -0500 Subject: [PATCH] security: fix organization takeover exploit chain (invite token leak + email bypass + role escalation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api/organizations.py | 38 +++++++++++++++++++++++----- backend/app/schemas/organizations.py | 17 ++++++++++++- 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index c776c6559..c04d202bc 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -54,6 +54,7 @@ ) from app.schemas.pagination import DefaultLimitOffsetPage from app.services.organizations import ( + ROLE_RANK, OrganizationContext, accept_invite, apply_invite_board_access, @@ -481,7 +482,16 @@ async def update_org_member( ) updates = payload.model_dump(exclude_unset=True) if "role" in updates and updates["role"] is not None: - updates["role"] = normalize_role(updates["role"]) + 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 updates["updated_at"] = utcnow() member = await crud.patch(session, member, updates) user = await User.objects.by_id(member.user_id).first(session) @@ -628,12 +638,22 @@ async def create_org_invite( if existing_member is not None: raise HTTPException(status_code=status.HTTP_409_CONFLICT) + # 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", + ) + token = secrets.token_urlsafe(24) invite = OrganizationInvite( organization_id=ctx.organization.id, invited_email=email, token=token, - role=normalize_role(payload.role), + role=requested_role, all_boards_read=payload.all_boards_read, all_boards_write=payload.all_boards_write, created_by_user_id=ctx.member.user_id, @@ -702,11 +722,15 @@ async def accept_org_invite( ).first(session) if invite is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) - if ( - invite.invited_email - and auth.user.email - and normalize_invited_email(invite.invited_email) - != normalize_invited_email(auth.user.email) + # 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) diff --git a/backend/app/schemas/organizations.py b/backend/app/schemas/organizations.py index 0a17abdee..9e296f5a9 100644 --- a/backend/app/schemas/organizations.py +++ b/backend/app/schemas/organizations.py @@ -3,8 +3,10 @@ from __future__ import annotations from datetime import datetime +from typing import Any from uuid import UUID +from pydantic import model_validator from sqlmodel import Field, SQLModel RUNTIME_ANNOTATION_TYPES = (datetime, UUID) @@ -116,13 +118,26 @@ class OrganizationInviteRead(SQLModel): role: str all_boards_read: bool all_boards_write: bool - token: str + has_token: bool = False created_by_user_id: UUID | None = None accepted_by_user_id: UUID | None = None accepted_at: datetime | None = None created_at: datetime updated_at: datetime + @model_validator(mode="wrap") + @classmethod + def _compute_has_token(cls, values: Any, handler: Any) -> OrganizationInviteRead: + """Compute has_token from the ORM token field without exposing the raw value.""" + model = handler(values) + token = ( + getattr(values, "token", None) + if not isinstance(values, dict) + else values.get("token") + ) + model.has_token = bool(token) + return model + class OrganizationInviteAccept(SQLModel): """Payload for accepting an organization invite token."""