From 96d4ae95f0fc4aaf33b37c3c571b1a69fbcfad5e Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Fri, 28 Aug 2026 15:28:23 +0530 Subject: [PATCH 1/2] [INFRA-778] fix(security): scope ExportIssuesEndpoint.get to the requesting user's own exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExportIssuesEndpoint.get filtered ExporterHistory by workspace__slug only, with no initiated_by filter, so any workspace ADMIN/MEMBER could list every other member's export history. ExporterHistorySerializer includes url (a presigned S3 link, 7-day expiry, no auth required to use) and token. Since an export defaults to the initiator's own projects (including fully private ones) when no project list is supplied, any workspace member could read another member's private project data by listing exports and using the disclosed url — no crafted request needed, and revoking the user's access doesn't revoke the download. Add initiated_by=request.user to the queryset filter, per the advisory's own suggested fix. 2 new tests, fail-before verified. Co-authored-by: Plane AI --- apps/api/plane/app/views/exporter/base.py | 12 ++- .../contract/app/test_export_history_scope.py | 89 +++++++++++++++++++ 2 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 apps/api/plane/tests/contract/app/test_export_history_scope.py diff --git a/apps/api/plane/app/views/exporter/base.py b/apps/api/plane/app/views/exporter/base.py index 64364ecf470..96543e8d771 100644 --- a/apps/api/plane/app/views/exporter/base.py +++ b/apps/api/plane/app/views/exporter/base.py @@ -66,9 +66,15 @@ def post(self, request, slug): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE") def get(self, request, slug): - exporter_history = ExporterHistory.objects.filter(workspace__slug=slug, type="issue_exports").select_related( - "workspace", "initiated_by" - ) + # Scoped to the requesting user: an export defaults to the initiator's + # own projects (including private ones), and ExporterHistorySerializer + # returns a presigned download url (7-day, no-auth-required) plus a + # token — without this filter, any workspace member could list and use + # another member's export, reading issues from projects they cannot + # otherwise access. + exporter_history = ExporterHistory.objects.filter( + workspace__slug=slug, type="issue_exports", initiated_by=request.user + ).select_related("workspace", "initiated_by") if request.GET.get("per_page", False) and request.GET.get("cursor", False): return self.paginate( diff --git a/apps/api/plane/tests/contract/app/test_export_history_scope.py b/apps/api/plane/tests/contract/app/test_export_history_scope.py new file mode 100644 index 00000000000..cd54dbaabb4 --- /dev/null +++ b/apps/api/plane/tests/contract/app/test_export_history_scope.py @@ -0,0 +1,89 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Regression test for cross-member export-history disclosure. + +Root cause: ExportIssuesEndpoint.get filtered ExporterHistory by +workspace__slug only, with no initiated_by filter — so any workspace +ADMIN/MEMBER could list every other member's export history. +ExporterHistorySerializer includes `url` (a presigned S3 link, 7-day expiry, +no auth required to use) and `token`. Since an export defaults to the +initiator's own projects (including fully private ones) when no project list +is supplied, this let any workspace member read another member's private +project data by listing exports and using the disclosed url — no crafted +request needed, no revocation possible once disclosed. + +Fixed by adding initiated_by=request.user to the queryset filter. +""" + +from uuid import uuid4 + +import pytest +from rest_framework.test import APIClient + +from plane.db.models import ExporterHistory, User, WorkspaceMember + +pytestmark = pytest.mark.contract + + +def _make_user(prefix): + unique = uuid4().hex[:8] + user = User.objects.create(email=f"{prefix}-{unique}@plane.so", username=f"{prefix}_{unique}") + user.set_password("test-password") + user.save() + return user + + +def _client_for(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def _export_url(slug): + return f"/api/workspaces/{slug}/export-issues/?per_page=10&cursor=10:0:0" + + +@pytest.fixture +def other_member(db, workspace): + """A second, active workspace MEMBER (role 15) — a different user than + the workspace fixture's own admin/creator.""" + user = _make_user("other-member") + WorkspaceMember.objects.create(workspace=workspace, member=user, role=15, is_active=True) + return user + + +@pytest.fixture +def own_export(db, workspace, create_user): + """An export initiated by create_user (the workspace admin).""" + return ExporterHistory.objects.create( + workspace=workspace, + project=[], + initiated_by=create_user, + provider="csv", + type="issue_exports", + url="https://example-bucket.s3.amazonaws.com/secret-export.csv?X-Amz-Signature=forged", + ) + + +@pytest.mark.django_db +class TestExportHistoryScope: + def test_member_cannot_see_another_members_export(self, workspace, own_export, other_member): + """other_member did not initiate own_export (create_user's) — must + not see it, and therefore must never receive its presigned url.""" + response = _client_for(other_member).get(_export_url(workspace.slug)) + + assert response.status_code == 200, response.data + result_ids = [str(row["id"]) for row in response.data["results"]] + assert str(own_export.id) not in result_ids, "a workspace member must not see another member's export history" + + def test_member_can_still_see_their_own_export(self, workspace, create_user, own_export): + """Positive control: the initiator must still see their own export.""" + response = _client_for(create_user).get(_export_url(workspace.slug)) + + assert response.status_code == 200, response.data + result_ids = [str(row["id"]) for row in response.data["results"]] + assert str(own_export.id) in result_ids + own_row = next(row for row in response.data["results"] if str(row["id"]) == str(own_export.id)) + assert own_row["url"] == own_export.url From 1b2716c9304c6bfa3609221819bd5b5222215ae5 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Fri, 28 Aug 2026 15:34:44 +0530 Subject: [PATCH 2/2] [INFRA-778] use status.HTTP_200_OK instead of bare 200 in the new test Address Copilot finding on PR #9707: this repo's contract tests consistently assert HTTP codes via rest_framework.status constants. Co-authored-by: Plane AI --- .../plane/tests/contract/app/test_export_history_scope.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/tests/contract/app/test_export_history_scope.py b/apps/api/plane/tests/contract/app/test_export_history_scope.py index cd54dbaabb4..2a6fd2fe3cc 100644 --- a/apps/api/plane/tests/contract/app/test_export_history_scope.py +++ b/apps/api/plane/tests/contract/app/test_export_history_scope.py @@ -20,6 +20,7 @@ from uuid import uuid4 import pytest +from rest_framework import status from rest_framework.test import APIClient from plane.db.models import ExporterHistory, User, WorkspaceMember @@ -74,7 +75,7 @@ def test_member_cannot_see_another_members_export(self, workspace, own_export, o not see it, and therefore must never receive its presigned url.""" response = _client_for(other_member).get(_export_url(workspace.slug)) - assert response.status_code == 200, response.data + assert response.status_code == status.HTTP_200_OK, response.data result_ids = [str(row["id"]) for row in response.data["results"]] assert str(own_export.id) not in result_ids, "a workspace member must not see another member's export history" @@ -82,7 +83,7 @@ def test_member_can_still_see_their_own_export(self, workspace, create_user, own """Positive control: the initiator must still see their own export.""" response = _client_for(create_user).get(_export_url(workspace.slug)) - assert response.status_code == 200, response.data + assert response.status_code == status.HTTP_200_OK, response.data result_ids = [str(row["id"]) for row in response.data["results"]] assert str(own_export.id) in result_ids own_row = next(row for row in response.data["results"] if str(row["id"]) == str(own_export.id))