Skip to content

Cross-project page-version disclosure via project-agnostic page lookup in ProjectPagePermission (PageVersionEndpoint)

Moderate
mguptahub published GHSA-g49r-p85q-qq2w Sep 28, 2026

Software

makeplane/plane

Affected versions

<= 1.3.1

Patched versions

1.4.0

Description

Duplicate of GHSA-ghcr-frqr-6pqr.

This report describes the same underlying bug: ProjectPagePermission derives the caller's role from the URL's project_id, but the target page is resolved by workspace only — letting any workspace member read another project's page version history. See GHSA-ghcr-frqr-6pqr for the authoritative report and remediation details. This advisory is being withdrawn to avoid duplicate tracking; the reporter's credit for this finding has been carried over to the surviving advisory.

Cross-project page-version disclosure via project-agnostic page lookup in ProjectPagePermission (PageVersionEndpoint)

Field Value
Severity Medium — CVSS 3.1 6.5
CVSS vector AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N
CWE CWE-639 (Authorization Bypass Through User-Controlled Key)
Component apps/api/plane/app/permissions/page.py:42 (ProjectPagePermission.has_permission), enforced for apps/api/plane/app/views/page/version.py:23,28 (PageVersionEndpoint.get)
Affected Plane Community Edition through 1.3.1 (latest release); no fixed version as of 1.3.1
Reporter Ibrahim Hashimov — AuditCode.ai

Summary

ProjectPagePermission — the permission class guarding the page endpoints — derives the caller's
role from the URL's project_id, but then resolves the target page by workspace + page id
only
, with no filter binding the page to that project:

page = Page.objects.get(id=page_id, workspace__slug=slug)   # permissions/page.py:42 — no projects__id

A GET on PageVersionEndpoint (.../projects/<project_id>/pages/<page_id>/versions/<pk>/) is
therefore authorised against the caller's role in their own project, while the returned
PageVersion belongs to any project in the same workspace. The version view itself also never
re-scopes by project (views/page/version.py:23,28). A GUEST of one project can read the full
saved body (description_html / description_stripped / description_json) of a PUBLIC page
living in a project where they have no membership at all. The base page CRUD (PageViewSet) is
not affected because its get_queryset re-scopes every row by the URL project — the leak exists
only on the version endpoint.

Attack scenario (trust boundary)

The trust boundary is the project: within one workspace, a user's role in project A must not
grant read access to content in project B they are not a member of. Concrete path:

  1. Attacker is an authenticated workspace user and a GUEST of project A only (the lowest
    project role — self-service invites routinely hand this out).
  2. A PUBLIC page P (access = 0) lives in project B, where the attacker has no
    membership
    . The attacker obtains the page's UUID and a version PK out-of-band (shared links,
    referrer headers, notification payloads, logs, or any other endpoint that echoes page ids).
  3. The attacker issues
    GET /api/workspaces/<slug>/projects/<A>/pages/<P>/versions/<pk>/ — routing the request
    through their own project A. ProjectPagePermission confirms the attacker is a GUEST of A
    (a valid SAFE-method role), then loads page P by workspace + id only, sees it is public, and
    allows. The view returns page P's version body verbatim.

No victim interaction is required — the attacker reads directly.

Vulnerable code

apps/api/plane/app/permissions/page.py:37-53 (ProjectPagePermission.has_permission):

# Hook for extended validation
extended_access, role = self._check_access_and_get_role(request, slug, project_id)  # role from URL project
if extended_access is False:
    return False

if page_id:
    page = Page.objects.get(id=page_id, workspace__slug=slug)      # line 42 — NO projects__id filter

    # Allow access if the user is the owner of the page
    if page.owned_by_id == user_id:
        return True

    # Handle private page access
    if page.access == Page.PRIVATE_ACCESS:
        return self._has_private_page_action_access(request, slug, page, project_id)

# Handle public page access
return self._has_public_page_action_access(request, role)          # public + GUEST SAFE method → allow

The role comes from _check_project_member_access(... project_id=project_id) (line 55-68) — bound to
the URL project — but the page on line 42 is fetched with workspace__slug only. Nothing ties the
two together, so the role belongs to project A while the page belongs to project B.

