Skip to content

Commit 7fc4602

Browse files
committed
validate jwt in bitbucket
1 parent 7eed62c commit 7fc4602

File tree

2 files changed

+160
-6
lines changed

2 files changed

+160
-6
lines changed

src/sentry/integrations/bitbucket/installed.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,23 @@
11
from django.http.request import HttpRequest
22
from django.http.response import HttpResponseBase
33
from django.views.decorators.csrf import csrf_exempt
4+
from rest_framework import status
45
from rest_framework.request import Request
56
from rest_framework.response import Response
67

78
from sentry.api.api_owners import ApiOwner
89
from sentry.api.api_publish_status import ApiPublishStatus
910
from sentry.api.base import Endpoint, control_silo_endpoint
11+
from sentry.integrations.jira.webhooks.installed import INVALID_KEY_IDS
1012
from sentry.integrations.pipeline import ensure_integration
1113
from sentry.integrations.types import IntegrationProviderSlug
14+
from sentry.integrations.utils.atlassian_connect import (
15+
AtlassianConnectValidationError,
16+
authenticate_asymmetric_jwt,
17+
get_token,
18+
verify_claims,
19+
)
20+
from sentry.utils import jwt
1221

1322
from .integration import BitbucketIntegrationProvider
1423

@@ -27,7 +36,34 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponseBase:
2736
return super().dispatch(request, *args, **kwargs)
2837

2938
def post(self, request: Request, *args, **kwargs) -> Response:
39+
try:
40+
token = get_token(request)
41+
except AtlassianConnectValidationError:
42+
return self.respond(
43+
{"detail": "Missing authorization header"}, status=status.HTTP_400_BAD_REQUEST
44+
)
45+
46+
key_id = jwt.peek_header(token).get("kid")
47+
if not key_id:
48+
return self.respond({"detail": "Missing key id"}, status=status.HTTP_400_BAD_REQUEST)
49+
50+
if key_id in INVALID_KEY_IDS:
51+
return self.respond({"detail": "Invalid key id"}, status=status.HTTP_400_BAD_REQUEST)
52+
53+
try:
54+
decoded_claims = authenticate_asymmetric_jwt(token, key_id)
55+
verify_claims(decoded_claims, request.path, request.GET, method="POST")
56+
except AtlassianConnectValidationError:
57+
return self.respond(
58+
{"detail": "Could not validate JWT"}, status=status.HTTP_400_BAD_REQUEST
59+
)
60+
3061
state = request.data
62+
if decoded_claims.get("iss") != state.get("clientKey"):
63+
return self.respond(
64+
{"detail": "JWT issuer does not match client key"},
65+
status=status.HTTP_400_BAD_REQUEST,
66+
)
3167
data = BitbucketIntegrationProvider().build_integration(state)
3268
ensure_integration(IntegrationProviderSlug.BITBUCKET.value, data)
3369

tests/sentry/integrations/bitbucket/test_installed.py

Lines changed: 124 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
from typing import Any
44
from unittest import mock
55

6+
import jwt as pyjwt
67
import responses
78

89
from sentry.integrations.bitbucket.installed import BitbucketInstalledEndpoint
910
from sentry.integrations.bitbucket.integration import BitbucketIntegrationProvider, scopes
1011
from sentry.integrations.models.integration import Integration
12+
from sentry.integrations.utils.atlassian_connect import get_query_hash
1113
from sentry.models.project import Project
1214
from sentry.models.repository import Repository
1315
from sentry.organizations.services.organization.serial import serialize_rpc_organization
@@ -16,6 +18,8 @@
1618
from sentry.silo.base import SiloMode
1719
from sentry.testutils.cases import APITestCase
1820
from sentry.testutils.silo import assume_test_silo_mode, control_silo_test
21+
from sentry.utils.http import absolute_uri
22+
from tests.sentry.utils.test_jwt import RS256_KEY, RS256_PUB_KEY
1923

2024

2125
class BitbucketPlugin(IssueTrackingPlugin2):
@@ -29,6 +33,7 @@ class BitbucketInstalledEndpointTest(APITestCase):
2933
def setUp(self) -> None:
3034
self.provider = "bitbucket"
3135
self.path = "/extensions/bitbucket/installed/"
36+
self.kid = "bitbucket-kid"
3237
self.username = "sentryuser"
3338
self.client_key = "connection:123"
3439
self.public_key = "123abcDEFg"
@@ -98,27 +103,118 @@ def tearDown(self) -> None:
98103
plugins.unregister(BitbucketPlugin)
99104
super().tearDown()
100105

106+
def jwt_token_cdn(self) -> str:
107+
return pyjwt.encode(
108+
{
109+
"iss": self.client_key,
110+
"aud": absolute_uri(),
111+
"qsh": get_query_hash(self.path, method="POST", query_params={}),
112+
},
113+
RS256_KEY,
114+
algorithm="RS256",
115+
headers={"kid": self.kid, "alg": "RS256"},
116+
)
117+
118+
def add_cdn_response(self) -> None:
119+
responses.add(
120+
responses.GET,
121+
f"https://connect-install-keys.atlassian.com/{self.kid}",
122+
body=RS256_PUB_KEY,
123+
)
124+
101125
def test_default_permissions(self) -> None:
102126
# Permissions must be empty so that it will be accessible to bitbucket.
103127
assert BitbucketInstalledEndpoint.authentication_classes == ()
104128
assert BitbucketInstalledEndpoint.permission_classes == ()
105129

