From c259b95b24dd096ae6d4cd801d23d294c895dd66 Mon Sep 17 00:00:00 2001 From: Gaurav Singhal Date: Tue, 23 Jun 2026 21:23:10 -0700 Subject: [PATCH] fix(api): protect project invitation reads --- apps/api/plane/app/views/project/invite.py | 12 +++++ .../app/test_project_invitation_app.py | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 apps/api/plane/tests/contract/app/test_project_invitation_app.py diff --git a/apps/api/plane/app/views/project/invite.py b/apps/api/plane/app/views/project/invite.py index 19d8c36bcf7..06266e34c1b 100644 --- a/apps/api/plane/app/views/project/invite.py +++ b/apps/api/plane/app/views/project/invite.py @@ -50,6 +50,14 @@ def get_queryset(self): .select_related("workspace", "workspace__owner") ) + @allow_permission([ROLE.ADMIN]) + def list(self, request, slug, project_id): + return super().list(request, slug=slug, project_id=project_id) + + @allow_permission([ROLE.ADMIN]) + def retrieve(self, request, slug, project_id, pk): + return super().retrieve(request, slug=slug, project_id=project_id, pk=pk) + @allow_permission([ROLE.ADMIN]) def create(self, request, slug, project_id): emails = request.data.get("emails", []) @@ -112,6 +120,10 @@ def create(self, request, slug, project_id): return Response({"message": "Email sent successfully"}, status=status.HTTP_200_OK) + @allow_permission([ROLE.ADMIN]) + def destroy(self, request, slug, project_id, pk): + return super().destroy(request, slug=slug, project_id=project_id, pk=pk) + class UserProjectInvitationsViewset(BaseViewSet): serializer_class = ProjectMemberInviteSerializer diff --git a/apps/api/plane/tests/contract/app/test_project_invitation_app.py b/apps/api/plane/tests/contract/app/test_project_invitation_app.py new file mode 100644 index 00000000000..50c7aea2d42 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_project_invitation_app.py @@ -0,0 +1,48 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest +from rest_framework import status + +from plane.db.models import ( + Project, + ProjectMember, + ProjectMemberInvite, + User, + Workspace, + WorkspaceMember, +) + + +@pytest.mark.contract +class TestProjectInvitationAPI: + @pytest.mark.django_db + def test_foreign_workspace_user_cannot_read_project_invitations(self, session_client, workspace, create_user): + project = Project.objects.create(name="Invite Protected", identifier="IP", workspace=workspace) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + invitation = ProjectMemberInvite.objects.create( + project=project, + workspace=workspace, + email="invitee@example.com", + token="secret-project-invite-token", + role=15, + created_by=create_user, + ) + + foreign_user = User.objects.create_user(email="foreign@example.com", username="foreign") + foreign_workspace = Workspace.objects.create(name="Foreign Workspace", slug="foreign-workspace", owner=foreign_user) + WorkspaceMember.objects.create(workspace=foreign_workspace, member=foreign_user, role=15, is_active=True) + + session_client.force_authenticate(user=foreign_user) + + list_url = f"/api/workspaces/{workspace.slug}/projects/{project.id}/invitations/" + detail_url = f"{list_url}{invitation.id}/" + + list_response = session_client.get(list_url) + detail_response = session_client.get(detail_url) + + assert list_response.status_code == status.HTTP_403_FORBIDDEN + assert detail_response.status_code == status.HTTP_403_FORBIDDEN + assert b"secret-project-invite-token" not in list_response.content + assert b"secret-project-invite-token" not in detail_response.content