Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
OrganizationIntegrationsLoosePermission,
)
from sentry.api.serializers import serialize
from sentry.api.serializers.rest_framework.base import camel_to_snake_case, convert_dict_key_case
from sentry.integrations.models.repository_project_path_config import RepositoryProjectPathConfig
from sentry.integrations.services.integration import integration_service

Expand Down Expand Up @@ -44,17 +45,18 @@ def convert_args(self, request: Request, organization_id_or_slug, config_id, *ar
id=config_id,
organization_integration_id__in=[oi.id for oi in ois],
)
except RepositoryProjectPathConfig.DoesNotExist:
# Only set when the request wants to move the mapping to another project.
# Normalize keys the way the serializer does first, otherwise a snake_case
# `project_id` skips the access check below but still moves the mapping.
data = convert_dict_key_case(request.data, camel_to_snake_case)
if data.get("project_id"):
kwargs["new_project"] = self.get_project(kwargs["organization"], data["project_id"])
except (RepositoryProjectPathConfig.DoesNotExist, ValueError):
raise Http404

if request.data.get("projectId"):
kwargs["new_project"] = super().get_project(
kwargs["organization"], request.data.get("projectId")
)

return (args, kwargs)

def put(self, request: Request, config_id, organization, config, new_project) -> Response:
def put(self, request: Request, config_id, organization, config, new_project=None) -> Response:
"""
Update a repository project path config
``````````````````
Expand All @@ -68,8 +70,10 @@ def put(self, request: Request, config_id, organization, config, new_project) ->
:param string default_branch:
:auth: required
"""
project = config.project_repository.project
if not request.access.has_projects_access([project, new_project]):
projects = [config.project_repository.project]
if new_project is not None:
projects.append(new_project)
if not request.access.has_projects_access(projects):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Snake_case bypasses project access check

High Severity

Making new_project optional means a PUT can skip the destination access check when the body uses snake_case project_id instead of projectId. convert_args only reads projectId, so new_project stays unset and has_projects_access only covers the current project, while RepositoryProjectPathConfigSerializer still accepts project_id and can move the mapping. Previously this path 500'd on the required new_project argument, so the bypass was unreachable.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 28bcc5b. Configure here.

return self.respond(status=status.HTTP_403_FORBIDDEN)

try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def test_non_project_member_permissions(self) -> None:
non_member_om = self.create_member(organization=self.org, user=non_member)
self.login_as(user=non_member)

response = self.client.put(self.url, {"sourceRoot": ""})
assert response.status_code == status.HTTP_403_FORBIDDEN

response = self.make_put({"sourceRoot": "newRoot"})
assert response.status_code == status.HTTP_403_FORBIDDEN

Expand Down Expand Up @@ -102,6 +105,40 @@ def test_basic_edit(self) -> None:
assert resp.data["id"] == str(self.config.id)
assert resp.data["sourceRoot"] == "newRoot"

def test_edit_rejects_missing_required_fields(self) -> None:
resp = self.client.put(self.url, {"sourceRoot": ""})

assert resp.status_code == status.HTTP_400_BAD_REQUEST
assert resp.data == {
"projectId": ["This field is required."],
"repositoryId": ["This field is required."],
"stackRoot": ["This field is required."],
}

def test_edit_snake_case_project_id_is_access_checked(self) -> None:
member = self.create_user()
self.create_member(
organization=self.org, user=member, has_global_access=False, teams=[self.team]
)
self.login_as(user=member)

# every key is snake_case so `projectId` never appears in the body, but the
# serializer still reads it as project_id and would move the mapping
resp = self.client.put(
self.url,
{
"repository_id": self.repo.id,
"project_id": self.project2.id,
"stack_root": "/stack/root",
"source_root": "/source/root",
"default_branch": "master",
},
)

assert resp.status_code == status.HTTP_403_FORBIDDEN
self.config.refresh_from_db()
assert self.config.project_repository.project_id == self.project.id

def test_basic_edit_from_member_permissions(self) -> None:
self.login_as(user=self.user2)
resp = self.make_put({"sourceRoot": "newRoot"})
Expand Down
Loading