From 6de5e44dd3e0f5a49e1f42f732f9c85ac53d8f61 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Wed, 24 Jun 2026 16:57:02 +0530 Subject: [PATCH 1/2] fix(security): enforce token + auth validation on project invite accept/reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProjectJoinEndpoint.post() only checked that the caller-supplied email matched the invited email — no token required, no authentication required. Anyone who knew the workspace slug, project ID, invite UUID, and invitee email could accept or reject the invitation on the invitee's behalf (GHSA-g36h-p63v-g9c7). Mirror WorkspaceJoinEndpoint.post() exactly: - Validate `token` from request body against project_invite.token (→ 403 on mismatch) - Require authenticated session (→ 401 if unauthenticated) - Validate request.user.email against project_invite.email (→ 403 on mismatch) - Remove the old request.data["email"] guard - Use project_invite.email for downstream User lookup Co-authored-by: Plane AI --- apps/api/plane/app/views/project/invite.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py index 19d8c36bcf7..3aa510ec0f0 100644 --- a/apps/api/plane/app/views/project/invite.py +++ b/apps/api/plane/app/views/project/invite.py @@ -186,14 +186,30 @@ class ProjectJoinEndpoint(BaseAPIView): def post(self, request, slug, project_id, pk): project_invite = ProjectMemberInvite.objects.get(pk=pk, project_id=project_id, workspace__slug=slug) - email = request.data.get("email", "") + token = request.data.get("token", "") - if email == "" or project_invite.email != email: + # Validate the token to verify the user received the invitation email + if not token or project_invite.token != token: return Response( {"error": "You do not have permission to join the project"}, status=status.HTTP_403_FORBIDDEN, ) + # Require an authenticated session — the accepting user must be the + # person who was invited. Without this check an attacker who knows the + # invitee email and obtains the token can hijack the project membership + # (GHSA-g36h-p63v-g9c7). + if not request.user.is_authenticated: + return Response( + {"error": "Authentication required to accept project invitation"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + if request.user.email.lower() != project_invite.email.lower(): + return Response( + {"error": "You do not have permission to accept this invitation"}, + status=status.HTTP_403_FORBIDDEN, + ) + if project_invite.responded_at is None: project_invite.accepted = request.data.get("accepted", False) project_invite.responded_at = timezone.now() @@ -201,7 +217,7 @@ def post(self, request, slug, project_id, pk): if project_invite.accepted: # Check if the user account exists - user = User.objects.filter(email=email).first() + user = User.objects.filter(email=project_invite.email).first() # Check if user is a part of workspace workspace_member = WorkspaceMember.objects.filter(workspace__slug=slug, member=user).first() From c03149c2758251aa1cd266084b45be1e155ef9dd Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Wed, 24 Jun 2026 18:05:14 +0530 Subject: [PATCH 2/2] fix(security): address CR review on project invite token validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use request.user directly instead of re-querying User by exact project_invite.email — avoids case-variant miss after the case-insensitive email check already validated the authenticated user (CR comment 1) - Validate `accepted` as a real boolean before saving — form-encoded strings like "false" are truthy and could accidentally create memberships (CR comment 2) Co-authored-by: Plane AI --- apps/api/plane/app/views/project/invite.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py index 3aa510ec0f0..33673a1acc2 100644 --- a/apps/api/plane/app/views/project/invite.py +++ b/apps/api/plane/app/views/project/invite.py @@ -211,13 +211,20 @@ def post(self, request, slug, project_id, pk): ) if project_invite.responded_at is None: - project_invite.accepted = request.data.get("accepted", False) + accepted = request.data.get("accepted", False) + if not isinstance(accepted, bool): + return Response( + {"error": "`accepted` must be a boolean"}, + status=status.HTTP_400_BAD_REQUEST, + ) + project_invite.accepted = accepted project_invite.responded_at = timezone.now() project_invite.save() if project_invite.accepted: - # Check if the user account exists - user = User.objects.filter(email=project_invite.email).first() + # Use the authenticated user directly — they've already been + # validated as the invite recipient above. + user = request.user # Check if user is a part of workspace workspace_member = WorkspaceMember.objects.filter(workspace__slug=slug, member=user).first()