[WEB-7769] fix(security): scope EstimatePoint create/destroy to workspace and project (#9286)

GHSA-933r-rxg8-f3h2 — EstimatePointEndpoint.create trusted the
estimate_id URL parameter without verifying it belonged to the caller's
workspace and project. An authenticated user in project A could inject
estimate points into any other workspace's estimate by supplying a
foreign estimate_id.

Fix: added a workspace+project scoped Estimate ownership check before
EstimatePoint.objects.create().

GHSA-933r-rxg8-f3h2 (destroy) — old_estimate_point was fetched with
pk only (unscoped), allowing cross-tenant key disclosure and
manipulation during the key-rearrangement step.

Fix: scoped the old_estimate_point lookup to estimate_id + project_id +
workspace__slug; added 404 guard for missing/foreign points.

Note: BulkEstimatePointEndpoint.partial_update (GHSA-vm3j-5j49-gwrf)
was already correctly scoped at lines 116 and 125-130 — no change needed.

Co-authored-by: Plane AI <noreply@plane.so>
This commit is contained in:
Manish Gupta
2026-06-23 17:52:41 +05:30
committed by GitHub
parent 0d58adb69d
commit 971c2aadb4

View File

@@ -159,6 +159,17 @@ class EstimatePointEndpoint(BaseViewSet):
{"error": "Key and value are required"},
status=status.HTTP_400_BAD_REQUEST,
)
# Verify the estimate belongs to this workspace and project before creating a point
estimate = Estimate.objects.filter(
pk=estimate_id,
workspace__slug=slug,
project_id=project_id,
).first()
if not estimate:
return Response(
{"error": "Estimate not found"},
status=status.HTTP_404_NOT_FOUND,
)
key = request.data.get("key", 0)
value = request.data.get("value", "")
estimate_point = EstimatePoint.objects.create(
@@ -227,8 +238,18 @@ class EstimatePointEndpoint(BaseViewSet):
epoch=int(timezone.now().timestamp()),
)
# delete the estimate point
old_estimate_point = EstimatePoint.objects.filter(pk=estimate_point_id).first()
# delete the estimate point — scope to this estimate/project/workspace to prevent cross-tenant key manipulation
old_estimate_point = EstimatePoint.objects.filter(
pk=estimate_point_id,
estimate_id=estimate_id,
project_id=project_id,
workspace__slug=slug,
).first()
if not old_estimate_point:
return Response(
{"error": "Estimate point not found"},
status=status.HTTP_404_NOT_FOUND,
)
# rearrange the estimate points
updated_estimate_points = []