Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
124 changes: 124 additions & 0 deletions src/sentry/api/endpoints/organization_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
from __future__ import annotations

import logging

from rest_framework.request import Request
from rest_framework.response import Response

from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import control_silo_endpoint
from sentry.api.bases.organization import (
ControlSiloOrganizationEndpoint,
OrganizationPermission,
)
from sentry.exceptions import NotRegistered
from sentry.identity.pipeline import IdentityPipeline
from sentry.integrations.pipeline import (
IntegrationPipeline,
IntegrationPipelineError,
initialize_integration_pipeline,
)
from sentry.organizations.services.organization.model import RpcOrganization
from sentry.pipeline.base import Pipeline
from sentry.pipeline.types import PipelineStepAction

logger = logging.getLogger(__name__)

# All pipeline classes that can be driven via the API. The endpoint tries each
# in order and uses whichever one has a valid session for the request.
PIPELINE_CLASSES = (IntegrationPipeline, IdentityPipeline)


class PipelinePermission(OrganizationPermission):
scope_map = {
"GET": ["org:read", "org:write", "org:admin", "org:integrations"],
"POST": ["org:write", "org:admin", "org:integrations"],
}


def _get_api_pipeline(
request: Request, organization: RpcOrganization, pipeline_name: str
) -> Response | Pipeline:
"""Look up an active API-ready pipeline from the session, or return an error Response."""
pipelines = {cls.pipeline_name: cls for cls in PIPELINE_CLASSES}
if pipeline_name not in pipelines:
return Response({"detail": "Invalid pipeline type"}, status=404)

pipeline = pipelines[pipeline_name].get_for_request(request._request)
if not pipeline or not pipeline.organization:
return Response({"detail": "No active pipeline session."}, status=404)

if not pipeline.is_valid() or pipeline.organization.id != organization.id:
return Response({"detail": "Invalid pipeline state."}, status=404)

if not pipeline.is_api_ready():
return Response({"detail": "Pipeline does not support API mode."}, status=400)

return pipeline


@control_silo_endpoint
class OrganizationPipelineEndpoint(ControlSiloOrganizationEndpoint):
owner = ApiOwner.ENTERPRISE
publish_status = {
"GET": ApiPublishStatus.EXPERIMENTAL,
"POST": ApiPublishStatus.EXPERIMENTAL,
}
permission_classes = (PipelinePermission,)

def get(
self, request: Request, organization: RpcOrganization, pipeline_name: str, **kwargs: object
) -> Response:
result = _get_api_pipeline(request, organization, pipeline_name)
if isinstance(result, Response):
return result
return Response(result.get_current_step_info())

def post(
self, request: Request, organization: RpcOrganization, pipeline_name: str, **kwargs: object
) -> Response:
if request.data.get("action") == "initialize":
return self._initialize_pipeline(request, organization, pipeline_name)

result = _get_api_pipeline(request, organization, pipeline_name)
if isinstance(result, Response):
return result
pipeline = result

step_result = pipeline.api_advance(request._request, request.data)

response_data = step_result.serialize()
if step_result.action == PipelineStepAction.ADVANCE:
response_data.update(pipeline.get_current_step_info())

if step_result.action == PipelineStepAction.ERROR:
return Response(response_data, status=400)

return Response(response_data)

def _initialize_pipeline(
self, request: Request, organization: RpcOrganization, pipeline_name: str
) -> Response:
if pipeline_name != IntegrationPipeline.pipeline_name:
return Response(
{"detail": "Initialization not supported for this pipeline."}, status=400
)

provider_id = request.data.get("provider")
if not provider_id:
return Response({"detail": "provider is required."}, status=400)

try:
pipeline = initialize_integration_pipeline(request._request, organization, provider_id)
except NotRegistered:
return Response({"detail": f"Unknown provider: {provider_id}"}, status=404)
except IntegrationPipelineError as e:
return Response({"detail": str(e)}, status=404 if e.not_found else 400)

Check warning

Code scanning / CodeQL

Information exposure through an exception Medium

Stack trace information
flows to this location and may be exposed to an external user.

Copilot Autofix

AI 20 days ago

