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:
- Attacker is an authenticated workspace user and a GUEST of project A only (the lowest
project role — self-service invites routinely hand this out).
- 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).
- 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
Cross-project page-version disclosure via project-agnostic page lookup in
ProjectPagePermission(PageVersionEndpoint)AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:Napps/api/plane/app/permissions/page.py:42(ProjectPagePermission.has_permission), enforced forapps/api/plane/app/views/page/version.py:23,28(PageVersionEndpoint.get)Summary
ProjectPagePermission— the permission class guarding the page endpoints — derives the caller'srole from the URL's
project_id, but then resolves the target page by workspace + page idonly, with no filter binding the page to that project:
A
GETonPageVersionEndpoint(.../projects/<project_id>/pages/<page_id>/versions/<pk>/) istherefore authorised against the caller's role in their own project, while the returned
PageVersionbelongs to any project in the same workspace. The version view itself also neverre-scopes by project (
views/page/version.py:23,28). A GUEST of one project can read the fullsaved body (
description_html/description_stripped/description_json) of a PUBLIC pageliving in a project where they have no membership at all. The base page CRUD (
PageViewSet) isnot affected because its
get_querysetre-scopes every row by the URL project — the leak existsonly 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:
project role — self-service invites routinely hand this out).
P(access = 0) lives in project B, where the attacker has nomembership. 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).
GET /api/workspaces/<slug>/projects/<A>/pages/<P>/versions/<pk>/— routing the requestthrough their own project A.
ProjectPagePermissionconfirms the attacker is a GUEST of A(a valid SAFE-method role), then loads page
Pby workspace + id only, sees it is public, andallows. 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):The role comes from
_check_project_member_access(... project_id=project_id)(line 55-68) — bound tothe URL project — but the page on line 42 is fetched with
workspace__slugonly. Nothing ties thetwo 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 objectwithout any project scope of its own:
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>/:Root cause:
Page.objects.get(id=page_id, workspace__slug=slug)omits theprojects__id=project_idconstraint, so the authorisation decision is made against a role in adifferent 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 projectsAandB; attacker isa GUEST of A only (never a member of B); one PUBLIC page
Pin project B carrying asaved
PageVersionwhose body contains an attacker-unique canary. The attacker then issues threerequests — the page's real project route (control), the base page CRUD (baseline), and the version
route via the attacker's own project (exploit):
Observed (captured driver log, Plane CE 1.3.1,
RUN=1782893722; UUIDs abbreviated):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_querysetleaves the pageinvisible; 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
Pis linked to project B only and the attacker holds aProjectMemberrow 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 returnsFalsefornon-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)inProjectPagePermissionand the unscopedPageVersionEndpointship as shown. No fixed versionexists — verified line-by-line unfixed at v1.3.1,
master, ANDpreview(upcoming release):app/permissions/page.pyis byte-identical across all three,projects__idscoping = 0 in thepermission, and
PageVersionEndpointcarries no project filter. TheProjectPagePermission/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 mayargue
AC:Hbecause the attacker must obtain the page UUID and version PK out-of-band; that wouldlower 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 theworkspace (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 thepage version's complete
description_html/description_stripped/description_json; theattacker 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:
WorkspaceFileAssetEndpoint/ProjectAssetEndpoint), different models & views.None touches the page / page-version model or
ProjectPagePermission. This is a novel cross-projectpage-version read caused by the missing
projects__idscope in the wired page-permission lookup —verified line-by-line unfixed at v1.3.1,
master, andpreview(the permission class isbyte-identical across all three; no asset/member/issue remediation reached it).
Anticipated objections → rebuttals
app/permissions/page.py(
plane.app.permissions.__init__→from .page import ProjectPagePermission), whichversion.pyimports and enforces. The runtime PoC exercises exactly this class; theutils/permissions/page.pycopy is not on thePageVersionEndpointpath and is not referenced bythis advisory.
PageViewSet.get_querysetre-scopes by
projects__project_projectmember__member+project=True, so the page is invisiblethere (baseline: no leak). The version endpoint has no equivalent scope, so the same page id
leaks. The asymmetry is the vulnerability.
in the
AC:Halternative 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.
access = 0pages; private pages return
False. Public-in-project-B still crosses the project trust boundaryfor 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:
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:
(Return
404on the resultingDoesNotExistrather than the current unhandled500.)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.
makeplane/plane; self-hosted / Docker).docker compose, defaultself-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