apps/api/plane/app/views/page/version.py:19-31 (PageVersionEndpoint.get) then returns the object
without any project scope of its own:

def get(self, request, slug, project_id, page_id, pk=None):
    if pk:
        page_version = PageVersion.objects.get(workspace__slug=slug, page_id=page_id, pk=pk)  # line 23 — sink
        serializer = PageVersionDetailSerializer(page_version)
        return Response(serializer.data, status=status.HTTP_200_OK)
    page_versions = PageVersion.objects.filter(workspace__slug=slug, page_id=page_id)          # line 28
    serializer = PageVersionSerializer(page_versions, many=True)
    return Response(serializer.data, status=status.HTTP_200_OK)

Note for triage. The live, wired permission is plane/app/permissions/page.py
(plane.app.permissions.__init__ imports from .page import ProjectPagePermission, which
version.py uses). There is a second, byte-similar copy at
plane/utils/permissions/page.py that is not used by PageVersionEndpoint; this advisory
concerns the live app/permissions/page.py only.

Root cause & call stack

The object-scope guard is split: the role is scoped to the URL project, the page is not, and
the version view adds no scope of its own. Exact chain for GET .../projects/<A>/pages/<P>/versions/<pk>/:

PageVersionEndpoint.get(slug, project_id=A, page_id=P, pk)         # views/page/version.py:19
  → ProjectPagePermission.has_permission(request, view)           # permissions/page.py:24
    → _check_access_and_get_role(slug, project_id=A)              # role = GUEST-in-A  (page.py:70-78)
    → Page.objects.get(id=P, workspace__slug=slug)                # page.py:42 — resolves P in project B
    → page.access == PUBLIC → _has_public_page_action_access(role)# page.py:52-53
    → _check_project_action_access: SAFE method + GUEST → True    # page.py:96-100  ← ALLOW
  → PageVersion.objects.get(workspace__slug, page_id=P, pk)       # version.py:23  ← SINK: returns P's body

Root cause: Page.objects.get(id=page_id, workspace__slug=slug) omits the
projects__id=project_id constraint, so the authorisation decision is made against a role in a
different project than the resource being served, and the version endpoint never re-imposes the
project scope.

Proof of Concept

Runtime-verified against a default Plane CE 1.3.1 stack (docker compose, self-signup enabled),
driving the real HTTP API. Seeding: one workspace W; victim owns projects A and B; attacker is
a GUEST of A only (never a member of B); one PUBLIC page P in project B carrying a
saved PageVersion whose body contains an attacker-unique canary. The attacker then issues three
requests — the page's real project route (control), the base page CRUD (baseline), and the version
route via the attacker's own project (exploit):

ver_via_b  = f"/api/workspaces/{WS}/projects/{b_id}/pages/{page_id}/versions/{version_pk}/"  # real project
ver_via_a  = f"/api/workspaces/{WS}/projects/{a_id}/pages/{page_id}/versions/{version_pk}/"  # attacker's project
base_via_a = f"/api/workspaces/{WS}/projects/{a_id}/pages/{page_id}/"                        # base CRUD

attacker.get(ver_via_b)    # CONTROL  — project-membership gate fires
attacker.get(base_via_a)   # BASELINE — get_queryset re-scopes, page invisible
attacker.get(ver_via_a)    # EXPLOIT  — permission allows, version body returned

Observed (captured driver log, Plane CE 1.3.1, RUN=1782893722; UUIDs abbreviated):

