-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
ref(insights): replace useRouter with specific hooks in spanSummary sampleList #110119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
95f784e
1e503d8
e48f592
1cc63d4
04d8776
2041623
aff7bd4
d2fbf90
fd47017
ee9941b
88d7648
5957262
a1998b6
59c987e
5377282
8a83071
d7941c2
74615eb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
|
|
||
| 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()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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", | ||
|
|
@@ -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]: | ||
|
|
@@ -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 | ||
|
|
@@ -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"))) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. UnicodeDecodeError unhandled in OAuth token exchange The VerificationRead 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
Identified by Warden |
||
| return orjson.loads(body) | ||
| except orjson.JSONDecodeError: | ||
| lifecycle.record_failure( | ||
|
|
||
Check warning
Code scanning / CodeQL
Information exposure through an exception Medium
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:
_initialize_pipeline, theexcept IntegrationPipelineError as e:block currently returns{"detail": str(e)}with different status codes based one.not_found.loggerdefined at the top of the file, ideally withlogger.exceptionso the stack trace is captured.e’s content.404vs400depending one.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:return Response({"detail": str(e)}, status=404 if e.not_found else 400)with:logger.exceptioncall that logs that initialization failed and includesprovider_idfor context.Responsethat has either:loggeris already defined and useslogging.getLogger(__name__).This preserves functionality (status codes and control flow) while ensuring the client never sees the raw exception message.