Overview

workspace asset download bypass in plane

October 12, 2026
3 min read
share

TL;DR

Plane 1.3.1 had two download routes for the same file asset. The project-scoped route checked project membership and returned 403 for outsiders. The workspace-scoped route only checked workspace membership and handed back a signed download URL.

  • CVE-2026-104960 (Moderate, CWE-639, CWE-862)
  • A workspace guest with no access to a secret project could download that project’s private attachments by calling the workspace route directly with the asset UUID
  • Fixed in Plane 1.4.0

I reported this on May 21 and I’m credited as reporter on the advisory.

The bug

Plane serves file downloads through this workspace-scoped endpoint:

GET /api/assets/v2/workspaces/<workspace_slug>/download/<asset_id>/

WorkspaceAssetDownloadEndpoint.get() resolves the asset by id, workspace__slug, and is_uploaded=True, then authorizes at workspace scope. For project-bound assets (issue attachments, comment and page descriptions, project covers), it never asks whether the caller belongs to the owning project.

The adjacent project-scoped route does ask:

GET /api/assets/v2/workspaces/<workspace_slug>/projects/<project_id>/download/<asset_id>/

Same asset, same workspace, different answer. The authorization model had a gap exactly shaped like the route table: one route scoped by project, the other not.

Attacker model

  • Authenticated
  • A member of the victim’s workspace (lowest confirmed role: workspace guest)
  • Not a member of the victim secret project
  • Knows the target asset UUID

Authenticated users outside the workspace are denied outright, so this stays inside the workspace boundary. It breaks the project boundary within it.

Proof of concept

Set up a workspace with an attacker (workspace only) and a victim (member of a secret project holding an uploaded ISSUE_ATTACHMENT). As the attacker, request the project-scoped route first:

GET /api/assets/v2/workspaces/<workspace_slug>/projects/<secret_project_id>/download/<victim_asset_id>/

Response: 403. Correct.

Then request the workspace-scoped route for the same asset:

GET /api/assets/v2/workspaces/<workspace_slug>/download/<victim_asset_id>/

Response: 302 with a signed download URL for the victim’s file.

The regression test I wrote against the Plane 1.3.1 source tree pins this down:

exploit_response = session_client.get(self.get_workspace_download_url(workspace.slug, victim_asset.id))
> assert exploit_response.status_code == 403
E assert 302 == 403
E + where 302 = <HttpResponseRedirect ... url="https://signed.example/victim-secret.txt">.status_code

The negative control matters here. The project-scoped route denies the same caller for the same asset, so this is not a test setup artifact: the server knows how to say no and one route simply never asks.

Impact

Secret projects in Plane exist so workspace members outside the project cannot see its content. This bug let any workspace member pull file-backed private content (attachments, descriptions, covers) out of those projects with nothing but the asset UUID.

CVSS 3.1 vector: AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N: network-reachable, low complexity, low privileges, no user interaction, high confidentiality impact, no integrity or availability impact.

Out of scope for this report: unauthenticated access, integrity impact, and anything outside the four confirmed project-bound asset types.

The fix

Patched in Plane 1.4.0. Conceptually the fix is what you would expect: the workspace-scoped handler now has to resolve the asset’s owning project and enforce membership there, the same question the project-scoped route already asked.

The broader lesson, which I keep running into across projects: when two routes serve the same object at different scopes, the broader route needs every check the narrower one has, plus its own. A workspace check is not a project check, the same way a tenant check is not an object check. Convenience routes that skip a path segment tend to skip the authorization that lived in it.

Timeline

  • May 21, 2026: Opened the private report, added as collaborator and credited reporter
  • May 23, 2026: Accepted the reporter credit
  • Aug 3, 2026: Report accepted by the Plane team, CVE requested
  • Sep 28, 2026: Advisory GHSA-wrrw-wvfv-67h7 published
  • Oct 2026: CVE-2026-104960 assigned

References

Related posts