Skip to content
Merged
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
50 changes: 43 additions & 7 deletions src/sentry/integrations/atlassian_connect.py
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/sentry/integrations/jira/descriptor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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,
}
)
21 changes: 20 additions & 1 deletion src/sentry/integrations/jira/installed.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down
55 changes: 55 additions & 0 deletions tests/sentry/integrations/jira/test_installed.py
Original file line number Diff line number Diff line change
@@ -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
44 changes: 33 additions & 11 deletions tests/sentry/integrations/jira/test_uninstalled.py
Original file line number Diff line number Diff line change
@@ -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