106-
def test_installed_with_public_key(self) -> None:
130+
def test_missing_token(self) -> None:
107131
response = self.client.post(self.path, data=self.team_data_from_bitbucket)
132+
assert response.status_code == 400
133+
134+
def test_invalid_token(self) -> None:
135+
response = self.client.post(
136+
self.path,
137+
data=self.team_data_from_bitbucket,
138+
HTTP_AUTHORIZATION="invalid",
139+
)
140+
assert response.status_code == 400
141+
142+
@responses.activate
143+
def test_missing_key_id(self) -> None:
144+
token = pyjwt.encode(
145+
{
146+
"iss": self.client_key,
147+
"aud": absolute_uri(),
148+
"qsh": get_query_hash(self.path, method="POST", query_params={}),
149+
},
150+
RS256_KEY,
151+
algorithm="RS256",
152+
headers={"alg": "RS256"},
153+
)
154+
response = self.client.post(
155+
self.path,
156+
data=self.team_data_from_bitbucket,
157+
HTTP_AUTHORIZATION=f"JWT {token}",
158+
)
159+
assert response.status_code == 400
160+
161+
@responses.activate
162+
def test_invalid_key_id(self) -> None:
163+
token = pyjwt.encode(
164+
{
165+
"iss": self.client_key,
166+
"aud": absolute_uri(),
167+
"qsh": get_query_hash(self.path, method="POST", query_params={}),
168+
},
169+
RS256_KEY,
170+
algorithm="RS256",
171+
headers={"kid": "fake-kid", "alg": "RS256"},
172+
)
173+
response = self.client.post(
174+
self.path,
175+
data=self.team_data_from_bitbucket,
176+
HTTP_AUTHORIZATION=f"JWT {token}",
177+
)
178+
assert response.status_code == 400
179+
180+
@responses.activate
181+
def test_jwt_issuer_mismatch(self) -> None:
182+
self.add_cdn_response()
183+
response = self.client.post(
184+
self.path,
185+
data={**self.team_data_from_bitbucket, "clientKey": "different-client-key"},
186+
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
187+
)
188+
assert response.status_code == 400
189+
190+
@responses.activate
191+
def test_installed_with_public_key(self) -> None:
192+
self.add_cdn_response()
193+
response = self.client.post(
194+
self.path,
195+
data=self.team_data_from_bitbucket,
196+
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
197+
)
108198
assert response.status_code == 200
109199
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
110200
assert integration.name == self.username
111201
del integration.metadata["webhook_secret"]
112202
assert integration.metadata == self.metadata
113203

204+
@responses.activate
114205
def test_installed_without_public_key(self) -> None:
115206
integration, created = Integration.objects.get_or_create(
116207
provider=self.provider,
117208
external_id=self.client_key,
118209
defaults={"name": self.user_display_name, "metadata": self.user_metadata},
119210
)
120211
del self.user_data_from_bitbucket["principal"]["username"]
121-
response = self.client.post(self.path, data=self.user_data_from_bitbucket)
212+
self.add_cdn_response()
213+
response = self.client.post(
214+
self.path,
215+
data=self.user_data_from_bitbucket,
216+
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
217+
)
122218
assert response.status_code == 200
123219

124220
# assert no changes have been made to the integration
@@ -129,22 +225,34 @@ def test_installed_without_public_key(self) -> None:
129225
del integration_after.metadata["webhook_secret"]
130226
assert integration.metadata == integration_after.metadata
131227

228+
@responses.activate
132229
def test_installed_without_username(self) -> None:
133230
"""Test a user (not team) installation where the user has hidden their username from public view"""
134231

135232
# Remove username to simulate privacy mode
136233
del self.user_data_from_bitbucket["principal"]["username"]
137234

138-
response = self.client.post(self.path, data=self.user_data_from_bitbucket)
235+
self.add_cdn_response()
236+
response = self.client.post(
237+
self.path,
238+
data=self.user_data_from_bitbucket,
239+
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
240+
)
139241
assert response.status_code == 200
140242
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
141243
assert integration.name == self.user_display_name
142244
del integration.metadata["webhook_secret"]
143245
assert integration.metadata == self.user_metadata
144246

247+
@responses.activate
145248
@mock.patch("sentry.integrations.bitbucket.integration.generate_token", return_value="0" * 64)
146249
def test_installed_with_secret(self, mock_generate_token: mock.MagicMock) -> None:
147-
response = self.client.post(self.path, data=self.team_data_from_bitbucket)
250+
self.add_cdn_response()
251+
response = self.client.post(
252+
self.path,
253+
data=self.team_data_from_bitbucket,
254+
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
255+
)
148256
assert mock_generate_token.called
149257
assert response.status_code == 200
150258
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
@@ -172,7 +280,12 @@ def test_plugin_migration(self) -> None:
172280
config={"name": "otheruser/otherrepo"},
173281
)
174282

175-
self.client.post(self.path, data=self.team_data_from_bitbucket)
283+
self.add_cdn_response()
284+
self.client.post(
285+
self.path,
286+
data=self.team_data_from_bitbucket,
287+
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
288+
)
176289

177290
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
178291

@@ -219,7 +332,12 @@ def test_disable_plugin_when_fully_migrated(self) -> None:
219332
config={"name": "sentryuser/repo"},
220333
)
221334

222-
self.client.post(self.path, data=self.team_data_from_bitbucket)
335+
self.add_cdn_response()
336+
self.client.post(
337+
self.path,
338+
data=self.team_data_from_bitbucket,
339+
HTTP_AUTHORIZATION=f"JWT {self.jwt_token_cdn()}",
340+
)
223341

224342
integration = Integration.objects.get(provider=self.provider, external_id=self.client_key)
225343

0 commit comments

Comments
 (0)