Skip to content

Commit d29b554

Browse files
committed
fix: prevent IDOR vulnerability in environment update endpoint
Make the `project` field read-only during environment updates to prevent attackers from moving an environment to a different project they don't own. The vulnerability allowed an attacker with access to their own environment to modify the `project` field in the PUT request body, effectively moving their environment into a victim's project. Fix: Override __init__ in CreateUpdateEnvironmentSerializer to set project field as read-only when instance exists (update operation).
1 parent 5a26f45 commit d29b554

File tree

3 files changed

+60
-17
lines changed

3 files changed

+60
-17
lines changed

api/environments/serializers.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ class Meta(EnvironmentSerializerWithMetadata.Meta):
115115
)
116116

117117

118-
class CreateUpdateEnvironmentSerializer(
118+
class _BaseCreateUpdateEnvironmentSerializer(
119119
ReadOnlyIfNotValidPlanMixin, EnvironmentSerializerWithMetadata
120120
):
121121
invalid_plans = ("free",)
@@ -130,25 +130,33 @@ class Meta(EnvironmentSerializerWithMetadata.Meta):
130130
)
131131
]
132132

133+
134+
class CreateEnvironmentSerializer(_BaseCreateUpdateEnvironmentSerializer):
133135
def get_subscription(self) -> Subscription | None:
134136
view = self.context["view"]
137+
# handle `project` not being part of the data
138+
# When request comes from yasg2(as part of schema generation)
139+
project_id = view.request.data.get("project")
140+
if not project_id:
141+
return None
135142

136-
if view.action == "create":
137-
# handle `project` not being part of the data
138-
# When request comes from yasg2(as part of schema generation)
139-
project_id = view.request.data.get("project")
140-
if not project_id:
141-
return None
143+
project = Project.objects.select_related(
144+
"organisation", "organisation__subscription"
145+
).get(id=project_id)
142146

143-
project = Project.objects.select_related(
144-
"organisation", "organisation__subscription"
145-
).get(id=project_id)
147+
return getattr(project.organisation, "subscription", None)
146148

147-
return getattr(project.organisation, "subscription", None)
148-
elif view.action in ("update", "partial_update"):
149-
return getattr(self.instance.project.organisation, "subscription", None) # type: ignore[union-attr]
150149

151-
return None
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+
if self.instance is None:
158+
return None
159+
return getattr(self.instance.project.organisation, "subscription", None) # type: ignore[union-attr]
152160

153161

154162
class CloneEnvironmentSerializer(EnvironmentSerializerLight):

api/environments/views.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,11 @@
5555
)
5656
from .serializers import (
5757
CloneEnvironmentSerializer,
58-
CreateUpdateEnvironmentSerializer,
58+
CreateEnvironmentSerializer,
5959
EnvironmentAPIKeySerializer,
6060
EnvironmentRetrieveSerializerWithMetadata,
6161
EnvironmentSerializerWithMetadata,
62+
UpdateEnvironmentSerializer,
6263
WebhookSerializer,
6364
)
6465

@@ -94,8 +95,10 @@ def get_serializer_class(self): # type: ignore[no-untyped-def]
9495
return CloneEnvironmentSerializer
9596
if self.action == "retrieve":
9697
return EnvironmentRetrieveSerializerWithMetadata
97-
elif self.action in ("create", "update", "partial_update"):
98-
return CreateUpdateEnvironmentSerializer
98+
elif self.action == "create":
99+
return CreateEnvironmentSerializer
100+
elif self.action in ("update", "partial_update"):
101+
return UpdateEnvironmentSerializer
99102
return EnvironmentSerializerWithMetadata
100103

101104
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
@@ -1083,6 +1083,38 @@ def test_environment_update_cannot_change_is_creating(
10831083
assert response.json()["is_creating"] is False
10841084

10851085

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

0 commit comments

Comments
 (0)