* [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>
53 lines
2.2 KiB
Python
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)
|