mirror of
https://github.com/makeplane/plane.git
synced 2026-09-01 19:48:42 +02:00
[WEB-8291] fix: gate project invitation list/retrieve/destroy to project admins (GHSA-r68c-48rr-m67f)
ProjectInvitationsViewset declared no permission_classes (inheriting IsAuthenticated) and only decorated create with @allow_permission([ADMIN]). The default list/retrieve/destroy actions were ungated and get_queryset was scoped only by URL slug + project_id, so any authenticated user could read another project's pending invitations — including invitee email and the raw token (re-exposing what #9305 stripped from the public path) — and delete them. Gate list/retrieve/destroy with @allow_permission([ROLE.ADMIN]), matching create (project admin, or a workspace-admin who is a project member). The workspace sibling WorkspaceInvitationsViewset was already admin-gated. Also drop a pre-existing unused User import in the same file. Adds contract regression tests (fail-before verified): a non-member of the project is rejected with 403 on list/retrieve/destroy (invite left intact), with a positive control confirming a project admin can still list. Co-authored-by: Plane AI <noreply@plane.so>
This commit is contained in:
@@ -28,7 +28,6 @@ from plane.db.models import (
|
||||
ProjectMember,
|
||||
Workspace,
|
||||
ProjectMemberInvite,
|
||||
User,
|
||||
WorkspaceMember,
|
||||
Project,
|
||||
ProjectUserProperty,
|
||||
@@ -53,6 +52,23 @@ class ProjectInvitationsViewset(BaseViewSet):
|
||||
.select_related("workspace", "workspace__owner")
|
||||
)
|
||||
|
||||
# GHSA-r68c-48rr-m67f: project invitations expose invitee email + raw token
|
||||
# and allow deletion, so every action must be restricted to project admins
|
||||
# (mirroring create). Without these, the default list/retrieve/destroy
|
||||
# inherited only IsAuthenticated, letting any workspace user read/delete
|
||||
# another project's invitations.
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def list(self, request, slug, project_id):
|
||||
return super().list(request)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
return super().retrieve(request)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
return super().destroy(request)
|
||||
|
||||
@allow_permission([ROLE.ADMIN])
|
||||
def create(self, request, slug, project_id):
|
||||
emails = request.data.get("emails", [])
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
"""Contract tests for ``ProjectInvitationsViewset`` authorization.
|
||||
|
||||
Regression coverage for GHSA-r68c-48rr-m67f (WEB-8291).
|
||||
|
||||
The viewset declared no ``permission_classes`` (inheriting ``IsAuthenticated``)
|
||||
and only decorated ``create`` with ``@allow_permission([ROLE.ADMIN])``. The
|
||||
default ``list`` / ``retrieve`` / ``destroy`` actions were therefore ungated and
|
||||
``get_queryset`` was scoped only by URL slug + project_id, so any authenticated
|
||||
user could read another project's pending invitations — including invitee
|
||||
``email`` and the raw ``token`` — and delete them.
|
||||
|
||||
The fix gates every action to project admins (``@allow_permission([ROLE.ADMIN])``).
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import Project, ProjectMember, ProjectMemberInvite, User, WorkspaceMember
|
||||
|
||||
|
||||
def _invites_url(slug, project_id, pk=None):
|
||||
base = f"/api/workspaces/{slug}/projects/{project_id}/invitations/"
|
||||
return f"{base}{pk}/" if pk else base
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
"""A project where ``create_user`` (session_client) is an active admin."""
|
||||
project = Project.objects.create(
|
||||
name="Invite Project", identifier="INV", workspace=workspace, created_by=create_user
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=create_user, workspace=workspace, role=20, is_active=True
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invite(db, workspace, project):
|
||||
"""A pending project invitation carrying an email + raw token."""
|
||||
return ProjectMemberInvite.objects.create(
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
email="invitee@plane.so",
|
||||
token="super-secret-token",
|
||||
role=15,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def outsider_client(db, workspace):
|
||||
"""A workspace member who is a member of a *different* project, not ``project``.
|
||||
|
||||
Mirrors the vulnerable scenario: the caller is a legitimate workspace user
|
||||
with project membership elsewhere, but not in the target project.
|
||||
"""
|
||||
uid = uuid4().hex[:8]
|
||||
outsider = User.objects.create(email=f"outsider-{uid}@plane.so", username=f"outsider_{uid}")
|
||||
outsider.set_password("test-password")
|
||||
outsider.save()
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=outsider, role=15)
|
||||
other = Project.objects.create(
|
||||
name="Other Project", identifier="OTH", workspace=workspace, created_by=outsider
|
||||
)
|
||||
ProjectMember.objects.create(
|
||||
project=other, member=outsider, workspace=workspace, role=20, is_active=True
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=outsider)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestProjectInviteListScope:
|
||||
@pytest.mark.django_db
|
||||
def test_non_project_member_cannot_list_invitations(self, outsider_client, workspace, project, invite):
|
||||
response = outsider_client.get(_invites_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_non_project_member_cannot_retrieve_invitation(self, outsider_client, workspace, project, invite):
|
||||
response = outsider_client.get(_invites_url(workspace.slug, project.id, pk=invite.id))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_non_project_member_cannot_delete_invitation(self, outsider_client, workspace, project, invite):
|
||||
response = outsider_client.delete(_invites_url(workspace.slug, project.id, pk=invite.id))
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
assert ProjectMemberInvite.objects.filter(pk=invite.id).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_admin_can_list_invitations(self, session_client, workspace, project, invite):
|
||||
"""Positive control: an active project admin can still list invitations."""
|
||||
response = session_client.get(_invites_url(workspace.slug, project.id))
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
ids = {str(item["id"]) for item in response.json()}
|
||||
assert str(invite.id) in ids
|
||||
Reference in New Issue
Block a user