From 771fac577145320e5152edbfd594cc90348c380b Mon Sep 17 00:00:00 2001 From: gabrielburnworth Date: Mon, 16 Sep 2024 22:30:19 -0700 Subject: [PATCH] add /webhooks --- app/controllers/webhooks_controller.rb | 62 ++++++++++ config/routes.rb | 1 + spec/controllers/webhooks_controller_spec.rb | 115 +++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 app/controllers/webhooks_controller.rb create mode 100644 spec/controllers/webhooks_controller_spec.rb diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb new file mode 100644 index 000000000..568706ebf --- /dev/null +++ b/app/controllers/webhooks_controller.rb @@ -0,0 +1,62 @@ +class WebhooksController < ApplicationController + + skip_before_action :verify_authenticity_token + + def create + request_body = request.raw_post + heroku_signature = request.headers['Heroku-Webhook-Hmac-SHA256'] + secret = ENV['HEROKU_WEBHOOK_SECRET'] + + if secret.nil? + render json: { message: 'Secret not set' }, status: :forbidden + return + end + calculated_signature = Base64.strict_encode64(OpenSSL::HMAC.digest('sha256', secret, request_body)) + + if heroku_signature && secure_compare(calculated_signature, heroku_signature) + webhook_url = ENV["RELEASE_WEBHOOK_URL"] + current = params[:data][:current] + status = params[:data][:status] + build = params[:resource] == "build" && params[:action] == "create" && status == "pending" + if webhook_url && (current || build) + environment = ENV["HEROKU_APP_NAME"]&.include?("staging") ? "staging" : "production" + notification_text = "New #{environment} Heroku event: " + if build + output = params[:data][:output_stream_url] + notification_text += "Build started" + details = "<#{output}|build log>" + else + notification_text += "#{params[:data][:description]} `#{status}`" + commit_desc = params[:data][:slug][:commit_description] + details = commit_desc + end + payload = { + "mrkdwn": true, + "text": notification_text, + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "#{notification_text}\n#{details}", + } + } + ], + "channel": "#software", + }.to_json + Faraday.post(webhook_url, + payload, + "Content-Type" => "application/json") + end + render json: { message: 'Webhook received successfully' }, status: :ok + else + render json: { message: 'Invalid signature' }, status: :forbidden + end + end + + private + + def secure_compare(calculated_signature, heroku_signature) + ActiveSupport::SecurityUtils.secure_compare(calculated_signature, heroku_signature) + end +end diff --git a/config/routes.rb b/config/routes.rb index 2fccffaf7..b623130ce 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -138,4 +138,5 @@ get "/verify/:token" => "dashboard#confirmation_page", as: :confirmation_page post "/csp_reports" => "dashboard#csp_reports", as: :csp_report post "/direct_upload" => "dashboard#direct_upload", as: :direct_upload + post "/webhooks" => "webhooks#create", as: :webhooks end diff --git a/spec/controllers/webhooks_controller_spec.rb b/spec/controllers/webhooks_controller_spec.rb new file mode 100644 index 000000000..704c3feba --- /dev/null +++ b/spec/controllers/webhooks_controller_spec.rb @@ -0,0 +1,115 @@ +require "spec_helper" + +describe WebhooksController do + include Devise::Test::ControllerHelpers + before do + allow(Faraday).to receive(:post) + end + + RELEASE = { + id: "id", + data: { + status: "succeeded", + current: true, + description: "description", + slug: { + commit_description: "commit_description", + }, + }, + resource: "release", + action: "update", + } + BUILD = { + id: "id", + data: { + status: "pending", + output_stream_url: "output_stream_url", + }, + resource: "build", + action: "create", + } + + it "handles a release payload" do + with_modified_env( + RELEASE_WEBHOOK_URL: "https://example.com/webhook_url", + HEROKU_WEBHOOK_SECRET: "secret", + ) do + stub_request(:post, ENV["RELEASE_WEBHOOK_URL"]). + to_return(status: 200, body: "", headers: {}) + request.headers["Heroku-Webhook-Hmac-SHA256"] = Base64.strict_encode64( + OpenSSL::HMAC.digest("sha256", "secret", RELEASE.to_json)) + request.headers["Content-Type"] = "application/json" + post :create, body: RELEASE.to_json, params: { format: :json } + expect(response.status).to eq(200) + expect(json[:message]).to eq("Webhook received successfully") + expect(Faraday).to have_received(:post).with( + ENV["RELEASE_WEBHOOK_URL"], + {"mrkdwn":true, + "text":"New production Heroku event: description `succeeded`", + "blocks":[{ + "type":"section", + "text":{ + "type":"mrkdwn", + "text":"New production Heroku event: description `succeeded`\ncommit_description"}}], + "channel":"#software"}.to_json, + "Content-Type" => "application/json") + end + end + + it "handles a build payload" do + with_modified_env( + RELEASE_WEBHOOK_URL: "https://example.com/webhook_url", + HEROKU_WEBHOOK_SECRET: "secret", + ) do + stub_request(:post, ENV["RELEASE_WEBHOOK_URL"]). + to_return(status: 200, body: "", headers: {}) + request.headers["Heroku-Webhook-Hmac-SHA256"] = Base64.strict_encode64( + OpenSSL::HMAC.digest("sha256", "secret", BUILD.to_json)) + request.headers["Content-Type"] = "application/json" + post :create, body: BUILD.to_json, params: { format: :json } + expect(response.status).to eq(200) + expect(json[:message]).to eq("Webhook received successfully") + expect(Faraday).to have_received(:post).with( + ENV["RELEASE_WEBHOOK_URL"], + {"mrkdwn":true, + "text":"New production Heroku event: Build started", + "blocks":[{ + "type":"section", + "text":{ + "type":"mrkdwn", + "text":"New production Heroku event: Build started\n\u003coutput_stream_url|build log\u003e"}}], + "channel":"#software"}.to_json, + "Content-Type" => "application/json") + end + end + + it "receives a webhook: no relay" do + with_modified_env(HEROKU_WEBHOOK_SECRET: "secret", RELEASE_WEBHOOK_URL: nil) do + request.headers["Heroku-Webhook-Hmac-SHA256"] = Base64.strict_encode64( + OpenSSL::HMAC.digest("sha256", "secret", RELEASE.to_json)) + request.headers["Content-Type"] = "application/json" + post :create, body: RELEASE.to_json, params: { format: :json } + expect(response.status).to eq(200) + expect(json[:message]).to eq("Webhook received successfully") + end + end + + it "rejects a webhook with an invalid signature" do + with_modified_env HEROKU_WEBHOOK_SECRET: "secret" do + request.headers["Heroku-Webhook-Hmac-SHA256"] = "invalid" + request.headers["Content-Type"] = "application/json" + post :create, body: RELEASE.to_json, params: { format: :json } + expect(response.status).to eq(403) + expect(json[:message]).to eq("Invalid signature") + end + end + + it "rejects a webhook with a missing secret" do + with_modified_env HEROKU_WEBHOOK_SECRET: nil do + request.headers["Content-Type"] = "application/json" + post :create, body: RELEASE.to_json, params: { format: :json } + expect(response.status).to eq(403) + expect(json[:message]).to eq("Secret not set") + end + end +end