diff --git a/src/sentry/integrations/atlassian_connect.py b/src/sentry/integrations/atlassian_connect.py index 810a3e91a9c040..7916c8b408bbbf 100644 --- a/src/sentry/integrations/atlassian_connect.py +++ b/src/sentry/integrations/atlassian_connect.py @@ -1,12 +1,13 @@ import hashlib from typing import Mapping, Optional, Sequence, Union +import requests from jwt import InvalidSignatureError from rest_framework.request import Request from sentry.models import Integration from sentry.utils import jwt -from sentry.utils.http import percent_encode +from sentry.utils.http import absolute_uri, percent_encode __all__ = ["AtlassianConnectValidationError", "get_query_hash", "get_integration_from_request"] @@ -53,11 +54,13 @@ def get_integration_from_jwt( raise AtlassianConnectValidationError("No token parameter") # Decode the JWT token, without verification. This gives # you a header JSON object, a claims JSON object, and a signature. - decoded = jwt.peek_claims(token) + claims = jwt.peek_claims(token) + headers = jwt.peek_header(token) + # Extract the issuer ('iss') claim from the decoded, unverified # claims object. This is the clientKey for the tenant - an identifier # for the Atlassian application making the call - issuer = decoded["iss"] + issuer = claims.get("iss") # Look up the sharedSecret for the clientKey, as stored # by the add-on during the installation handshake try: @@ -69,18 +72,51 @@ def get_integration_from_jwt( # audience to the JWT validation that is require to match. Bitbucket does give us an # audience claim however, so disable verification of this. try: - decoded_verified = jwt.decode(token, integration.metadata["shared_secret"], audience=False) + # We only authenticate asymmetrically (through the CDN) if the event provides a key ID + # in its JWT headers. This should only appear for install/uninstall events. + decoded_claims = ( + authenticate_asymmetric_jwt(token) + if headers.get("kid") + else jwt.decode(token, integration.metadata["shared_secret"], audience=False) + ) except InvalidSignatureError: raise AtlassianConnectValidationError("Signature is invalid") + verify_claims(decoded_claims, path, query_params, method) + + return integration + + +def verify_claims( + claims: Optional[Mapping[str, str]], + path: str, + query_params: Optional[Mapping[str, str]], + method: str, +) -> None: # Verify the query has not been tampered by Creating a Query Hash # and comparing it against the qsh claim on the verified token. - qsh = get_query_hash(path, method, query_params) - if qsh != decoded_verified["qsh"]: + if qsh != claims["qsh"]: raise AtlassianConnectValidationError("Query hash mismatch") - return integration + +def authenticate_asymmetric_jwt(token: Optional[str]) -> Optional[Mapping[str, str]]: + """ + Allows for Atlassian Connect installation lifecycle security improvements (i.e. verified senders) + 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") + headers = jwt.peek_header(token) + key_id = headers.get("kid") + 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") + return decoded_claims def get_integration_from_request(request: Request, provider: str) -> Integration: diff --git a/src/sentry/integrations/jira/descriptor.py b/src/sentry/integrations/jira/descriptor.py index 3e4f7782976f84..1d06363b934488 100644 --- a/src/sentry/integrations/jira/descriptor.py +++ b/src/sentry/integrations/jira/descriptor.py @@ -24,7 +24,7 @@ def get(self, request): return self.respond( { "name": "Sentry", - "description": "Sentry", + "description": "Connect your Sentry organization into one or more of your Jira cloud instances. Get started streamlining your bug squashing workflow by unifying your Sentry and Jira instances together.", "key": JIRA_KEY, "baseUrl": absolute_uri(), "vendor": {"name": "Sentry", "url": "https://sentry.io"}, @@ -65,7 +65,7 @@ def get(self, request): } ], }, - "apiMigrations": {"gdpr": True, "context-qsh": True}, + "apiMigrations": {"gdpr": True, "context-qsh": True, "signed-install": True}, "scopes": scopes, } ) diff --git a/src/sentry/integrations/jira/installed.py b/src/sentry/integrations/jira/installed.py index 3fd90376edd094..a2b5a3831d755b 100644 --- a/src/sentry/integrations/jira/installed.py +++ b/src/sentry/integrations/jira/installed.py @@ -2,6 +2,11 @@ from rest_framework import status from sentry.api.base import Endpoint +from sentry.integrations.atlassian_connect import ( + AtlassianConnectValidationError, + authenticate_asymmetric_jwt, + verify_claims, +) from sentry.integrations.pipeline import ensure_integration from sentry.tasks.integrations import sync_metadata @@ -17,11 +22,25 @@ def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): - state = request.data + try: + token = request.META["HTTP_AUTHORIZATION"].split(" ", 1)[1] + except (KeyError, IndexError): + return self.respond(status=status.HTTP_400_BAD_REQUEST) + state = request.data if not state: return self.respond(status=status.HTTP_400_BAD_REQUEST) + try: + decoded_claims = authenticate_asymmetric_jwt(token) + except AtlassianConnectValidationError: + return self.respond(status=status.HTTP_400_BAD_REQUEST) + + try: + verify_claims(decoded_claims, request.path, request.GET, method="POST") + except AtlassianConnectValidationError: + return self.respond(status=status.HTTP_400_BAD_REQUEST) + data = JiraIntegrationProvider().build_integration(state) integration = ensure_integration("jira", data) diff --git a/tests/sentry/integrations/jira/test_installed.py b/tests/sentry/integrations/jira/test_installed.py new file mode 100644 index 00000000000000..34f7a022b6b0d2 --- /dev/null +++ b/tests/sentry/integrations/jira/test_installed.py @@ -0,0 +1,55 @@ +import jwt +import responses + +from sentry.constants import ObjectStatus +from sentry.integrations.atlassian_connect import get_query_hash +from sentry.models import Integration +from sentry.testutils import APITestCase +from sentry.utils.http import absolute_uri +from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY + + +class JiraInstalledTest(APITestCase): + external_id = "it2may+cody" + jira_signing_algorithm = "RS256" + kid = "cudi" + path = "/extensions/jira/installed/" + + def jwt_token(self): + return jwt.encode( + { + "iss": self.external_id, + "aud": absolute_uri(), + "qsh": get_query_hash(self.path, method="POST", query_params={}), + }, + RS256_KEY, + algorithm=self.jira_signing_algorithm, + headers={"kid": self.kid, "alg": self.jira_signing_algorithm}, + ) + + @responses.activate + def test_simple(self): + responses.add( + responses.GET, + f"https://connect-install-keys.atlassian.com/{self.kid}", + body=RS256_PUB_KEY, + ) + + resp = self.client.post( + self.path, + data={ + "jira": { + "metadata": {}, + "external_id": self.external_id, + }, + "clientKey": "limepie", + "oauthClientId": "EFG", + "publicKey": "yourCar", + "sharedSecret": "garden", + "baseUrl": "https://sentry.io.org.xyz.online.dev.sentry.io", + }, + HTTP_AUTHORIZATION="JWT " + self.jwt_token(), + ) + integration = Integration.objects.get(provider="jira", external_id=self.external_id) + assert integration.status == ObjectStatus.VISIBLE + assert resp.status_code == 200 diff --git a/tests/sentry/integrations/jira/test_uninstalled.py b/tests/sentry/integrations/jira/test_uninstalled.py index b6b624d911a8e8..a8229198681127 100644 --- a/tests/sentry/integrations/jira/test_uninstalled.py +++ b/tests/sentry/integrations/jira/test_uninstalled.py @@ -1,26 +1,48 @@ -from unittest.mock import patch +import jwt +import responses from sentry.constants import ObjectStatus +from sentry.integrations.atlassian_connect import get_query_hash from sentry.models import Integration from sentry.testutils import APITestCase +from sentry.utils.http import absolute_uri +from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY class JiraUninstalledTest(APITestCase): + external_id = "it2may+cody" + jira_signing_algorithm = "RS256" + kid = "cudi" + path = "/extensions/jira/uninstalled/" + + def jwt_token(self): + return jwt.encode( + { + "iss": self.external_id, + "aud": absolute_uri(), + "qsh": get_query_hash(self.path, method="POST", query_params={}), + }, + RS256_KEY, + algorithm=self.jira_signing_algorithm, + headers={"kid": self.kid, "alg": self.jira_signing_algorithm}, + ) + + @responses.activate def test_simple(self): org = self.organization integration = Integration.objects.create( - provider="jira", name="Example Jira", status=ObjectStatus.VISIBLE + provider="jira", status=ObjectStatus.VISIBLE, external_id=self.external_id ) integration.add_organization(org, self.user) - path = "/extensions/jira/uninstalled/" + responses.add( + responses.GET, + f"https://connect-install-keys.atlassian.com/{self.kid}", + body=RS256_PUB_KEY, + ) - with patch( - "sentry.integrations.jira.uninstalled.get_integration_from_jwt", - return_value=integration, - ): - resp = self.client.post(path, data={}, HTTP_AUTHORIZATION="JWT anexampletoken") - integration = Integration.objects.get(id=integration.id) - assert integration.status == ObjectStatus.DISABLED - assert resp.status_code == 200 + resp = self.client.post(self.path, data={}, HTTP_AUTHORIZATION="JWT " + self.jwt_token()) + integration = Integration.objects.get(id=integration.id) + assert integration.status == ObjectStatus.DISABLED + assert resp.status_code == 200