[*] victim=victim002_… ws=ws002-… projectA=b739dda0… projectB=a4ffb8c3…
[*] PUBLIC page P=d3619d63… created in project B
[*] attacker=attacker002_… = GUEST of A only; seeded PageVersion v=9a332880…
[*] attacker session: attacker002_… (b45212c5…)
[CONTROL]  GET version via REAL project B (non-member) …/projects/a4ffb8c3…/pages/d3619d63…/versions/9a332880…/ -> HTTP 403
[BASELINE] GET base page CRUD via project A …/projects/b739dda0…/pages/d3619d63…/ -> HTTP 500 | canary_leaked=False
[EXPLOIT]  GET version via attacker project A …/projects/b739dda0…/pages/d3619d63…/versions/9a332880…/ -> HTTP 200 | {"id":"9a332880…","workspace":"f6b86c25…","page":"d3619d63…","last_saved_at":"2026-07-01T08:15:23.466910Z","description_binary":null,"descr…
[CAPTURE] page P projects=['a4ffb8c3…'] (expect B only)
[CAPTURE] attacker ProjectMember projects=['b739dda0…'] (expect A only)
[CAPTURE] canary present in EXPLOIT body = True
RESULT: TP-CONFIRMED — a GUEST of project A read the version body of a PUBLIC page living in project B

The differential is decisive: the same page id is 403 through its real project (control) and
does not leak through the base page CRUD (baseline — the scoped get_queryset leaves the page
invisible; the endpoint returns HTTP 500, a not-found, not the content), yet returns HTTP 200
with the canary body
through the attacker's own project on the version route. The in-container ORM
read-back confirms page P is linked to project B only and the attacker holds a ProjectMember
row for A only.

Impact

Cross-project confidentiality breach: any authenticated GUEST of one project can read the full saved
body of PUBLIC pages in other projects of the same workspace they have no membership in, given a
page UUID and version PK. Page-version bodies routinely contain internal documentation, meeting
notes, specs, and credentials-in-prose. Integrity and availability are unaffected (SAFE-method read
only). Private pages (access = PRIVATE) are not exposed — the permission returns False for
non-owners on private pages (line 49-50, _has_private_page_action_access → False).

Affected versions

Present in Plane Community Edition through 1.3.1 (the latest release; package.json → 1.3.1),
where both the project-agnostic Page.objects.get(id=page_id, workspace__slug=slug) in
ProjectPagePermission and the unscoped PageVersionEndpoint ship as shown. No fixed version
exists — verified line-by-line unfixed at v1.3.1, master, AND preview (upcoming release):
app/permissions/page.py is byte-identical across all three, projects__id scoping = 0 in the
permission, and PageVersionEndpoint carries no project filter.
The ProjectPagePermission /
page-version shape has shipped since at least v1.0.0 (class present in every 1.x tag). Stated
range: >= 1.0.0, <= 1.3.1, unpatched (high confidence on 1.3.1/master/preview; lower bound is the
earliest 1.x release, not a line-level bisect of the exact introducing commit).

CVSS justification

  • AV:N — exploited over the network-reachable REST API.
  • AC:L — a single authenticated GET; no race or special conditions. (A stricter analyst may
    argue AC:H because the attacker must obtain the page UUID and version PK out-of-band; that would
    lower the score to 5.3. The two UUIDs are the only real-world friction — see objections.)
  • PR:L — the attacker must be an authenticated GUEST of at least one project in the
    workspace (the lowest project role), routinely granted via self-service invites; not admin.
  • UI:N — no victim interaction; the attacker reads directly.
  • S:U — the authorization defect and the disclosed data are within the same Plane component.
  • C:H — the demonstrated read channel is the HTTP 200 response body, which returns the
    page version's complete description_html / description_stripped / description_json; the
    attacker controls which page is targeted and receives its full content (confirmed: the canary
    appears verbatim in the exploit response).
  • I:N / A:N — SAFE-method read only; no write or denial.

Base score 6.5 (Medium).

Why this is not a duplicate

No published Plane advisory or CVE covers page / page-version IDOR through ProjectPagePermission.
Checked against the entire current Plane advisory set, not one anchor — this sink is distinct from
every one:

  • CVE-2026-46558 / CVE-2026-27705 (GHSA-qw87 / GHSA-rfj3) — asset endpoints (WorkspaceFileAssetEndpoint / ProjectAssetEndpoint), different models & views.
  • CVE-2026-30244 (GHSA-87x4) — workspace-member information disclosure, a different endpoint and object.
  • CVE-2026-39374 (GHSA-4q54) — issue bulk-date write IDOR, different endpoint/verb/model.
  • CVE-2026-40102 (GHSA-93x3) — analytics ORM field-reference, unrelated.

None touches the page / page-version model or ProjectPagePermission. This is a novel cross-project
page-version read caused by the missing projects__id scope in the wired page-permission lookup —
verified line-by-line unfixed at v1.3.1, master, and preview (the permission class is
byte-identical across all three; no asset/member/issue remediation reached it).

Anticipated objections → rebuttals

  • "You cited a dead duplicate." No. The wired permission is app/permissions/page.py
    (plane.app.permissions.__init__ → from .page import ProjectPagePermission), which
    version.py imports and enforces. The runtime PoC exercises exactly this class; the
    utils/permissions/page.py copy is not on the PageVersionEndpoint path and is not referenced by
    this advisory.
  • "The base page CRUD already blocks this." It does — and that is the point. PageViewSet.get_queryset
    re-scopes by projects__project_projectmember__member + project=True, so the page is invisible
    there (baseline: no leak). The version endpoint has no equivalent scope, so the same page id
    leaks. The asymmetry is the vulnerability.
  • "The attacker needs the page UUID and version PK." True — this bounds exploitability (reflected
    in the AC:H alternative above), but UUIDs leak routinely (shared page links, referrer headers,
    notification payloads, activity feeds, logs) and are not a confidentiality control. Once obtained,
    the read is deterministic and requires only GUEST membership of any one project.
  • "Only public pages leak." Correct and stated honestly. The impact is scoped to access = 0
    pages; private pages return False. Public-in-project-B still crosses the project trust boundary
    for a non-member, and page bodies commonly hold sensitive internal content.

Suggested remediation

Bind the page to the URL project in the permission so authorization is decided against the same
project that serves the resource — fail closed when the page is not in that project:

--- a/apps/api/plane/app/permissions/page.py
+++ b/apps/api/plane/app/permissions/page.py
         if page_id:
-            page = Page.objects.get(id=page_id, workspace__slug=slug)
+            # Scope the page to the URL project: the role was derived from `project_id`,
+            # so the page must belong to that same project or access is denied.
+            try:
+                page = Page.objects.get(
+                    id=page_id, workspace__slug=slug, projects__id=project_id
+                )
+            except Page.DoesNotExist:
+                return False

Defence in depth — also re-scope the version query in the view so it cannot serve a page from another
project even if the permission is bypassed:

--- a/apps/api/plane/app/views/page/version.py
+++ b/apps/api/plane/app/views/page/version.py
         if pk:
-            page_version = PageVersion.objects.get(workspace__slug=slug, page_id=page_id, pk=pk)
+            page_version = PageVersion.objects.get(
+                workspace__slug=slug, page__projects__id=project_id, page_id=page_id, pk=pk
+            )
             serializer = PageVersionDetailSerializer(page_version)
             return Response(serializer.data, status=status.HTTP_200_OK)
-        page_versions = PageVersion.objects.filter(workspace__slug=slug, page_id=page_id)
+        page_versions = PageVersion.objects.filter(
+            workspace__slug=slug, page__projects__id=project_id, page_id=page_id
+        )

(Return 404 on the resulting DoesNotExist rather than the current unhandled 500.)

References

Disclosure, credit & coordination

Discovered by AuditCode's AI code-security engine — an LLM-based, three-stage reasoning
pipeline (declaration extraction → per-module review → cross-module data-flow analysis). Every
finding is then manually verified by a human researcher against the live code before submission — no
raw model output is ever sent to a maintainer.

  • Credit (GHSA Credits field): Ibrahim Hashimov — AuditCode Research, Brussels, Belgium.
  • Methodology: https://auditcode.ai/research/methodology
  • Coordination contact: security@auditcode.ai
  • Affected package: Plane Community Edition (makeplane/plane; self-hosted / Docker).
  • CVE: we request a CVE identifier be assigned via this GitHub Security Advisory.
  • Verification: reproduced against a released Plane CE 1.3.1 stack (docker compose, default
    self-signup config) driving the real HTTP API; full reproduction package (PoC driver + captured
    driver log) available to the security team on request.

Coordination. Available for coordinated disclosure on the maintainer's preferred timeline;
proof-of-concept shared privately, and the public advisory limited to what is needed to validate the
fix. Full disclosure policy: https://auditcode.ai/research#disclosure

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N

CVE ID

No known CVE

Weaknesses

Authorization Bypass Through User-Controlled Key

The system's authorization functionality does not prevent one user from gaining access to another user's data or record by modifying the key value identifying the data. Learn more on MITRE.

Credits