Skip to content

Commit ca43d5f

Browse files
authored
feat(bitbucket): check bitbucket webhook signature if webhook_secret is defined (#84309)
Preparing [Bitbucket webhook secret validation](https://support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/#Validating-webhook-deliveries). This is actual signature header validation, but no integrations/repos have the associated secret yet. Follow-up PRs: - backend endpoint to modify `webhook_secret`: #84311 Previous attempt (#82541) had repository-level secrets but we decided to go with integration-level secret to align with other integrations (GitLab, GitHub).
1 parent 31355bb commit ca43d5f

File tree

2 files changed

+129
-11
lines changed

2 files changed

+129
-11
lines changed

src/sentry/integrations/bitbucket/webhook.py

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import hashlib
2+
import hmac
13
import ipaddress
24
import logging
35
from abc import ABC
@@ -15,8 +17,10 @@
1517
from sentry.api.api_owners import ApiOwner
1618
from sentry.api.api_publish_status import ApiPublishStatus
1719
from sentry.api.base import Endpoint, region_silo_endpoint
20+
from sentry.api.exceptions import SentryAPIException
1821
from sentry.integrations.base import IntegrationDomain
1922
from sentry.integrations.bitbucket.constants import BITBUCKET_IP_RANGES, BITBUCKET_IPS
23+
from sentry.integrations.services.integration.service import integration_service
2024
from sentry.integrations.source_code_management.webhook import SCMWebhook
2125
from sentry.integrations.utils.metrics import IntegrationWebhookEvent, IntegrationWebhookEventType
2226
from sentry.models.commit import Commit
@@ -31,6 +35,42 @@
3135
PROVIDER_NAME = "integrations:bitbucket"
3236

3337

38+
def is_valid_signature(body: bytes, secret: str, signature: str) -> bool:
39+
hash_object = hmac.new(
40+
secret.encode("utf-8"),
41+
msg=body,
42+
digestmod=hashlib.sha256,
43+
)
44+
expected_signature = hash_object.hexdigest()
45+
46+
if not hmac.compare_digest(expected_signature, signature):
47+
logger.info(
48+
"%s.webhook.invalid-signature",
49+
PROVIDER_NAME,
50+
extra={"expected": expected_signature, "given": signature},
51+
)
52+
return False
53+
return True
54+
55+
56+
class WebhookMissingSignatureException(SentryAPIException):
57+
status_code = 400
58+
code = f"{PROVIDER_NAME}.webhook.missing-signature"
59+
message = "Missing webhook signature"
60+
61+
62+
class WebhookUnsupportedSignatureMethodException(SentryAPIException):
63+
status_code = 400
64+
code = f"{PROVIDER_NAME}.webhook.unsupported-signature-method"
65+
message = "Signature method is not supported"
66+
67+
68+
class WebhookInvalidSignatureException(SentryAPIException):
69+
status_code = 400
70+
code = f"{PROVIDER_NAME}.webhook.invalid-signature"
71+
message = "Webhook signature is invalid"
72+
73+
3474
class BitbucketWebhook(SCMWebhook, ABC):
3575
@property
3676
def provider(self) -> str:
@@ -74,18 +114,11 @@ def event_type(self) -> IntegrationWebhookEventType:
74114
def __call__(self, event: Mapping[str, Any], **kwargs) -> None:
75115
authors = {}
76116

117+
if not (repo := kwargs.get("repo")):
118+
raise ValueError("Missing repo")
77119
if not (organization := kwargs.get("organization")):
78120
raise ValueError("Missing organization")
79121

80-
try:
81-
repo = Repository.objects.get(
82-
organization_id=organization.id,
83-
provider=PROVIDER_NAME,
84-
external_id=str(event["repository"]["uuid"]),
85-
)
86-
except Repository.DoesNotExist:
87-
raise Http404()
88-
89122
# while we're here, make sure repo data is up to date
90123
self.update_repo_data(repo, event)
91124

@@ -199,13 +232,36 @@ def post(self, request: HttpRequest, organization_id: int) -> HttpResponse:
199232
)
200233
return HttpResponse(status=400)
201234

235+
try:
236+
repo = Repository.objects.get(
237+
organization_id=organization.id,
238+
provider=PROVIDER_NAME,
239+
external_id=str(event["repository"]["uuid"]),
240+
)
241+
except Repository.DoesNotExist:
242+
raise Http404()
243+
244+
integration = integration_service.get_integration(integration_id=repo.integration_id)
245+
if integration and "webhook_secret" in integration.metadata:
246+
secret = integration.metadata["webhook_secret"]
247+
try:
248+
method, signature = request.META["HTTP_X_HUB_SIGNATURE"].split("=", 1)
249+
except (IndexError, KeyError, ValueError):
250+
raise WebhookMissingSignatureException()
251+
252+
if method != "sha256":
253+
raise WebhookUnsupportedSignatureMethodException()
254+
255+
if not is_valid_signature(request.body, secret, signature):
256+
raise WebhookInvalidSignatureException()
257+
202258
event_handler = handler()
203259

204260
with IntegrationWebhookEvent(
205261
interaction_type=event_handler.event_type,
206262
domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT,
207263
provider_key=event_handler.provider,
208264
).capture():
209-
event_handler(event, organization=organization)
265+
event_handler(event, repo=repo, organization=organization)
210266

211267
return HttpResponse(status=204)

tests/sentry/integrations/bitbucket/test_webhook.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
from unittest.mock import patch
66

77
from fixtures.bitbucket import PUSH_EVENT_EXAMPLE
8-
from sentry.integrations.bitbucket.webhook import PROVIDER_NAME
8+
from sentry.integrations.bitbucket.webhook import PROVIDER_NAME, is_valid_signature
99
from sentry.models.commit import Commit
1010
from sentry.models.commitauthor import CommitAuthor
1111
from sentry.models.repository import Repository
12+
from sentry.silo.base import SiloMode
1213
from sentry.testutils.asserts import assert_failure_metric, assert_success_metric
1314
from sentry.testutils.cases import APITestCase
15+
from sentry.testutils.silo import assume_test_silo_mode
1416

1517
BAD_IP = "109.111.111.10"
1618
BITBUCKET_IP_IN_RANGE = "104.192.143.10"
@@ -192,3 +194,63 @@ def test_update_repo_url(self):
192194
# url has been updated
193195
repo_out_of_date_url.refresh_from_db()
194196
assert repo_out_of_date_url.url == "https://bitbucket.org/maxbittker/newsdiffs"
197+
198+
199+
class WebhookSignatureTest(WebhookBaseTest):
200+
method = "post"
201+
202+
def setUp(self):
203+
super().setUp()
204+
205+
with assume_test_silo_mode(SiloMode.CONTROL):
206+
integration = self.create_provider_integration(
207+
provider="bitbucket",
208+
external_id="bitbucket_external_id",
209+
name="Hello world",
210+
metadata={"webhook_secret": "test_secret"},
211+
)
212+
integration.add_organization(self.organization)
213+
214+
self.create_repository(integration_id=integration.id)
215+
216+
def send_signed_webhook(self):
217+
return self.get_response(
218+
self.organization_id,
219+
raw_data=PUSH_EVENT_EXAMPLE,
220+
extra_headers=dict(
221+
HTTP_X_EVENT_KEY="repo:push",
222+
HTTP_X_HUB_SIGNATURE=self.signature,
223+
REMOTE_ADDR=BITBUCKET_IP,
224+
),
225+
)
226+
227+
def test_is_valid_signature(self):
228+
# https://support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/#Examples
229+
assert is_valid_signature(
230+
b"Hello World!",
231+
"It's a Secret to Everybody",
232+
"a4771c39fbe90f317c7824e83ddef3caae9cb3d976c214ace1f2937e133263c9",
233+
)
234+
235+
def test_success(self):
236+
self.signature = "sha256=ee07bac3b2fa849cf4346113dc5f6b9738660673aca6fa8f07ce459e7543f980"
237+
response = self.send_signed_webhook()
238+
assert response.status_code == 204
239+
240+
def test_missing_signature(self):
241+
self.signature = ""
242+
response = self.send_signed_webhook()
243+
assert response.status_code == 400
244+
assert response.data["detail"]["message"] == "Missing webhook signature"
245+
246+
def test_invalid_signature(self):
247+
self.signature = "sha256=definitely-invalid"
248+
response = self.send_signed_webhook()
249+
assert response.status_code == 400
250+
assert response.data["detail"]["message"] == "Webhook signature is invalid"
251+
252+
def test_invalid_method(self):
253+
self.signature = "sha1=b842d7b7d535c446133bcf18cf085fb9472175c7"
254+
response = self.send_signed_webhook()
255+
assert response.status_code == 400
256+
assert response.data["detail"]["message"] == "Signature method is not supported"

0 commit comments

Comments
 (0)