mirror of
https://github.com/makeplane/plane.git
synced 2026-09-02 03:59:00 +02:00
[WEB-8374] fix(security): scope ProjectMemberPreferenceEndpoint to the caller (GHSA-gx67-r6wp-3357)
ProjectMemberPreferenceEndpoint.get/patch took a member_id URL param and loaded the ProjectMember by (project_id, member_id, workspace__slug) with no check that member_id is the requesting user. Any project member (including a Guest) could read and modify any other member's per-project preferences by supplying their member_id. Preferences are personal — reject any request where member_id != request.user.id with 403, on both get and patch. Adds 4 contract tests (cross-member read/write blocked, own read/write allowed); fail-before verified. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -389,6 +389,14 @@ class ProjectMemberPreferenceEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, member_id):
|
||||
# Preferences are personal: a member may only read/modify their OWN
|
||||
# preferences. member_id is a URL param, so reject any mismatch to prevent
|
||||
# cross-member IDOR (GHSA-gx67-r6wp-3357).
|
||||
if str(member_id) != str(request.user.id):
|
||||
return Response(
|
||||
{"error": "You cannot access another member's preferences."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
project_member = self.get_queryset(slug, project_id, member_id)
|
||||
|
||||
serializer = ProjectMemberPreferenceSerializer(project_member, {"preferences": request.data}, partial=True)
|
||||
@@ -401,6 +409,13 @@ class ProjectMemberPreferenceEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, member_id):
|
||||
# Preferences are personal: a member may only read their OWN preferences.
|
||||
# member_id is a URL param, so reject any mismatch (GHSA-gx67-r6wp-3357).
|
||||
if str(member_id) != str(request.user.id):
|
||||
return Response(
|
||||
{"error": "You cannot access another member's preferences."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
project_member = self.get_queryset(slug, project_id, member_id)
|
||||
|
||||
serializer = ProjectMemberPreferenceSerializer(project_member)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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 ProjectMemberPreferenceEndpoint ownership scoping.
|
||||
|
||||
Regression coverage for GHSA-gx67-r6wp-3357. The endpoint takes a ``member_id``
|
||||
URL parameter and loaded the ``ProjectMember`` by ``(project_id, member_id,
|
||||
workspace__slug)`` with no check that ``member_id`` is the caller — so any project
|
||||
member (including a Guest) could read and modify any other member's per-project
|
||||
preferences.
|
||||
|
||||
The fix rejects any request where ``member_id != request.user.id`` (403);
|
||||
preferences are personal.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import Project, ProjectMember, User, WorkspaceMember
|
||||
|
||||
PREF_URL = "/api/workspaces/{slug}/projects/{project_id}/preferences/member/{member_id}/"
|
||||
|
||||
|
||||
def _member(workspace, project, *, role):
|
||||
unique = uuid4().hex[:8]
|
||||
user = User.objects.create(email=f"pref-{role}-{unique}@plane.so", username=f"pref_{role}_{unique}")
|
||||
user.set_password("test-password")
|
||||
user.save()
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=user, role=role, is_active=True)
|
||||
ProjectMember.objects.create(project=project, member=user, workspace=workspace, role=role, is_active=True)
|
||||
return user
|
||||
|
||||
|
||||
def _client(user):
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=user)
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project(db, workspace, create_user):
|
||||
project = Project.objects.create(
|
||||
name="Pref Project", identifier="PR", workspace=workspace, created_by=create_user
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=create_user, workspace=workspace, role=20, is_active=True)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
@pytest.mark.django_db
|
||||
class TestMemberPreferenceScope:
|
||||
"""A member may only read/modify their OWN project preferences."""
|
||||
|
||||
def test_member_cannot_read_others_preferences(self, workspace, project, create_user):
|
||||
attacker = _member(workspace, project, role=15)
|
||||
# attacker requests the admin (create_user)'s preferences
|
||||
response = _client(attacker).get(
|
||||
PREF_URL.format(slug=workspace.slug, project_id=project.id, member_id=create_user.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
def test_member_cannot_modify_others_preferences(self, workspace, project, create_user):
|
||||
attacker = _member(workspace, project, role=15)
|
||||
victim_member = ProjectMember.objects.get(project=project, member=create_user)
|
||||
original = victim_member.preferences
|
||||
|
||||
response = _client(attacker).patch(
|
||||
PREF_URL.format(slug=workspace.slug, project_id=project.id, member_id=create_user.id),
|
||||
{"pinned": ["hacked"]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
victim_member.refresh_from_db()
|
||||
assert victim_member.preferences == original, "Another member's preferences were modified"
|
||||
|
||||
def test_member_can_read_own_preferences(self, workspace, project):
|
||||
member = _member(workspace, project, role=15)
|
||||
response = _client(member).get(
|
||||
PREF_URL.format(slug=workspace.slug, project_id=project.id, member_id=member.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
|
||||
def test_member_can_modify_own_preferences(self, workspace, project):
|
||||
member = _member(workspace, project, role=15)
|
||||
response = _client(member).patch(
|
||||
PREF_URL.format(slug=workspace.slug, project_id=project.id, member_id=member.id),
|
||||
{"pinned": ["my-view"]},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK, (
|
||||
f"Got {response.status_code}: {getattr(response, 'data', None)!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user