jira/apps/api/plane/app/views/page/version.py
Manish Gupta cfe951c8af
[WEB-8095] fix: scope page-version reads to the URL project (GHSA-g49r/ghcr) (#9380)
* [WEB-8095] fix: scope page-version reads to the URL project (GHSA-g49r/ghcr)

ProjectPagePermission verified the caller was a member of the URL
project_id but then resolved the page by workspace + page_id only, and
PageVersionEndpoint filtered versions the same way. A member of one
project could read the page versions of a public page belonging to a
different project in the same workspace via that project's URL
(GHSA-g49r-p85q-qq2w / GHSA-ghcr-frqr-6pqr).

- Scope the page lookup in ProjectPagePermission to projects__id via the
  ProjectPage M2M (both app/ and utils/ copies); deny when the page does
  not belong to the URL project.
- Scope PageVersionEndpoint list/detail querysets to
  page__projects__id=project_id (defense in depth); distinct() on the
  list guards against active + soft-deleted ProjectPage duplicates.
- Add contract regression tests (fail-before verified).

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8095] fix: require active ProjectPage link when scoping pages to a project

Address CodeRabbit + Copilot review on #9380: projects__id=project_id
matched even soft-deleted ProjectPage links, so a page removed from the
project (link revoked) would still pass, and the version detail get()
could raise MultipleObjectsReturned on active + soft-deleted rows.

Put both conditions on the same project_pages relation in one filter so
they match a single ProjectPage row that is active:
project_pages__project_id=project_id + project_pages__deleted_at__isnull
=True. The partial-unique constraint (project, page WHERE deleted_at IS
NULL) then guarantees at most one row, so get() stays unambiguous and the
list needs no distinct(). Add a revoked-link regression test.

Co-authored-by: Plane AI <noreply@plane.so>

* [WEB-8095] fix: distinct() on page-version detail lookup as a MultipleObjectsReturned guard

Address CodeRabbit review on #9380. The active-link filter already keeps
the page__project_pages join to a single row via the partial-unique
constraint, but add distinct() to the detail get() as defense in depth so
the join can never surface MultipleObjectsReturned (a 500) even if that
invariant were ever violated.

Co-authored-by: Plane AI <noreply@plane.so>

---------

Co-authored-by: Plane AI <noreply@plane.so>
2026-07-16 16:03:06 +05:30

53 lines
2.2 KiB
Python

# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import PageVersion
from ..base import BaseAPIView
from plane.app.serializers import PageVersionSerializer, PageVersionDetailSerializer
from plane.app.permissions import ProjectPagePermission
class PageVersionEndpoint(BaseAPIView):
permission_classes = [ProjectPagePermission]
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
# Return a single page version. Scope to an *active* ProjectPage link
# for the URL project so a page belonging to (or removed from)
# another project cannot be read via this endpoint (GHSA-g49r /
# GHSA-ghcr). The active-link partial-unique constraint keeps the
# join to a single row; distinct() is a defensive guard so the
# page__project_pages join can never make get() raise
# MultipleObjectsReturned (a 500).
page_version = (
PageVersion.objects.filter(
workspace__slug=slug,
page__project_pages__project_id=project_id,
page__project_pages__deleted_at__isnull=True,
page_id=page_id,
pk=pk,
)
.distinct()
.get()
)
# Serialize the page version
serializer = PageVersionDetailSerializer(page_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all page versions scoped to an active ProjectPage link for the
# URL project (defense in depth).
page_versions = PageVersion.objects.filter(
workspace__slug=slug,
page__project_pages__project_id=project_id,
page__project_pages__deleted_at__isnull=True,
page_id=page_id,
)
# Serialize the page versions
serializer = PageVersionSerializer(page_versions, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)