In general, the fix is to avoid sending the raw exception message back to the client. Instead, log the exception (including stack trace / message) on the server, and return a generic, non‑sensitive error message to the user. This preserves debuggability while preventing information exposure.

For this specific code:

  • In _initialize_pipeline, the except IntegrationPipelineError as e: block currently returns {"detail": str(e)} with different status codes based on e.not_found.
  • We should:
    • Log the exception using the existing logger defined at the top of the file, ideally with logger.exception so the stack trace is captured.
    • Return a generic error message string that does not include e’s content.
    • Preserve the existing behavior of using 404 vs 400 depending on e.not_found, since that is part of the API contract and does not itself leak sensitive information.

Concretely, in src/sentry/api/endpoints/organization_pipeline.py, around lines 112–118:

  • Replace return Response({"detail": str(e)}, status=404 if e.not_found else 400) with:
    • A logger.exception call that logs that initialization failed and includes provider_id for context.
    • A Response that has either:
      • a 404 status with a generic “Resource not found.”‑style message, or
      • a 400 status with a generic “Failed to initialize integration pipeline.”‑style message.
  • No new imports are needed; logger is already defined and uses logging.getLogger(__name__).

This preserves functionality (status codes and control flow) while ensuring the client never sees the raw exception message.


Suggested changeset 1
src/sentry/api/endpoints/organization_pipeline.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/sentry/api/endpoints/organization_pipeline.py b/src/sentry/api/endpoints/organization_pipeline.py
--- a/src/sentry/api/endpoints/organization_pipeline.py
+++ b/src/sentry/api/endpoints/organization_pipeline.py
@@ -114,7 +114,16 @@
         except NotRegistered:
             return Response({"detail": f"Unknown provider: {provider_id}"}, status=404)
         except IntegrationPipelineError as e:
-            return Response({"detail": str(e)}, status=404 if e.not_found else 400)
+            logger.exception(
+                "Failed to initialize integration pipeline for provider %s",
+                provider_id,
+            )
+            if getattr(e, "not_found", False):
+                return Response({"detail": "Requested resource was not found."}, status=404)
+            return Response(
+                {"detail": "Failed to initialize integration pipeline."},
+                status=400,
+            )
 
         if not pipeline.is_api_ready():
             return Response({"detail": "Pipeline does not support API mode."}, status=400)
EOF
@@ -114,7 +114,16 @@
except NotRegistered:
return Response({"detail": f"Unknown provider: {provider_id}"}, status=404)
except IntegrationPipelineError as e:
return Response({"detail": str(e)}, status=404 if e.not_found else 400)
logger.exception(
"Failed to initialize integration pipeline for provider %s",
provider_id,
)
if getattr(e, "not_found", False):
return Response({"detail": "Requested resource was not found."}, status=404)
return Response(
{"detail": "Failed to initialize integration pipeline."},
status=400,
)

if not pipeline.is_api_ready():
return Response({"detail": "Pipeline does not support API mode."}, status=400)
Copilot is powered by AI and may make mistakes. Always verify output.

if not pipeline.is_api_ready():
return Response({"detail": "Pipeline does not support API mode."}, status=400)

pipeline.set_api_mode()

