Skip to content

Commit 89f2477

Browse files
fix: prevent IDOR vulnerability in environment update endpoint (#6384)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 696bf62 commit 89f2477

3 files changed

Lines changed: 63 additions & 26 deletions

File tree

api/environments/serializers.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import TYPE_CHECKING, Any
1+
from typing import Any
22

33
from rest_framework import serializers
44

@@ -117,7 +117,7 @@ class Meta(EnvironmentSerializerWithMetadata.Meta):
117117
)
118118

119119

120-
class CreateUpdateEnvironmentSerializer(
120+
class _BaseCreateUpdateEnvironmentSerializer(
121121
ReadOnlyIfNotValidPlanMixin, EnvironmentSerializerWithMetadata
122122
):
123123
invalid_plans = ("free",)
@@ -132,30 +132,32 @@ class Meta(EnvironmentSerializerWithMetadata.Meta):
132132
)
133133
]
134134

135+
136+
class CreateEnvironmentSerializer(_BaseCreateUpdateEnvironmentSerializer):
135137
def get_subscription(self) -> Subscription | None:
136138
view = self.context["view"]
139+
if getattr(view, "swagger_fake_view", False):
140+
return None
141+
142+
project_id = view.request.data["project"]
143+
project = Project.objects.select_related(
144+
"organisation", "organisation__subscription"
145+
).get(id=project_id)
146+
147+
return getattr(project.organisation, "subscription", None)
137148

138-
if view.action == "create":
139-
# handle `project` not being part of the data
140-
# When request comes from drf-spectacular (as part of schema generation)
141-
project_id = view.request.data.get("project")
142-
if not project_id:
143-
return None
144-
145-
project = Project.objects.select_related(
146-
"organisation", "organisation__subscription"
147-
).get(id=project_id)
148-
149-
return getattr(project.organisation, "subscription", None)
150-
elif view.action in ("update", "partial_update"):
151-
# Handle schema generation when instance is None.
152-
if self.instance is None:
153-
return None
154-
if TYPE_CHECKING:
155-
assert isinstance(self.instance, Environment)
156-
return getattr(self.instance.project.organisation, "subscription", None)
157-
158-
return None
149+
150+
class UpdateEnvironmentSerializer(_BaseCreateUpdateEnvironmentSerializer):
151+
class Meta(_BaseCreateUpdateEnvironmentSerializer.Meta):
152+
read_only_fields = EnvironmentSerializerLight.Meta.read_only_fields + ( # type: ignore[assignment]
153+
"project",
154+
)
155+
156+
def get_subscription(self) -> Subscription | None:
157+
view = self.context["view"]
158+
if getattr(view, "swagger_fake_view", False):
159+
return None
160+
return getattr(self.instance.project.organisation, "subscription", None) # type: ignore[union-attr]
159161

160162

161163
class CloneEnvironmentSerializer(EnvironmentSerializerLight):

api/environments/views.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,11 @@
5454
)
5555
from .serializers import (
5656
CloneEnvironmentSerializer,
57-
CreateUpdateEnvironmentSerializer,
57+
CreateEnvironmentSerializer,
5858
EnvironmentAPIKeySerializer,
5959
EnvironmentRetrieveSerializerWithMetadata,
6060
EnvironmentSerializerWithMetadata,
61+
UpdateEnvironmentSerializer,
6162
WebhookSerializer,
6263
)
6364

@@ -100,8 +101,10 @@ def get_serializer_class(self): # type: ignore[no-untyped-def]
100101
return CloneEnvironmentSerializer
101102
if self.action == "retrieve":
102103
return EnvironmentRetrieveSerializerWithMetadata
103-
elif self.action in ("create", "update", "partial_update"):
104-
return CreateUpdateEnvironmentSerializer
104+
elif self.action == "create":
105+
return CreateEnvironmentSerializer
106+
elif self.action in ("update", "partial_update"):
107+
return UpdateEnvironmentSerializer
105108
return EnvironmentSerializerWithMetadata
106109

107110
def get_serializer_context(self): # type: ignore[no-untyped-def]

api/tests/unit/environments/test_unit_environments_views.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1088,6 +1088,38 @@ def test_environment_update_cannot_change_is_creating(
10881088
assert response.json()["is_creating"] is False
10891089

10901090

1091+
def test_environment_update_cannot_change_project(
1092+
environment: Environment,
1093+
project: Project,
1094+
organisation: Organisation,
1095+
admin_client_new: APIClient,
1096+
) -> None:
1097+
# Given - an environment in the original project
1098+
original_project = project
1099+
url = reverse("api-v1:environments:environment-detail", args=[environment.api_key])
1100+
1101+
# and a different project
1102+
other_project = Project.objects.create(
1103+
name="Other Project", organisation=organisation
1104+
)
1105+
1106+
data = {
1107+
"project": other_project.id,
1108+
"name": environment.name,
1109+
}
1110+
1111+
# When
1112+
response = admin_client_new.put(
1113+
url, data=json.dumps(data), content_type="application/json"
1114+
)
1115+
1116+
# Then - the project should NOT change
1117+
assert response.status_code == status.HTTP_200_OK
1118+
environment.refresh_from_db()
1119+
assert environment.project_id == original_project.id
1120+
assert response.json()["project"] == original_project.id
1121+
1122+
10911123
def test_get_document(
10921124
environment: Environment,
10931125
project: Project,

0 commit comments

Comments
 (0)