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
76 changes: 66 additions & 10 deletions src/sentry/integrations/bitbucket/webhook.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import hashlib
import hmac
import ipaddress
import logging
from abc import ABC
Expand All @@ -15,8 +17,10 @@
from sentry.api.api_owners import ApiOwner
from sentry.api.api_publish_status import ApiPublishStatus
from sentry.api.base import Endpoint, region_silo_endpoint
from sentry.api.exceptions import SentryAPIException
from sentry.integrations.base import IntegrationDomain
from sentry.integrations.bitbucket.constants import BITBUCKET_IP_RANGES, BITBUCKET_IPS
from sentry.integrations.services.integration.service import integration_service
from sentry.integrations.source_code_management.webhook import SCMWebhook
from sentry.integrations.utils.metrics import IntegrationWebhookEvent, IntegrationWebhookEventType
from sentry.models.commit import Commit
Expand All @@ -31,6 +35,42 @@
PROVIDER_NAME = "integrations:bitbucket"


def is_valid_signature(body: bytes, secret: str, signature: str) -> bool:
hash_object = hmac.new(
secret.encode("utf-8"),
msg=body,
digestmod=hashlib.sha256,
)
expected_signature = hash_object.hexdigest()

if not hmac.compare_digest(expected_signature, signature):
logger.info(
"%s.webhook.invalid-signature",
PROVIDER_NAME,
extra={"expected": expected_signature, "given": signature},
)
return False
return True


class WebhookMissingSignatureException(SentryAPIException):
status_code = 400
code = f"{PROVIDER_NAME}.webhook.missing-signature"
message = "Missing webhook signature"


class WebhookUnsupportedSignatureMethodException(SentryAPIException):
status_code = 400
code = f"{PROVIDER_NAME}.webhook.unsupported-signature-method"
message = "Signature method is not supported"


class WebhookInvalidSignatureException(SentryAPIException):
status_code = 400
code = f"{PROVIDER_NAME}.webhook.invalid-signature"
message = "Webhook signature is invalid"


class BitbucketWebhook(SCMWebhook, ABC):
@property
def provider(self) -> str:
Expand Down Expand Up @@ -74,18 +114,11 @@ def event_type(self) -> IntegrationWebhookEventType:
def __call__(self, event: Mapping[str, Any], **kwargs) -> None:
authors = {}

if not (repo := kwargs.get("repo")):
raise ValueError("Missing repo")
if not (organization := kwargs.get("organization")):
raise ValueError("Missing organization")

try:
repo = Repository.objects.get(
organization_id=organization.id,
provider=PROVIDER_NAME,
external_id=str(event["repository"]["uuid"]),
)
except Repository.DoesNotExist:
raise Http404()
Copy link
Member Author

Choose a reason for hiding this comment

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

Since we are now doing signature check on the upper level (in post() method), and repository is always in the payload (https://support.atlassian.com/bitbucket-cloud/docs/event-payloads/#Repository), we will always lookup the repo on the upper level as well. Hence passing the repo as kwarg and removing this code.


# while we're here, make sure repo data is up to date
self.update_repo_data(repo, event)

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

try:
repo = Repository.objects.get(
organization_id=organization.id,
provider=PROVIDER_NAME,
external_id=str(event["repository"]["uuid"]),
)
except Repository.DoesNotExist:
raise Http404()

integration = integration_service.get_integration(integration_id=repo.integration_id)
if integration and "webhook_secret" in integration.metadata:
secret = integration.metadata["webhook_secret"]
try:
method, signature = request.META["HTTP_X_HUB_SIGNATURE"].split("=", 1)
except (IndexError, KeyError, ValueError):
raise WebhookMissingSignatureException()

if method != "sha256":
raise WebhookUnsupportedSignatureMethodException()

if not is_valid_signature(request.body, secret, signature):
raise WebhookInvalidSignatureException()

event_handler = handler()

with IntegrationWebhookEvent(
interaction_type=event_handler.event_type,
domain=IntegrationDomain.SOURCE_CODE_MANAGEMENT,
provider_key=event_handler.provider,
).capture():
event_handler(event, organization=organization)
event_handler(event, repo=repo, organization=organization)

return HttpResponse(status=204)
64 changes: 63 additions & 1 deletion tests/sentry/integrations/bitbucket/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from unittest.mock import patch

from fixtures.bitbucket import PUSH_EVENT_EXAMPLE
from sentry.integrations.bitbucket.webhook import PROVIDER_NAME
from sentry.integrations.bitbucket.webhook import PROVIDER_NAME, is_valid_signature
from sentry.models.commit import Commit
from sentry.models.commitauthor import CommitAuthor
from sentry.models.repository import Repository
from sentry.silo.base import SiloMode
from sentry.testutils.asserts import assert_failure_metric, assert_success_metric
from sentry.testutils.cases import APITestCase
from sentry.testutils.silo import assume_test_silo_mode

BAD_IP = "109.111.111.10"
BITBUCKET_IP_IN_RANGE = "104.192.143.10"
Expand Down Expand Up @@ -192,3 +194,63 @@ def test_update_repo_url(self):
# url has been updated
repo_out_of_date_url.refresh_from_db()
assert repo_out_of_date_url.url == "https://bitbucket.org/maxbittker/newsdiffs"


class WebhookSignatureTest(WebhookBaseTest):
method = "post"

def setUp(self):
super().setUp()

with assume_test_silo_mode(SiloMode.CONTROL):
integration = self.create_provider_integration(
provider="bitbucket",
external_id="bitbucket_external_id",
name="Hello world",
metadata={"webhook_secret": "test_secret"},
)
integration.add_organization(self.organization)

self.create_repository(integration_id=integration.id)

def send_signed_webhook(self):
return self.get_response(
self.organization_id,
raw_data=PUSH_EVENT_EXAMPLE,
extra_headers=dict(
HTTP_X_EVENT_KEY="repo:push",
HTTP_X_HUB_SIGNATURE=self.signature,
REMOTE_ADDR=BITBUCKET_IP,
),
)

def test_is_valid_signature(self):
# https://support.atlassian.com/bitbucket-cloud/docs/manage-webhooks/#Examples
assert is_valid_signature(
b"Hello World!",
"It's a Secret to Everybody",
"a4771c39fbe90f317c7824e83ddef3caae9cb3d976c214ace1f2937e133263c9",
)

def test_success(self):
self.signature = "sha256=ee07bac3b2fa849cf4346113dc5f6b9738660673aca6fa8f07ce459e7543f980"
response = self.send_signed_webhook()
assert response.status_code == 204

def test_missing_signature(self):
self.signature = ""
response = self.send_signed_webhook()
assert response.status_code == 400
assert response.data["detail"]["message"] == "Missing webhook signature"

def test_invalid_signature(self):
self.signature = "sha256=definitely-invalid"
response = self.send_signed_webhook()
assert response.status_code == 400
assert response.data["detail"]["message"] == "Webhook signature is invalid"

def test_invalid_method(self):
self.signature = "sha1=b842d7b7d535c446133bcf18cf085fb9472175c7"
response = self.send_signed_webhook()
assert response.status_code == 400
assert response.data["detail"]["message"] == "Signature method is not supported"
Loading