return Response(pipeline.get_current_step_info())
6 changes: 6 additions & 0 deletions src/sentry/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from sentry.api.endpoints.organization_insights_tree import OrganizationInsightsTreeEndpoint
from sentry.api.endpoints.organization_intercom_jwt import OrganizationIntercomJwtEndpoint
from sentry.api.endpoints.organization_missing_org_members import OrganizationMissingMembersEndpoint
from sentry.api.endpoints.organization_pipeline import OrganizationPipelineEndpoint
from sentry.api.endpoints.organization_plugin_deprecation_info import (
OrganizationPluginDeprecationInfoEndpoint,
)
Expand Down Expand Up @@ -2038,6 +2039,11 @@ def create_group_urls(name_prefix: str) -> list[URLPattern | URLResolver]:
ExternalUserDetailsEndpoint.as_view(),
name="sentry-api-0-organization-external-user-details",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/pipeline/(?P<pipeline_name>[^/]+)/$",
OrganizationPipelineEndpoint.as_view(),
name="sentry-api-0-organization-pipeline",
),
re_path(
r"^(?P<organization_id_or_slug>[^/]+)/integration-requests/$",
OrganizationIntegrationRequestEndpoint.as_view(),
Expand Down
150 changes: 147 additions & 3 deletions src/sentry/identity/oauth2.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from requests import Response
from requests.exceptions import HTTPError, SSLError
from requests.exceptions import ConnectionError, HTTPError, SSLError
from rest_framework.fields import CharField
from rest_framework.serializers import Serializer

from sentry.auth.exceptions import IdentityNotValid
from sentry.exceptions import NotRegistered
Expand All @@ -30,20 +32,27 @@
IntegrationPipelineViewEvent,
IntegrationPipelineViewType,
)
from sentry.pipeline.types import PipelineStepResult
from sentry.pipeline.views.base import PipelineView
from sentry.shared_integrations.exceptions import ApiError, ApiInvalidRequestError, ApiUnauthorized
from sentry.users.models.identity import Identity
from sentry.utils.http import absolute_uri

from .base import Provider

__all__ = ["OAuth2Provider", "OAuth2CallbackView", "OAuth2LoginView"]
__all__ = ["OAuth2Provider", "OAuth2CallbackView", "OAuth2LoginView", "OAuth2ApiStep"]

logger = logging.getLogger(__name__)
ERR_INVALID_STATE = "An error occurred while validating your request."
ERR_TOKEN_RETRIEVAL = "Failed to retrieve token from the upstream service."


class OAuth2ApiStepError(Exception):
"""Raised when the OAuth2 API step encounters an error during token exchange."""

pass


def _redirect_url(pipeline: IdentityPipeline) -> str:
associate_url = reverse(
"sentry-extension-setup",
Expand Down Expand Up @@ -137,6 +146,23 @@ def get_pipeline_views(self) -> list[PipelineView[IdentityPipeline]]:
),
]

def get_pipeline_api_steps(self) -> list[OAuth2ApiStep]:
redirect_url = self.config.get(
"redirect_url",
reverse("sentry-extension-setup", kwargs={"provider_id": "default"}),
)
return [
OAuth2ApiStep(
authorize_url=self.get_oauth_authorize_url(),
client_id=self.get_oauth_client_id(),
client_secret=self.get_oauth_client_secret(),
access_token_url=self.get_oauth_access_token_url(),
scope=" ".join(self.get_oauth_scopes()),
redirect_url=redirect_url,
verify_ssl=self.config.get("verify_ssl", True),
),
]

def get_refresh_token_params(
self, refresh_token: str, identity: Identity | RpcIdentity, **kwargs: Any
) -> dict[str, str | None]:
Expand Down Expand Up @@ -214,6 +240,124 @@ def record_event(event: IntegrationPipelineViewType, provider: str):
)


class OAuth2ApiSerializer(Serializer):
code = CharField(required=True)
state = CharField(required=True)


class OAuth2ApiStep:
"""
Generic API-mode step for OAuth2 identity authentication.

Handles the full OAuth2 authorization code flow in a single API step:

- GET (get_step_data): returns the OAuth authorize URL for the frontend to
open in a popup.
- POST (handle_post): receives the callback params (code, state) relayed by
the trampoline via postMessage, validates state, exchanges the code for an
access token, and binds the token data to pipeline state.
"""

step_name = "oauth_login"

def __init__(
self,
authorize_url: str,
client_id: str,
client_secret: str,
access_token_url: str,
scope: str,
redirect_url: str,
verify_ssl: bool = True,
bind_key: str = "data",
extra_authorize_params: dict[str, str] | None = None,
) -> None:
self.authorize_url = authorize_url
self.client_id = client_id
self.client_secret = client_secret
self.access_token_url = access_token_url
self.scope = scope
self.redirect_url = redirect_url
self.verify_ssl = verify_ssl
self.bind_key = bind_key
self.extra_authorize_params = extra_authorize_params or {}

