Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions apps/api/plane/app/views/exporter/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
90 changes: 90 additions & 0 deletions apps/api/plane/tests/contract/app/test_export_history_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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 import status
from rest_framework.test import APIClient

Comment thread
mguptahub marked this conversation as resolved.
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 == 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"

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 == 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))
assert own_row["url"] == own_export.url
Loading