From 3f06a93d74b52b0520efca125e218aee6200a12a Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 15 Dec 2025 13:07:34 -0800 Subject: [PATCH 1/4] fix(security): IDOR in PromptsActivityEndpoint GET - scope project by organization The GET endpoint accepted project_id in query params but didn't validate it belonged to the organization, allowing users to potentially query prompt activity data using project IDs from other organizations. Added organization scoping check to the GET method (PUT was already fixed in PR #104920) and a regression test for the GET endpoint. --- src/sentry/api/endpoints/prompts_activity.py | 10 ++++++++++ .../api/endpoints/test_prompts_activity.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/sentry/api/endpoints/prompts_activity.py b/src/sentry/api/endpoints/prompts_activity.py index 29f21e5f4f2974..defab49fd14446 100644 --- a/src/sentry/api/endpoints/prompts_activity.py +++ b/src/sentry/api/endpoints/prompts_activity.py @@ -52,6 +52,7 @@ def get(self, request: Request, **kwargs) -> Response: if len(features) == 0: return Response({"details": "No feature specified"}, status=400) + organization = kwargs.get("organization") conditions: Q | None = None for feature in features: if not prompt_config.has(feature): @@ -62,6 +63,15 @@ def get(self, request: Request, **kwargs) -> Response: if field not in request.GET: return Response({"detail": 'Missing required field "%s"' % field}, status=400) filters = {k: request.GET.get(k) for k in required_fields} + + # Validate project_id belongs to the organization (IDOR protection) + if "project_id" in required_fields and organization: + project_id = filters.get("project_id") + if project_id and not Project.objects.filter( + id=project_id, organization_id=organization.id + ).exists(): + return Response({"detail": "Project not found"}, status=404) + condition = Q(feature=feature, **filters) conditions = condition if conditions is None else (conditions | condition) diff --git a/tests/sentry/api/endpoints/test_prompts_activity.py b/tests/sentry/api/endpoints/test_prompts_activity.py index 21c0fe0b49a67c..c35a8b85d052de 100644 --- a/tests/sentry/api/endpoints/test_prompts_activity.py +++ b/tests/sentry/api/endpoints/test_prompts_activity.py @@ -290,3 +290,21 @@ def test_project_from_different_organization(self) -> None: assert resp.status_code == 400 assert resp.data["detail"] == "Project does not belong to this organization" + + def test_idor_get_project_from_different_org(self) -> None: + """Regression test: GET cannot access projects from other organizations (IDOR).""" + other_org = self.create_organization() + other_project = self.create_project(organization=other_org) + + resp = self.client.get( + self.path, + { + "organization_id": self.org.id, + "project_id": other_project.id, + "feature": "releases", + }, + ) + + # Should return 404 to prevent ID enumeration + assert resp.status_code == 404 + assert resp.data["detail"] == "Project not found" From fb795aa6c999589f0fce57839e7b8f6500210763 Mon Sep 17 00:00:00 2001 From: "getsantry[bot]" <66042841+getsantry[bot]@users.noreply.github.com> Date: Wed, 14 Jan 2026 23:49:29 +0000 Subject: [PATCH 2/4] :hammer_and_wrench: apply pre-commit fixes --- src/sentry/api/endpoints/prompts_activity.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/sentry/api/endpoints/prompts_activity.py b/src/sentry/api/endpoints/prompts_activity.py index defab49fd14446..31f6a2be7ddf33 100644 --- a/src/sentry/api/endpoints/prompts_activity.py +++ b/src/sentry/api/endpoints/prompts_activity.py @@ -67,9 +67,12 @@ def get(self, request: Request, **kwargs) -> Response: # Validate project_id belongs to the organization (IDOR protection) if "project_id" in required_fields and organization: project_id = filters.get("project_id") - if project_id and not Project.objects.filter( - id=project_id, organization_id=organization.id - ).exists(): + if ( + project_id + and not Project.objects.filter( + id=project_id, organization_id=organization.id + ).exists() + ): return Response({"detail": "Project not found"}, status=404) condition = Q(feature=feature, **filters) From 2b88bec9e6cc8a8ed1c851650684357eab41586d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 15 Jan 2026 11:07:44 -0800 Subject: [PATCH 3/4] fix(security): address PR feedback for IDOR fix - Add organization parameter to method signatures for consistency - Remove redundant organization_id query param from GET (use URL org) - Fix fail-open pattern by removing conditional organization check - Add empty string validation for project_id - Update tests to reflect new behavior --- src/sentry/api/endpoints/prompts_activity.py | 61 +++++++++---------- .../api/endpoints/test_prompts_activity.py | 36 ++++++++--- 2 files changed, 58 insertions(+), 39 deletions(-) diff --git a/src/sentry/api/endpoints/prompts_activity.py b/src/sentry/api/endpoints/prompts_activity.py index 31f6a2be7ddf33..bc4949082b0f42 100644 --- a/src/sentry/api/endpoints/prompts_activity.py +++ b/src/sentry/api/endpoints/prompts_activity.py @@ -42,7 +42,7 @@ class PromptsActivityEndpoint(OrganizationEndpoint): "PUT": ApiPublishStatus.UNKNOWN, } - def get(self, request: Request, **kwargs) -> Response: + def get(self, request: Request, organization: Organization, **kwargs) -> Response: """Return feature prompt status if dismissed or in snoozed period""" if not request.user.is_authenticated: @@ -52,33 +52,32 @@ def get(self, request: Request, **kwargs) -> Response: if len(features) == 0: return Response({"details": "No feature specified"}, status=400) - organization = kwargs.get("organization") conditions: Q | None = None for feature in features: if not prompt_config.has(feature): return Response({"detail": "Invalid feature name " + feature}, status=400) required_fields = prompt_config.required_fields(feature) - for field in required_fields: - if field not in request.GET: - return Response({"detail": 'Missing required field "%s"' % field}, status=400) - filters = {k: request.GET.get(k) for k in required_fields} - - # Validate project_id belongs to the organization (IDOR protection) - if "project_id" in required_fields and organization: - project_id = filters.get("project_id") - if ( - project_id - and not Project.objects.filter( - id=project_id, organization_id=organization.id - ).exists() - ): + filters: dict[str, Any] = {} + + # project_id must be provided and belong to the organization + if "project_id" in required_fields: + project_id = request.GET.get("project_id") + if not project_id: + return Response({"detail": 'Missing required field "project_id"'}, status=400) + if not Project.objects.filter( + id=project_id, organization_id=organization.id + ).exists(): return Response({"detail": "Project not found"}, status=404) + filters["project_id"] = project_id condition = Q(feature=feature, **filters) conditions = condition if conditions is None else (conditions | condition) - result_qs = PromptsActivity.objects.filter(conditions, user_id=request.user.id) + # Always scope by organization from URL - passed directly to filter() to prevent override + result_qs = PromptsActivity.objects.filter( + conditions, user_id=request.user.id, organization_id=organization.id + ) featuredata = {k.feature: k.data for k in result_qs} if len(features) == 1: result = result_qs.first() @@ -87,7 +86,7 @@ def get(self, request: Request, **kwargs) -> Response: else: return Response({"features": featuredata}) - def put(self, request: Request, **kwargs): + def put(self, request: Request, organization: Organization, **kwargs) -> Response: serializer = PromptsActivitySerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=400) @@ -102,26 +101,26 @@ def put(self, request: Request, **kwargs): if any(elem is None for elem in fields.values()): return Response({"detail": "Missing required field"}, status=400) - # if project_id or organization_id in required fields make sure they exist - # if NOT in required fields, insert dummy value so dups aren't recorded + # Validate organization_id is present and matches URL organization + if "organization_id" not in required_fields: + return Response({"detail": "Organization missing or mismatched"}, status=400) + if str(fields["organization_id"]) != str(organization.id): + return Response({"detail": "Organization missing or mismatched"}, status=400) + # Override with URL organization to prevent IDOR + fields["organization_id"] = organization.id + + # Validate project_id if required, otherwise use dummy value to prevent duplicates if "project_id" in required_fields: - if not Project.objects.filter( - id=fields["project_id"], organization_id=request.organization.id - ).exists(): + project_id = fields["project_id"] + if not project_id: + return Response({"detail": "Invalid project_id"}, status=400) + if not Project.objects.filter(id=project_id, organization_id=organization.id).exists(): return Response( {"detail": "Project does not belong to this organization"}, status=400 ) else: fields["project_id"] = 0 - if "organization_id" in required_fields and str(fields["organization_id"]) == str( - request.organization.id - ): - if not Organization.objects.filter(id=fields["organization_id"]).exists(): - return Response({"detail": "Organization no longer exists"}, status=400) - else: - return Response({"detail": "Organization missing or mismatched"}, status=400) - data: dict[str, Any] = {} now = calendar.timegm(timezone.now().utctimetuple()) if status == "snoozed": diff --git a/tests/sentry/api/endpoints/test_prompts_activity.py b/tests/sentry/api/endpoints/test_prompts_activity.py index c35a8b85d052de..87ab93bb3c5ada 100644 --- a/tests/sentry/api/endpoints/test_prompts_activity.py +++ b/tests/sentry/api/endpoints/test_prompts_activity.py @@ -75,7 +75,6 @@ def test_batched_invalid_feature(self) -> None: def test_invalid_project(self) -> None: # Invalid project id data = { - "organization_id": self.org.id, "project_id": self.project.id, "feature": "releases", } @@ -98,7 +97,6 @@ def test_invalid_project(self) -> None: def test_dismiss(self) -> None: data = { - "organization_id": self.org.id, "project_id": self.project.id, "feature": "releases", } @@ -135,7 +133,6 @@ def test_dismiss_str_id(self) -> None: assert resp.status_code == 201, resp.content data = { - "organization_id": self.org.id, "project_id": self.project.id, "feature": "releases", } @@ -147,7 +144,6 @@ def test_dismiss_str_id(self) -> None: def test_snooze(self) -> None: data = { - "organization_id": self.org.id, "project_id": self.project.id, "feature": "releases", } @@ -173,7 +169,6 @@ def test_snooze(self) -> None: def test_visible(self) -> None: data = { - "organization_id": self.org.id, "project_id": self.project.id, "feature": "releases", } @@ -199,7 +194,6 @@ def test_visible(self) -> None: def test_visible_after_dismiss(self) -> None: data = { - "organization_id": self.org.id, "project_id": self.project.id, "feature": "releases", } @@ -235,7 +229,6 @@ def test_visible_after_dismiss(self) -> None: def test_batched(self) -> None: data = { - "organization_id": self.org.id, "project_id": self.project.id, "feature": ["releases", "alert_stream"], } @@ -299,7 +292,6 @@ def test_idor_get_project_from_different_org(self) -> None: resp = self.client.get( self.path, { - "organization_id": self.org.id, "project_id": other_project.id, "feature": "releases", }, @@ -308,3 +300,31 @@ def test_idor_get_project_from_different_org(self) -> None: # Should return 404 to prevent ID enumeration assert resp.status_code == 404 assert resp.data["detail"] == "Project not found" + + def test_get_empty_project_id(self) -> None: + """Test that empty string project_id returns 400 instead of 500.""" + resp = self.client.get( + self.path, + { + "project_id": "", + "feature": "releases", + }, + ) + + assert resp.status_code == 400 + assert resp.data["detail"] == 'Missing required field "project_id"' + + def test_put_empty_project_id(self) -> None: + """Test that empty string project_id in PUT returns 400 instead of 500.""" + resp = self.client.put( + self.path, + { + "organization_id": self.org.id, + "project_id": "", + "feature": "releases", + "status": "dismissed", + }, + ) + + assert resp.status_code == 400 + assert resp.data["detail"] == "Invalid project_id" From 6cb1f03b943c8b80fdb72192e143a966f8ec7ef0 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 15 Jan 2026 12:22:06 -0800 Subject: [PATCH 4/4] fix: address mypy type errors --- src/sentry/api/endpoints/prompts_activity.py | 9 ++++----- tests/sentry/api/endpoints/test_prompts_activity.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/sentry/api/endpoints/prompts_activity.py b/src/sentry/api/endpoints/prompts_activity.py index bc4949082b0f42..793fe71ccd9220 100644 --- a/src/sentry/api/endpoints/prompts_activity.py +++ b/src/sentry/api/endpoints/prompts_activity.py @@ -3,7 +3,6 @@ from django.db import IntegrityError, router, transaction from django.db.models import Q -from django.http import HttpResponse from django.utils import timezone from rest_framework import serializers from rest_framework.request import Request @@ -102,9 +101,9 @@ def put(self, request: Request, organization: Organization, **kwargs) -> Respons return Response({"detail": "Missing required field"}, status=400) # Validate organization_id is present and matches URL organization - if "organization_id" not in required_fields: - return Response({"detail": "Organization missing or mismatched"}, status=400) - if str(fields["organization_id"]) != str(organization.id): + if "organization_id" not in required_fields or str(fields["organization_id"]) != str( + organization.id + ): return Response({"detail": "Organization missing or mismatched"}, status=400) # Override with URL organization to prevent IDOR fields["organization_id"] = organization.id @@ -138,4 +137,4 @@ def put(self, request: Request, organization: Organization, **kwargs) -> Respons ) except IntegrityError: pass - return HttpResponse(status=201) + return Response(status=201) diff --git a/tests/sentry/api/endpoints/test_prompts_activity.py b/tests/sentry/api/endpoints/test_prompts_activity.py index 87ab93bb3c5ada..bf788ef9c26380 100644 --- a/tests/sentry/api/endpoints/test_prompts_activity.py +++ b/tests/sentry/api/endpoints/test_prompts_activity.py @@ -292,7 +292,7 @@ def test_idor_get_project_from_different_org(self) -> None: resp = self.client.get( self.path, { - "project_id": other_project.id, + "project_id": str(other_project.id), "feature": "releases", }, )