def get_step_data(self, pipeline: Any, request: HttpRequest) -> dict[str, str]:
params = urlencode(
{
"client_id": self.client_id,
"response_type": "code",
"scope": self.scope,
"state": pipeline.signature,
"redirect_uri": absolute_uri(self.redirect_url),
**self.extra_authorize_params,
}
)
return {"oauthUrl": f"{self.authorize_url}?{params}"}

def get_serializer_cls(self) -> type:
return OAuth2ApiSerializer

def handle_post(
self,
validated_data: dict[str, str],
pipeline: Any,
request: HttpRequest,
) -> PipelineStepResult:
code = validated_data["code"]
state = validated_data["state"]

if state != pipeline.signature:
return PipelineStepResult.error(ERR_INVALID_STATE)

try:
data = self._exchange_token(code)
except OAuth2ApiStepError as e:
logger.info("identity.token-exchange-error", extra={"error": str(e)})
return PipelineStepResult.error(str(e))

pipeline.bind_state(self.bind_key, data)
return PipelineStepResult.advance()

def _exchange_token(self, code: str) -> dict[str, Any]:
"""Exchange an authorization code for an access token.

Raises OAuth2ApiStepError on failure.
"""
token_params = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": absolute_uri(self.redirect_url),
"client_id": self.client_id,
"client_secret": self.client_secret,
}
try:
req = safe_urlopen(self.access_token_url, data=token_params, verify_ssl=self.verify_ssl)
req.raise_for_status()
except HTTPError as e:
error_resp = e.response
exc = ApiError.from_response(error_resp, url=self.access_token_url)
sentry_sdk.capture_exception(exc)
raise OAuth2ApiStepError(
f"Could not retrieve access token. Received {exc.code}: {exc.text}"
) from e
except SSLError as e:
raise OAuth2ApiStepError(
f"Could not verify SSL certificate for {self.access_token_url}"
) from e
except ConnectionError as e:
raise OAuth2ApiStepError(f"Could not connect to {self.access_token_url}") from e

try:
body = safe_urlread(req)
content_type = req.headers.get("Content-Type", "").lower()
if content_type.startswith("application/x-www-form-urlencoded"):
return dict(parse_qsl(body.decode("utf-8")))
return orjson.loads(body)
except orjson.JSONDecodeError as e:
raise OAuth2ApiStepError("Could not decode a JSON response, please try again.") from e


class OAuth2LoginView:
authorize_url: str | None = None
client_id: str | None = None
Expand Down Expand Up @@ -334,7 +478,7 @@ def exchange_token(
body = safe_urlread(req)
content_type = req.headers.get("Content-Type", "").lower()
if content_type.startswith("application/x-www-form-urlencoded"):
return dict(parse_qsl(body))
return dict(parse_qsl(body.decode("utf-8")))
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.

UnicodeDecodeError unhandled in OAuth token exchange

The body.decode("utf-8") call can raise UnicodeDecodeError if an external OAuth provider returns form-urlencoded data with invalid UTF-8 bytes. The current exception handler only catches orjson.JSONDecodeError, allowing UnicodeDecodeError to propagate as an unhandled 500 error. This matches the Data Parsing pattern (Check 8) where external data parsing lacks complete error handling.

Verification

Read src/sentry/identity/oauth2.py lines 477-495 to confirm the try/except only catches orjson.JSONDecodeError. Verified safe_urlread returns bytes (src/sentry/http.py line 120). Confirmed UnicodeDecodeError is handled elsewhere in the codebase for similar external data parsing (e.g., src/sentry/integrations/github/platform_detection.py).

Also found at 1 additional location
  • src/sentry/integrations/github_enterprise/integration.py:571-572

Identified by Warden sentry-backend-bugs · 7ZK-SG2

return orjson.loads(body)
except orjson.JSONDecodeError:
lifecycle.record_failure(
Expand Down
2 changes: 1 addition & 1 deletion src/sentry/integrations/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,7 +312,7 @@ def get_pipeline_views(
"""
raise NotImplementedError

def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline]:
def get_pipeline_api_steps(self) -> ApiPipelineSteps[IntegrationPipeline] | None:
"""
Return API step objects for this provider's pipeline, or None if API
mode is not supported. Override to enable the pipeline API for this
Expand Down
Loading
Loading