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
33 changes: 29 additions & 4 deletions src/sentry/integrations/bitbucket/installed.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
from django.http.request import HttpRequest
from django.http.response import HttpResponseBase
from django.views.decorators.csrf import csrf_exempt
from rest_framework import status
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 Endpoint, control_silo_endpoint
from sentry.integrations.base import IntegrationDomain
from sentry.integrations.pipeline import ensure_integration
from sentry.integrations.types import IntegrationProviderSlug
from sentry.integrations.utils.atlassian_connect import (
AtlassianConnectTokenValidator,
AtlassianConnectValidationError,
)
from sentry.integrations.utils.metrics import (
IntegrationPipelineViewEvent,
IntegrationPipelineViewType,
)

from .integration import BitbucketIntegrationProvider

Expand All @@ -27,8 +37,23 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase:
return super().dispatch(request, *args, **kwargs)

def post(self, request: Request, *args, **kwargs) -> Response:
state = request.data
data = BitbucketIntegrationProvider().build_integration(state)
ensure_integration(IntegrationProviderSlug.BITBUCKET.value, data)
with IntegrationPipelineViewEvent(
interaction_type=IntegrationPipelineViewType.VERIFY_INSTALLATION,
domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT,
provider_key=IntegrationProviderSlug.BITBUCKET.value,
).capture() as lifecycle:
state = request.data

return self.respond()
try:
AtlassianConnectTokenValidator(request, method="POST").get_token()
except AtlassianConnectValidationError as e:
lifecycle.record_halt(halt_reason=str(e))
return self.respond(
{"detail": "Request Token Validation Failed"},
status=status.HTTP_400_BAD_REQUEST,
)

data = BitbucketIntegrationProvider().build_integration(state)
ensure_integration(IntegrationProviderSlug.BITBUCKET.value, data)

return self.respond()
47 changes: 9 additions & 38 deletions src/sentry/integrations/jira/webhooks/installed.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import sentry_sdk
from django.db import router, transaction
from jwt import DecodeError, ExpiredSignatureError, InvalidKeyError, InvalidSignatureError
from rest_framework import status
from rest_framework.request import Request
from rest_framework.response import Response
Expand All @@ -14,12 +13,14 @@
from sentry.integrations.jira.webhooks.base import JiraWebhookBase
from sentry.integrations.pipeline import ensure_integration
from sentry.integrations.project_management.metrics import ProjectManagementFailuresReason
from sentry.integrations.utils.atlassian_connect import authenticate_asymmetric_jwt, verify_claims
from sentry.integrations.utils.atlassian_connect import (
AtlassianConnectTokenValidator,
AtlassianConnectValidationError,
)
from sentry.integrations.utils.metrics import (
IntegrationPipelineViewEvent,
IntegrationPipelineViewType,
)
from sentry.utils import jwt


@control_silo_endpoint
Expand All @@ -38,56 +39,26 @@ def post(self, request: Request, *args, **kwargs) -> Response:
domain=IntegrationDomain.PROJECT_MANAGEMENT,
provider_key=self.provider,
).capture() as lifecycle:
token = self.get_token(request)
state = request.data
if not state:
lifecycle.record_failure(ProjectManagementFailuresReason.INSTALLATION_STATE_MISSING)
return self.respond(status=status.HTTP_400_BAD_REQUEST)

try:
key_id = jwt.peek_header(token).get("kid")
except DecodeError:
lifecycle.record_halt(halt_reason="Failed to fetch key id")
return self.respond(
{"detail": "Failed to fetch key id"}, status=status.HTTP_400_BAD_REQUEST
)

lifecycle.add_extras(
{
"key_id": key_id,
"base_url": state.get("baseUrl", ""),
"description": state.get("description", ""),
"clientKey": state.get("clientKey", ""),
}
)

