[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:
Manish Gupta
2026-07-24 17:23:13 +05:30
parent a8e53b6ac7
commit 6b0f69ba11
2 changed files with 117 additions and 0 deletions

View File

@@ -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)

View File

@@ -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}"
)