Browse Source

fix(heroku): Use app webhooks (#43817)

Heroku is about to sunset their [deploy
hooks](https://blog.heroku.com/deployhooks-sunset) which we use to
listen for a deploy and create a release. This PR updates the usage to
the newer [app
webhooks](https://devcenter.heroku.com/articles/app-webhooks).

I left the old code in for now so users can have a bit of time to
migrate (really just 17 days now) and that can be deleted after the
sunset.

This also [secures the webhook
request](https://devcenter.heroku.com/articles/app-webhooks#securing-webhook-requests)
by comparing the `Heroku-Webhook-Hmac-SHA256` header against the webhook
secret. This does alter the plugin setup slightly in that the user will
need to generate the webhook secret and then go back and save it, and
existing users will need to go into their setup and add the webhook
secret after [following the Heroku migration
steps](https://blog.heroku.com/deployhooks-sunset). It's slightly
annoying but better than leaving it unsecured and vulnerable.

<img width="484" alt="Screen Shot 2023-01-30 at 1 49 59 PM"
src="https://user-images.githubusercontent.com/29959063/215603602-ac8a6769-78cd-4754-9d7b-2e8721f20b12.png">
Colleen O'Rourke 2 years ago
parent
commit
a41c9810fc
2 changed files with 116 additions and 13 deletions
  1. 77 3
      src/sentry_plugins/heroku/plugin.py
  2. 39 10
      tests/sentry_plugins/heroku/test_plugin.py

+ 77 - 3
src/sentry_plugins/heroku/plugin.py

@@ -1,5 +1,9 @@
+import base64
+import hmac
 import logging
+from hashlib import sha256
 
+from django.http import HttpResponse
 from rest_framework.request import Request
 from rest_framework.response import Response
 
@@ -8,7 +12,9 @@ from sentry.models import ApiKey, ProjectOption, Repository, User
 from sentry.plugins.base.configuration import react_plugin_config
 from sentry.plugins.bases import ReleaseTrackingPlugin
 from sentry.plugins.interfaces.releasehook import ReleaseHook
+from sentry.utils import json
 from sentry_plugins.base import CorePluginMixin
+from sentry_plugins.utils import get_secret_field_config
 
 from .client import HerokuApiClient
 
@@ -25,7 +31,20 @@ class HerokuReleaseHook(ReleaseHook):
     def get_client(self):
         return HerokuApiClient()
 
-    def handle(self, request: Request) -> Response:
+    def is_valid_signature(self, body, heroku_hmac):
+        secret = ProjectOption.objects.get_value(project=self.project, key="heroku:webhook_secret")
+        computed_hmac = base64.b64encode(
+            hmac.new(
+                key=secret.encode("utf-8"),
+                msg=body.encode("utf-8"),
+                digestmod=sha256,
+            ).digest()
+        ).decode("utf-8")
+
+        return hmac.compare_digest(heroku_hmac, computed_hmac)
+
+    def handle_legacy(self, request: Request) -> Response:
+        """Code path to handle deploy hooks. Delete after Feb 17th 2023 - https://blog.heroku.com/deployhooks-sunset"""
         email = None
         if "user" in request.POST:
             email = request.POST["user"]
@@ -49,6 +68,46 @@ class HerokuReleaseHook(ReleaseHook):
             version=request.POST.get("head_long"), url=request.POST.get("url"), owner=user
         )
 
+    def handle(self, request: Request) -> Response:
+        heroku_hmac = request.headers.get("Heroku-Webhook-Hmac-SHA256")
+
+        if heroku_hmac:
+            if not self.is_valid_signature(request.body.decode("utf-8"), heroku_hmac):
+                logger.error(
+                    "heroku.webhook.invalid-signature", extra={"project_id": self.project.id}
+                )
+                return HttpResponse(status=401)
+
+            body = json.loads(request.body)
+            data = body.get("data")
+            email = data.get("user", {}).get("email") or data.get("actor", {}).get("email")
+
+            try:
+                user = User.objects.get(
+                    email__iexact=email, sentry_orgmember_set__organization__project=self.project
+                )
+            except (User.DoesNotExist, User.MultipleObjectsReturned):
+                user = None
+                logger.info(
+                    "owner.missing",
+                    extra={
+                        "organization_id": self.project.organization_id,
+                        "project_id": self.project.id,
+                        "email": email,
+                    },
+                )
+            commit = data.get("slug", {}).get("commit")
+            app_name = data.get("app", {}).get("name")
+            if app_name:
+                self.finish_release(
+                    version=commit, url=f"http://{app_name}.herokuapp.com", owner=user
+                )
+            else:
+                self.finish_release(version=commit, owner=user)
+
+        else:
+            self.handle_legacy(request)  # TODO delete this else after Feb 17th 2023
+
     def set_refs(self, release, **values):
         if not values.get("owner", None):
             return
@@ -128,6 +187,20 @@ class HerokuPlugin(CorePluginMixin, ReleaseTrackingPlugin):
         else:
             choices = []
         choices.extend([(repo.name, repo.name) for repo in repo_list])
+
+        webhook_secret = self.get_option("webhook_secret", project)
+        secret_field = get_secret_field_config(
+            webhook_secret,
+            "Enter the webhook signing secret shown after running the Heroku CLI command.",
+        )
+        secret_field.update(
+            {
+                "name": "webhook_secret",
+                "label": "Webhook Secret",
+                "required": False,
+            }
+        )
+
         return [
             {
                 "name": "repository",
@@ -145,12 +218,13 @@ class HerokuPlugin(CorePluginMixin, ReleaseTrackingPlugin):
                 "default": "production",
                 "help": "Specify an environment name for your Heroku deploys",
             },
+            secret_field,
         ]
 
     def get_release_doc_html(self, hook_url):
         return f"""
-        <p>Add Sentry as a deploy hook to automatically track new releases.</p>
-        <pre class="clippy">heroku addons:create deployhooks:http --url={hook_url}</pre>
+        <p>Add a Sentry release webhook to automatically track new releases.</p>
+        <pre class="clippy">heroku webhooks:add -i api:release -l notify -u {hook_url} -a YOUR_APP_NAME</pre>
         """
 
     def get_release_hook(self):

+ 39 - 10
tests/sentry_plugins/heroku/test_plugin.py

@@ -17,6 +17,7 @@ from sentry.models import (
     User,
 )
 from sentry.testutils import TestCase
+from sentry.utils import json
 from sentry_plugins.heroku.plugin import HerokuReleaseHook
 
 
@@ -129,12 +130,20 @@ class HookHandleTest(TestCase):
         organization = self.create_organization(owner=user)
         project = self.create_project(organization=organization)
         hook = HerokuReleaseHook(project)
+        hook.is_valid_signature = Mock()
         hook.set_refs = Mock()
 
         req = Mock()
-        req.POST = {"head_long": "abcd123", "url": "http://example.com", "user": user.email}
+        body = {
+            "data": {
+                "user": {"email": user.email},
+                "slug": {"commit": "abcd123"},
+                "app": {"name": "example"},
+            }
+        }
+        req.body = bytes(json.dumps(body), "utf-8")
         hook.handle(req)
-        assert Release.objects.filter(version=req.POST["head_long"]).exists()
+        assert Release.objects.filter(version=body["data"]["slug"]["commit"]).exists()
         assert hook.set_refs.call_count == 1
 
     def test_actor_email_success(self):
@@ -142,16 +151,20 @@ class HookHandleTest(TestCase):
         organization = self.create_organization(owner=user)
         project = self.create_project(organization=organization)
         hook = HerokuReleaseHook(project)
+        hook.is_valid_signature = Mock()
         hook.set_refs = Mock()
 
         req = Mock()
-        req.POST = {
-            "head_long": "v999",
-            "url": "http://example.com",
-            "actor": {"email": user.email},
+        body = {
+            "data": {
+                "actor": {"email": user.email},
+                "slug": {"commit": "abcd123"},
+                "app": {"name": "example"},
+            }
         }
+        req.body = bytes(json.dumps(body), "utf-8")
         hook.handle(req)
-        assert Release.objects.filter(version=req.POST["head_long"]).exists()
+        assert Release.objects.filter(version=body["data"]["slug"]["commit"]).exists()
         assert hook.set_refs.call_count == 1
 
     def test_email_mismatch(self):
@@ -159,18 +172,34 @@ class HookHandleTest(TestCase):
         organization = self.create_organization(owner=user)
         project = self.create_project(organization=organization)
         hook = HerokuReleaseHook(project)
+        hook.is_valid_signature = Mock()
 
         req = Mock()
-        req.POST = {"head_long": "v999", "url": "http://example.com", "user": "wrong@example.com"}
+        body = {
+            "data": {
+                "user": {"email": "wrong@example.com"},
+                "slug": {"commit": "v999"},
+                "app": {"name": "example"},
+            }
+        }
+        req.body = bytes(json.dumps(body), "utf-8")
         hook.handle(req)
-        assert Release.objects.filter(version=req.POST["head_long"]).exists()
+        assert Release.objects.filter(version=body["data"]["slug"]["commit"]).exists()
 
     def test_bad_version(self):
         project = self.create_project()
         user = self.create_user()
         hook = HerokuReleaseHook(project)
+        hook.is_valid_signature = Mock()
 
         req = Mock()
-        req.POST = {"head_long": "", "url": "http://example.com", "user": user.email}
+        body = {
+            "data": {
+                "actor": {"email": user.email},
+                "slug": {"commit": ""},
+                "app": {"name": "example"},
+            }
+        }
+        req.body = bytes(json.dumps(body), "utf-8")
         with pytest.raises(HookValidationError):
             hook.handle(req)