CVE-2026-46558
Description
Plane versions prior to 1.3.1 allow authenticated users to bypass workspace authorization, enabling unauthorized access, modification, and deletion of assets across different workspaces.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Plane versions prior to 1.3.1 allow authenticated users to bypass workspace authorization, enabling unauthorized access, modification, and deletion of assets across different workspaces.
Vulnerability
Prior to version 1.3.1, Plane, an open-source project management tool, suffers from a cross-workspace asset authorization bypass. This vulnerability affects the V2 asset subsystem, specifically the WorkspaceFileAssetEndpoint and DuplicateAssetEndpoint. Any authenticated user can exploit this to read, copy, delete, and overwrite assets in workspaces they do not belong to. The issue was confirmed on Plane Community Edition 1.2.3 [1].
Exploitation
An attacker, authenticated as a regular user in one workspace, can target assets in another workspace. By exploiting the missing authorization checks in the WorkspaceFileAssetEndpoint, the attacker can directly access, modify, or delete assets using their IDs and the target workspace's slug. Furthermore, the DuplicateAssetEndpoint allows an attacker to copy any asset by its UUID without verifying access to the source workspace, enabling them to duplicate, and subsequently delete or overwrite, assets in their own workspace that belong to another [1].
Impact
Successful exploitation allows an attacker to gain unauthorized access to sensitive project assets across different workspaces. They can read, copy, delete, and overwrite any asset, including private project files and workspace logos. This breaks workspace isolation and can lead to data leakage, data corruption, or the defacement of workspace branding [1].
Mitigation
This vulnerability has been patched in Plane version 1.3.1, released on 2026-06-09 [2]. Users are advised to upgrade to version 1.3.1 or later to remediate the issue. No workarounds are specified in the available references.
AI Insight generated on Jun 10, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
2ac11c3ef7939fix: enforce workspace membership on V2 asset endpoints (#8885)
1 file changed · +15 −2
apps/api/plane/app/views/asset/v2.py+15 −2 modified@@ -18,7 +18,7 @@ # Module imports from ..base import BaseAPIView -from plane.db.models import FileAsset, Workspace, Project, User +from plane.db.models import FileAsset, Workspace, Project, User, WorkspaceMember from plane.settings.storage import S3Storage from plane.app.permissions import allow_permission, ROLE from plane.utils.cache import invalidate_cache_directly @@ -311,6 +311,7 @@ def entity_asset_delete(self, entity_type, asset, request): else: return + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def post(self, request, slug): name = request.data.get("name") type = request.data.get("type", "image/jpeg") @@ -376,6 +377,7 @@ def post(self, request, slug): status=status.HTTP_200_OK, ) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def patch(self, request, slug, asset_id): # get the asset id asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) @@ -397,6 +399,7 @@ def patch(self, request, slug, asset_id): asset.save(update_fields=["is_uploaded", "attributes"]) return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def delete(self, request, slug, asset_id): asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) asset.is_deleted = True @@ -406,6 +409,7 @@ def delete(self, request, slug, asset_id): asset.save(update_fields=["is_deleted", "deleted_at"]) return Response(status=status.HTTP_204_NO_CONTENT) + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug, asset_id): # get the asset id asset = FileAsset.objects.get(id=asset_id, workspace__slug=slug) @@ -752,7 +756,16 @@ def post(self, request, slug, asset_id): return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND) storage = S3Storage(request=request) - original_asset = FileAsset.objects.filter(id=asset_id, is_uploaded=True).first() + # Scope the source asset lookup to workspaces the caller is a member of + user_workspace_ids = WorkspaceMember.objects.filter( + member=request.user, + is_active=True, + ).values_list("workspace_id", flat=True) + original_asset = FileAsset.objects.filter( + id=asset_id, + is_uploaded=True, + workspace_id__in=user_workspace_ids, + ).first() if not original_asset: return Response({"error": "Asset not found"}, status=status.HTTP_404_NOT_FOUND)
9a30a07cf5e4fix(api): enforce workspace membership on GenericAssetEndpoint (#9212)
3 files changed · +152 −2
apps/api/plane/api/views/asset.py+7 −0 modified@@ -19,6 +19,7 @@ from plane.settings.storage import S3Storage from plane.utils.path_validator import sanitize_filename from plane.db.models import FileAsset, User, Workspace +from plane.app.permissions import WorkspaceUserPermission from plane.api.views.base import BaseAPIView from plane.api.serializers import ( UserAssetUploadSerializer, @@ -404,6 +405,12 @@ def delete(self, request, asset_id): class GenericAssetEndpoint(BaseAPIView): """This endpoint is used to upload generic assets that can be later bound to entities.""" + # The workspace is taken straight from the URL slug, so every method must + # verify the caller is an active member of that workspace. Without this the + # endpoint is a cross-workspace IDOR (the public-API sibling of the + # CVE-2026-46558 dashboard fix). + permission_classes = [WorkspaceUserPermission] + use_read_replica = True @asset_docs(
apps/api/plane/tests/contract/api/test_generic_asset.py+143 −0 added@@ -0,0 +1,143 @@ +# 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 the public REST API ``GenericAssetEndpoint``. + +Regression coverage for the cross-workspace asset IDOR (the unfixed +external-API sibling of CVE-2026-46558 / GHSA-qw87-v5w3-6vxx). The endpoint +must reject any caller that is not an active member of the workspace named in +the URL slug, regardless of the workspace their Personal Access Token came +from. +""" + +from unittest import mock +from uuid import uuid4 + +import pytest +from rest_framework import status + +from plane.db.models import FileAsset, User, Workspace, WorkspaceMember + + +@pytest.fixture +def victim_user(db): + """A user that owns a separate workspace the attacker is not part of.""" + unique_id = uuid4().hex[:8] + user = User.objects.create( + email=f"victim-{unique_id}@plane.so", + username=f"victim_{unique_id}", + first_name="Victim", + last_name="User", + ) + user.set_password("test-password") + user.save() + return user + + +@pytest.fixture +def victim_workspace(db, victim_user): + """A workspace whose only active member is ``victim_user``. + + The attacker (``create_user``, who authenticates ``api_key_client``) is + deliberately NOT a member here. + """ + workspace = Workspace.objects.create( + name="Victim Workspace", + owner=victim_user, + slug="victim-workspace", + ) + WorkspaceMember.objects.create(workspace=workspace, member=victim_user, role=20) + return workspace + + +@pytest.fixture +def victim_asset(db, victim_workspace, victim_user): + """An uploaded attachment that lives inside the victim workspace. + + ``storage_metadata`` is pre-populated so the PATCH handler does not enqueue + the metadata Celery task during the test. + """ + return FileAsset.objects.create( + attributes={"name": "secret.pdf", "type": "application/pdf", "size": 1024}, + asset=f"{victim_workspace.id}/secret.pdf", + size=1024, + workspace=victim_workspace, + created_by=victim_user, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_uploaded=True, + storage_metadata={"size": 1024}, + ) + + +@pytest.mark.contract +class TestGenericAssetCrossWorkspaceIDOR: + """A PAT holder must not reach assets in a workspace they don't belong to.""" + + def detail_url(self, slug, asset_id): + return f"/api/v1/workspaces/{slug}/assets/{asset_id}/" + + def list_url(self, slug): + return f"/api/v1/workspaces/{slug}/assets/" + + @pytest.mark.django_db + def test_get_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace, victim_asset): + """GET on another workspace's asset must be forbidden, not return a + presigned download URL.""" + url = self.detail_url(victim_workspace.slug, victim_asset.id) + + with mock.patch("plane.api.views.asset.S3Storage") as mock_storage: + mock_storage.return_value.generate_presigned_url.return_value = "https://signed.example/download" + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}" + # The S3 download URL must never be minted for a non-member. + mock_storage.return_value.generate_presigned_url.assert_not_called() + + @pytest.mark.django_db + def test_post_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace): + """POST (upload) into another workspace must be forbidden and must not + plant an asset row in the victim workspace.""" + url = self.list_url(victim_workspace.slug) + payload = {"name": "evil.pdf", "type": "application/pdf", "size": 1024} + + with mock.patch("plane.api.views.asset.S3Storage") as mock_storage: + mock_storage.return_value.generate_presigned_post.return_value = {"url": "x", "fields": {}} + response = api_key_client.post(url, payload, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}" + assert FileAsset.objects.filter(workspace=victim_workspace).count() == 0 + + @pytest.mark.django_db + def test_patch_cross_workspace_asset_returns_403(self, api_key_client, victim_workspace, victim_asset): + """PATCH on another workspace's asset must be forbidden and must leave + the asset untouched.""" + url = self.detail_url(victim_workspace.slug, victim_asset.id) + + response = api_key_client.patch(url, {"is_uploaded": False}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN, f"Got {response.status_code}: {response.data!r}" + victim_asset.refresh_from_db() + assert victim_asset.is_uploaded is True + + @pytest.mark.django_db + def test_member_can_patch_own_workspace_asset(self, api_key_client, workspace, create_user): + """Positive control: an active member of the workspace can still update + their own asset, so the fix does not over-block legitimate callers.""" + asset = FileAsset.objects.create( + attributes={"name": "mine.pdf", "type": "application/pdf", "size": 10}, + asset=f"{workspace.id}/mine.pdf", + size=10, + workspace=workspace, + created_by=create_user, + entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, + is_uploaded=False, + storage_metadata={"size": 10}, + ) + url = self.detail_url(workspace.slug, asset.id) + + response = api_key_client.patch(url, {"is_uploaded": True}, format="json") + + assert response.status_code == status.HTTP_204_NO_CONTENT, f"Got {response.status_code}: {response.data!r}" + asset.refresh_from_db() + assert asset.is_uploaded is True
.gitignore+2 −2 modified@@ -115,5 +115,5 @@ scripts/ # i18n auto-generated types (regenerated on every build) packages/i18n/src/types/keys.generated.ts -# Local security advisory notes (not for version control) -/advisories.md +# Local security notes (not for version control) +/security/
Vulnerability mechanics
Root cause
"Missing authorization checks in workspace asset handling allows unauthorized access and modification of assets across different workspaces."
Attack vector
An authenticated user can exploit this vulnerability by sending requests to the asset API endpoints. Specifically, by targeting the `WorkspaceFileAssetEndpoint` and `DuplicateAssetEndpoint` with crafted requests, an attacker can bypass workspace isolation. The attacker can read, copy, delete, and overwrite assets belonging to workspaces they are not a member of, as demonstrated by overwriting a workspace logo with attacker-controlled content [ref_id=1].
Affected code
The vulnerability lies within the `WorkspaceFileAssetEndpoint` and `DuplicateAssetEndpoint` handlers. Specifically, the code in `apps/api/plane/app/views/asset/v2.py` at lines 314, 379, 400, 409, and 736-780 fails to adequately verify the caller's authorization against the target workspace or source asset [ref_id=1].
What the fix does
The patch addresses the vulnerability by enforcing workspace authorization checks on asset-related API endpoints. This includes verifying caller membership and permissions for target workspaces before allowing operations like creating, reading, patching, or deleting assets. Additionally, the duplication endpoint now correctly authorizes both the source and destination workspaces, preventing unauthorized copying of assets [patch_id=5502046, patch_id=5502047].
Preconditions
- authThe attacker must be an authenticated user.
Reproduction
Start a fresh local Plane instance from commit 1faf06c7553d2bbce59634ae96fb498495c46d62. Create two normal users and two unrelated workspaces (e.g., Alpha and Bravo). As the Alpha user, create a project issue and upload a private asset. As the Bravo user, perform the following actions: 1. Request a GET request to download Alpha's asset. 2. Send a POST request to duplicate Alpha's asset into Bravo's workspace. 3. Send a DELETE request to delete Alpha's original asset. 4. Send a POST request to upload attacker-controlled content and then a PATCH request to overwrite Alpha's workspace logo with this content [ref_id=1].
Generated on Jun 10, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.