if not key_id:
lifecycle.record_halt(halt_reason="Missing key_id (kid)")
return self.respond(
{"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST
Comment on lines -64 to -67
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Refactored this all into a common class. I simplified some of the response logic to be a bit more intentionally vague but also more consistent across failure modes.

)
try:
decoded_claims = authenticate_asymmetric_jwt(token, key_id)
verify_claims(decoded_claims, request.path, request.GET, method="POST")
except InvalidKeyError:
lifecycle.record_halt(halt_reason="JWT contained invalid key_id (kid)")
return self.respond(
{"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST
)
except ExpiredSignatureError as e:
lifecycle.record_failure(e)
return self.respond(
{"detail": "Expired signature"}, status=status.HTTP_400_BAD_REQUEST
)
except InvalidSignatureError:
lifecycle.record_halt(halt_reason="JWT contained invalid signature")
return self.respond(
{"detail": "Invalid signature"}, status=status.HTTP_400_BAD_REQUEST
)
except DecodeError:
lifecycle.record_halt(halt_reason="Could not decode JWT token")
AtlassianConnectTokenValidator(request, method="POST").get_token()
except AtlassianConnectValidationError as e:
lifecycle.record_halt(halt_reason=str(e))
return self.respond(
{"detail": "Could not decode JWT token"}, status=status.HTTP_400_BAD_REQUEST
{"detail": "Request Token Validation Failed"},
status=status.HTTP_400_BAD_REQUEST,
)

data = JiraIntegrationProvider().build_integration(state)
Expand Down
87 changes: 78 additions & 9 deletions src/sentry/integrations/utils/atlassian_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

import hashlib
from collections.abc import Mapping, Sequence
from enum import StrEnum

import requests
from django.http import HttpRequest
from jwt import ExpiredSignatureError, InvalidSignatureError
from jwt import DecodeError, ExpiredSignatureError, InvalidKeyError, InvalidSignatureError

from sentry.integrations.models.integration import Integration
from sentry.integrations.services.integration.model import RpcIntegration
Expand All @@ -15,6 +16,23 @@
from sentry.utils.http import absolute_uri, percent_encode


class AtlassianConnectFailureReason(StrEnum):
MISSING_AUTHORIZATION_HEADER = "Missing/Invalid authorization header"
NO_TOKEN_PARAMETER = "No token parameter"
NO_INTEGRATION_FOUND = "No integration found"
INVALID_SIGNATURE = "Signature is invalid"
EXPIRED_SIGNATURE = "Signature is expired"
QUERY_HASH_MISMATCH = "Query hash mismatch"
UNABLE_TO_VERIFY_ASYMMETRIC_JWT = "Unable to verify asymmetric installation JWT"
FAILED_TO_RETRIEVE_TOKEN = "Failed to retrieve token from request headers"
FAILED_TO_FETCH_KEY_ID = "Failed to fetch key_id (kid)"
MISSING_KEY_ID = "Missing key_id (kid)"
INVALID_KEY_ID = "JWT contained invalid key_id (kid)"
EXPIRED_SIGNATURE_TOKEN = "Expired signature"
INVALID_SIGNATURE_TOKEN = "JWT contained invalid signature"
COULD_NOT_DECODE_JWT = "Could not decode JWT token"


class AtlassianConnectValidationError(Exception):
pass

Expand Down Expand Up @@ -49,7 +67,9 @@ def get_token(request: HttpRequest) -> str:
auth_header: str = request.META["HTTP_AUTHORIZATION"]
return auth_header.split(" ", 1)[1]
except (KeyError, IndexError):
raise AtlassianConnectValidationError("Missing/Invalid authorization header")
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.MISSING_AUTHORIZATION_HEADER
)


def get_integration_from_jwt(
Expand All @@ -63,7 +83,7 @@ def get_integration_from_jwt(
# Extract the JWT token from the request's jwt query
# parameter or the authorization header.
if token is None:
raise AtlassianConnectValidationError("No token parameter")
raise AtlassianConnectValidationError(AtlassianConnectFailureReason.NO_TOKEN_PARAMETER)
# Decode the JWT token, without verification. This gives
# you a header JSON object, a claims JSON object, and a signature.
claims = jwt.peek_claims(token)
Expand All @@ -77,7 +97,7 @@ def get_integration_from_jwt(
# by the add-on during the installation handshake
integration = integration_service.get_integration(provider=provider, external_id=issuer)
if not integration:
raise AtlassianConnectValidationError("No integration found")
raise AtlassianConnectValidationError(AtlassianConnectFailureReason.NO_INTEGRATION_FOUND)
# Verify the signature with the sharedSecret and the algorithm specified in the header's
# alg field. We only need the token + shared secret and do not want to provide an
# audience to the JWT validation that is require to match. Bitbucket does give us an
Expand All @@ -93,9 +113,13 @@ def get_integration_from_jwt(
else jwt.decode(token, integration.metadata["shared_secret"], audience=False)
)
except InvalidSignatureError as e:
raise AtlassianConnectValidationError("Signature is invalid") from e
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.INVALID_SIGNATURE
) from e
except ExpiredSignatureError as e:
raise AtlassianConnectValidationError("Signature is expired") from e
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.EXPIRED_SIGNATURE
) from e

verify_claims(decoded_claims, path, query_params, method)

Expand All @@ -112,7 +136,7 @@ def verify_claims(
# and comparing it against the qsh claim on the verified token.
qsh = get_query_hash(path, method, query_params)
if qsh != claims["qsh"]:
raise AtlassianConnectValidationError("Query hash mismatch")
raise AtlassianConnectValidationError(AtlassianConnectFailureReason.QUERY_HASH_MISMATCH)


def authenticate_asymmetric_jwt(token: str | None, key_id: str) -> dict[str, str]:
Expand All @@ -121,15 +145,17 @@ def authenticate_asymmetric_jwt(token: str | None, key_id: str) -> dict[str, str
See: https://community.developer.atlassian.com/t/action-required-atlassian-connect-installation-lifecycle-security-improvements/49046
"""
if token is None:
raise AtlassianConnectValidationError("No token parameter")
raise AtlassianConnectValidationError(AtlassianConnectFailureReason.NO_TOKEN_PARAMETER)
headers = jwt.peek_header(token)
key_response = requests.get(f"https://connect-install-keys.atlassian.com/{key_id}")
public_key = key_response.content.decode("utf-8").strip()
decoded_claims = jwt.decode(
token, public_key, audience=absolute_uri(), algorithms=[headers.get("alg")]
)
if not decoded_claims:
raise AtlassianConnectValidationError("Unable to verify asymmetric installation JWT")
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.UNABLE_TO_VERIFY_ASYMMETRIC_JWT
)
return decoded_claims


Expand All @@ -152,3 +178,46 @@ def parse_integration_from_request(request: HttpRequest, provider: str) -> Integ
method=request.method if request.method else "POST",
)
return Integration.objects.filter(id=rpc_integration.id).first()


class AtlassianConnectTokenValidator:
def __init__(self, request: HttpRequest, method: str) -> None:
self.request = request
self.method = method

def get_token(self) -> str:
try:
token = get_token(self.request)
except Exception:
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.FAILED_TO_RETRIEVE_TOKEN
)
self._validate_token(token)
return token

def _validate_token(self, token: str) -> None:
try:
key_id = jwt.peek_header(token).get("kid")
except DecodeError:
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.FAILED_TO_FETCH_KEY_ID
)
if not key_id:
raise AtlassianConnectValidationError(AtlassianConnectFailureReason.MISSING_KEY_ID)
try:
decoded_claims = authenticate_asymmetric_jwt(token, key_id)
Comment thread
sentry-warden[bot] marked this conversation as resolved.
verify_claims(decoded_claims, self.request.path, self.request.GET, self.method)
except InvalidKeyError:
raise AtlassianConnectValidationError(AtlassianConnectFailureReason.INVALID_KEY_ID)
except ExpiredSignatureError:
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.EXPIRED_SIGNATURE_TOKEN
)
except InvalidSignatureError:
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.INVALID_SIGNATURE_TOKEN
)
except DecodeError:
raise AtlassianConnectValidationError(
AtlassianConnectFailureReason.COULD_NOT_DECODE_JWT
)
Loading
Loading