diff --git a/Gemfile.lock b/Gemfile.lock index 932eb34..28946ea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - hooks-ruby (0.0.3) + hooks-ruby (0.0.4) dry-schema (~> 1.14, >= 1.14.1) grape (~> 2.3) puma (~> 6.6) diff --git a/lib/hooks/version.rb b/lib/hooks/version.rb index 5835701..d747a97 100644 --- a/lib/hooks/version.rb +++ b/lib/hooks/version.rb @@ -4,5 +4,5 @@ module Hooks # Current version of the Hooks webhook framework # @return [String] The version string following semantic versioning - VERSION = "0.0.3" + VERSION = "0.0.4".freeze end diff --git a/spec/acceptance/acceptance_tests.rb b/spec/acceptance/acceptance_tests.rb index 34116d6..7836a27 100644 --- a/spec/acceptance/acceptance_tests.rb +++ b/spec/acceptance/acceptance_tests.rb @@ -13,6 +13,61 @@ describe "Hooks" do let(:http) { Net::HTTP.new("0.0.0.0", 8080) } + # Helper methods to reduce duplication + def make_request(method, path, payload = nil, headers = {}) + case method + when :get + http.get(path, headers) + when :post + http.post(path, payload, headers) + end + end + + def expect_response(response, expected_type, expected_body_content = nil) + expect(response).to be_a(expected_type) + expect(response.body).to include(expected_body_content) if expected_body_content + end + + def parse_json_response(response) + JSON.parse(response.body) + end + + def json_headers(additional_headers = {}) + { "Content-Type" => "application/json" }.merge(additional_headers) + end + + def generate_hmac_signature(payload, secret, algorithm = "sha256", prefix = "sha256=") + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new(algorithm), secret, payload) + "#{prefix}#{digest}" + end + + def generate_hmac_with_timestamp(payload, secret, timestamp, algorithm = "sha256") + signing_payload = "#{timestamp}:#{payload}" + generate_hmac_signature(signing_payload, secret, algorithm) + end + + def generate_slack_signature(payload, secret, timestamp) + signing_payload = "v0:#{timestamp}:#{payload}" + digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), secret, signing_payload) + "v0=#{digest}" + end + + def current_timestamp + Time.now.utc.iso8601 + end + + def unix_timestamp + Time.now.to_i.to_s + end + + def expired_timestamp(seconds_ago = 600) + (Time.now.utc - seconds_ago).iso8601 + end + + def expired_unix_timestamp(seconds_ago = 600) + (Time.now.to_i - seconds_ago).to_s + end + before(:all) do start_time = Time.now loop do @@ -33,10 +88,10 @@ describe "operational endpoints" do it "responds to the /health check" do - response = http.get("/health") - expect(response).to be_a(Net::HTTPSuccess) + response = make_request(:get, "/health") + expect_response(response, Net::HTTPSuccess) - body = JSON.parse(response.body) + body = parse_json_response(response) expect(body["status"]).to eq("healthy") expect(body["version"]).to eq(Hooks::VERSION) expect(body).to have_key("timestamp") @@ -44,10 +99,10 @@ end it "responds to the /version endpoint" do - response = http.get("/version") - expect(response).to be_a(Net::HTTPSuccess) + response = make_request(:get, "/version") + expect_response(response, Net::HTTPSuccess) - body = JSON.parse(response.body) + body = parse_json_response(response) expect(body["version"]).to eq(Hooks::VERSION) expect(body).to have_key("timestamp") end @@ -56,17 +111,16 @@ describe "endpoints" do describe "team1" do it "responds to the /webhooks/team1 endpoint" do - response = http.get("/webhooks/team1") - expect(response).to be_a(Net::HTTPMethodNotAllowed) - expect(response.body).to include("405 Not Allowed") + response = make_request(:get, "/webhooks/team1") + expect_response(response, Net::HTTPMethodNotAllowed, "405 Not Allowed") end it "processes a POST request with JSON payload" do payload = { event: "test_event", data: "test_data", event_type: "alert" } - response = http.post("/webhooks/team1", payload.to_json, { "Content-Type" => "application/json" }) - expect(response).to be_a(Net::HTTPSuccess) + response = make_request(:post, "/webhooks/team1", payload.to_json, json_headers) + expect_response(response, Net::HTTPSuccess) - body = JSON.parse(response.body) + body = parse_json_response(response) expect(body["status"]).to eq("alert_processed") expect(body["handler"]).to eq("Team1Handler") expect(body["channels_notified"]).to include("#team1-alerts") @@ -77,41 +131,35 @@ describe "github" do it "receives a POST request but contains an invalid HMAC signature" do payload = { action: "push", repository: { name: "test-repo" } } - headers = { "Content-Type" => "application/json", "X-Hub-Signature-256" => "sha256=invalidsignature" } - response = http.post("/webhooks/github", payload.to_json, headers) + headers = json_headers("X-Hub-Signature-256" => "sha256=invalidsignature") + response = make_request(:post, "/webhooks/github", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "receives a POST request but there is no HMAC related header" do payload = { action: "push", repository: { name: "test-repo" } } - headers = { "Content-Type" => "application/json" } - response = http.post("/webhooks/github", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + response = make_request(:post, "/webhooks/github", payload.to_json, json_headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "receives a POST request but it uses the wrong algo" do payload = { action: "push", repository: { name: "test-repo" } } - headers = { - "Content-Type" => "application/json", - "X-Hub-Signature-256" => "sha512=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha512"), FAKE_HMAC_SECRET, payload.to_json) - } - response = http.post("/webhooks/github", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + json_payload = payload.to_json + signature = generate_hmac_signature(json_payload, FAKE_HMAC_SECRET, "sha512", "sha512=") + headers = json_headers("X-Hub-Signature-256" => signature) + response = make_request(:post, "/webhooks/github", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "successfully processes a valid POST request with HMAC signature" do payload = { action: "push", repository: { name: "test-repo" } } - headers = { - "Content-Type" => "application/json", - "X-Hub-Signature-256" => "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_HMAC_SECRET, payload.to_json) - } - response = http.post("/webhooks/github", payload.to_json, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + json_payload = payload.to_json + signature = generate_hmac_signature(json_payload, FAKE_HMAC_SECRET) + headers = json_headers("X-Hub-Signature-256" => signature) + response = make_request(:post, "/webhooks/github", json_payload, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end end @@ -119,342 +167,219 @@ describe "hmac_with_timestamp" do it "successfully processes a valid POST request with HMAC signature and timestamp" do payload = { text: "Hello, World!" } - timestamp = Time.now.utc.iso8601 - body = payload.to_json - signing_payload = "#{timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha256=#{digest}", - "X-HMAC-Timestamp" => timestamp - } - response = http.post("/webhooks/hmac_with_timestamp", body, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + timestamp = current_timestamp + json_payload = payload.to_json + signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, timestamp) + headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => timestamp) + response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end it "successfully processes a valid POST request with HMAC signature and timestamp and an empty payload" do payload = {} - timestamp = Time.now.utc.iso8601 - body = payload.to_json - signing_payload = "#{timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha256=#{digest}", - "X-HMAC-Timestamp" => timestamp - } - response = http.post("/webhooks/hmac_with_timestamp", body, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + timestamp = current_timestamp + json_payload = payload.to_json + signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, timestamp) + headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => timestamp) + response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end it "successfully processes a valid POST request with HMAC signature and the POST has no body" do - timestamp = Time.now.utc.iso8601 - signing_payload = "#{timestamp}:" # Empty body - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha256=#{digest}", - "X-HMAC-Timestamp" => timestamp - } - response = http.post("/webhooks/hmac_with_timestamp", nil, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + timestamp = current_timestamp + signature = generate_hmac_with_timestamp("", FAKE_ALT_HMAC_SECRET, timestamp) + headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => timestamp) + response = make_request(:post, "/webhooks/hmac_with_timestamp", nil, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end it "fails due to using the wrong HMAC secret" do payload = { text: "Hello, World!" } - timestamp = Time.now.utc.iso8601 - body = payload.to_json - signing_payload = "#{timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), "bad-hmac-secret", signing_payload) - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha256=#{digest}", - "X-HMAC-Timestamp" => timestamp - } - response = http.post("/webhooks/hmac_with_timestamp", body, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + timestamp = current_timestamp + json_payload = payload.to_json + signature = generate_hmac_with_timestamp(json_payload, "bad-hmac-secret", timestamp) + headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => timestamp) + response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "fails due to missing timestamp header" do payload = { text: "Hello, World!" } - body = payload.to_json - signing_payload = "#{Time.now.utc.iso8601}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha256=#{digest}" - # Missing X-HMAC-Timestamp header - } - response = http.post("/webhooks/hmac_with_timestamp", body, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + json_payload = payload.to_json + signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, current_timestamp) + headers = json_headers("X-HMAC-Signature" => signature) + response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "fails due to invalid timestamp format" do payload = { text: "Hello, World!" } invalid_timestamp = "not-a-timestamp" - body = payload.to_json - signing_payload = "#{invalid_timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha256=#{digest}", - "X-HMAC-Timestamp" => invalid_timestamp - } - response = http.post("/webhooks/hmac_with_timestamp", body, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + json_payload = payload.to_json + signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, invalid_timestamp) + headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => invalid_timestamp) + response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "rejects request with timestamp manipulation attack" do payload = { text: "Hello, World!" } - original_timestamp = Time.now.utc.iso8601 - manipulated_timestamp = (Time.now.utc + 100).iso8601 # Future timestamp - - # Create signature with original timestamp - signing_payload = "#{original_timestamp}:#{payload.to_json}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - - # But send manipulated timestamp in header - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha256=#{digest}", - "X-HMAC-Timestamp" => manipulated_timestamp - } + original_timestamp = current_timestamp + manipulated_timestamp = (Time.now.utc + 100).iso8601 + json_payload = payload.to_json - response = http.post("/webhooks/hmac_with_timestamp", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + # Create signature with original timestamp but send manipulated timestamp + signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, original_timestamp) + headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => manipulated_timestamp) + response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "fails because the timestamp is too old" do payload = { text: "Hello, World!" } - # Use timestamp that's 10 minutes old (beyond the tolerance) - expired_timestamp = (Time.now.utc - 600).iso8601 - - signing_payload = "#{expired_timestamp}:#{payload.to_json}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha256=#{digest}", - "X-HMAC-Timestamp" => expired_timestamp - } - - response = http.post("/webhooks/hmac_with_timestamp", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + expired_ts = expired_timestamp + json_payload = payload.to_json + signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, expired_ts) + headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => expired_ts) + response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "fails because the wrong HMAC algorithm is used" do payload = { text: "Hello, World!" } - timestamp = Time.now.utc.iso8601 - body = payload.to_json - signing_payload = "#{timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha512"), FAKE_ALT_HMAC_SECRET, signing_payload) - - headers = { - "Content-Type" => "application/json", - "X-HMAC-Signature" => "sha512=#{digest}", - "X-HMAC-Timestamp" => timestamp - } - - response = http.post("/webhooks/hmac_with_timestamp", body, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + timestamp = current_timestamp + json_payload = payload.to_json + signature = generate_hmac_with_timestamp(json_payload, FAKE_ALT_HMAC_SECRET, timestamp, "sha512") + signature = signature.gsub("sha256=", "sha512=") + headers = json_headers("X-HMAC-Signature" => signature, "X-HMAC-Timestamp" => timestamp) + response = make_request(:post, "/webhooks/hmac_with_timestamp", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end end describe "slack" do it "successfully processes a valid POST request with HMAC signature and timestamp" do payload = { text: "Hello, Slack!" } - timestamp = Time.now.to_i.to_s - body = payload.to_json - signing_payload = "v0:#{timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}", - "X-Timestamp" => timestamp - } - response = http.post("/webhooks/slack", body, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + timestamp = unix_timestamp + json_payload = payload.to_json + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, timestamp) + headers = json_headers("Signature-256" => signature, "X-Timestamp" => timestamp) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end it "rejects request with expired timestamp" do payload = { text: "Hello, Slack!" } - # Use timestamp that's 10 minutes old (beyond the 5 minute tolerance) - expired_timestamp = (Time.now.to_i - 600).to_s - - signing_payload = "v0:#{expired_timestamp}:#{payload.to_json}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}", - "X-Timestamp" => expired_timestamp - } - - response = http.post("/webhooks/slack", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + expired_ts = expired_unix_timestamp + json_payload = payload.to_json + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, expired_ts) + headers = json_headers("Signature-256" => signature, "X-Timestamp" => expired_ts) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "rejects request with missing timestamp header" do payload = { text: "Hello, Slack!" } - timestamp = Time.now.to_i.to_s - - # Create signature with timestamp but don't include timestamp header - signing_payload = "v0:#{timestamp}:#{payload.to_json}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}" - # Missing X-Timestamp header - } - - response = http.post("/webhooks/slack", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + timestamp = unix_timestamp + json_payload = payload.to_json + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, timestamp) + headers = json_headers("Signature-256" => signature) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "rejects request with invalid timestamp format" do payload = { text: "Hello, Slack!" } invalid_timestamp = "not-a-timestamp" - - signing_payload = "v0:#{invalid_timestamp}:#{payload.to_json}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}", - "X-Timestamp" => invalid_timestamp - } - - response = http.post("/webhooks/slack", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + json_payload = payload.to_json + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, invalid_timestamp) + headers = json_headers("Signature-256" => signature, "X-Timestamp" => invalid_timestamp) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "successfully processes request with ISO 8601 UTC timestamp" do payload = { text: "Hello, Slack!" } iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ") - body = payload.to_json - signing_payload = "v0:#{iso_timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}", - "X-Timestamp" => iso_timestamp - } - response = http.post("/webhooks/slack", body, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + json_payload = payload.to_json + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, iso_timestamp) + headers = json_headers("Signature-256" => signature, "X-Timestamp" => iso_timestamp) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end it "successfully processes request with ISO 8601 UTC timestamp (ruby default method)" do payload = { text: "Hello, Slack!" } - iso_timestamp = Time.now.utc.iso8601 - body = payload.to_json - signing_payload = "v0:#{iso_timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}", - "X-Timestamp" => iso_timestamp - } - response = http.post("/webhooks/slack", body, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + iso_timestamp = current_timestamp + json_payload = payload.to_json + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, iso_timestamp) + headers = json_headers("Signature-256" => signature, "X-Timestamp" => iso_timestamp) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end it "successfully processes request with ISO 8601 UTC timestamp using +00:00 format" do payload = { text: "Hello, Slack!" } iso_timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S+00:00") - body = payload.to_json - signing_payload = "v0:#{iso_timestamp}:#{body}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}", - "X-Timestamp" => iso_timestamp - } - response = http.post("/webhooks/slack", body, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + json_payload = payload.to_json + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, iso_timestamp) + headers = json_headers("Signature-256" => signature, "X-Timestamp" => iso_timestamp) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end it "rejects request with non-UTC ISO 8601 timestamp" do payload = { text: "Hello, Slack!" } - # Use EST timezone (non-UTC) non_utc_timestamp = Time.now.strftime("%Y-%m-%dT%H:%M:%S-05:00") - - signing_payload = "v0:#{non_utc_timestamp}:#{payload.to_json}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}", - "X-Timestamp" => non_utc_timestamp - } - - response = http.post("/webhooks/slack", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + json_payload = payload.to_json + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, non_utc_timestamp) + headers = json_headers("Signature-256" => signature, "X-Timestamp" => non_utc_timestamp) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "rejects request with timestamp manipulation attack" do payload = { text: "Hello, Slack!" } - original_timestamp = Time.now.to_i.to_s - manipulated_timestamp = (Time.now.to_i + 100).to_s # Future timestamp - - # Create signature with original timestamp - signing_payload = "v0:#{original_timestamp}:#{payload.to_json}" - digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_ALT_HMAC_SECRET, signing_payload) - - # But send manipulated timestamp in header - headers = { - "Content-Type" => "application/json", - "Signature-256" => "v0=#{digest}", - "X-Timestamp" => manipulated_timestamp - } + original_timestamp = unix_timestamp + manipulated_timestamp = (Time.now.to_i + 100).to_s + json_payload = payload.to_json - response = http.post("/webhooks/slack", payload.to_json, headers) - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + # Create signature with original timestamp but send manipulated timestamp + signature = generate_slack_signature(json_payload, FAKE_ALT_HMAC_SECRET, original_timestamp) + headers = json_headers("Signature-256" => signature, "X-Timestamp" => manipulated_timestamp) + response = make_request(:post, "/webhooks/slack", json_payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end end describe "okta" do it "receives a POST request but contains an invalid shared secret" do payload = { event: "user.login", user: { id: "12345" } } - headers = { "Content-Type" => "application/json", "Authorization" => "badvalue" } - response = http.post("/webhooks/okta", payload.to_json, headers) - - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + headers = json_headers("Authorization" => "badvalue") + response = make_request(:post, "/webhooks/okta", payload.to_json, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "successfully processes a valid POST request with shared secret" do payload = { event: "user.login", user: { id: "12345" } } - headers = { "Content-Type" => "application/json", "Authorization" => FAKE_SHARED_SECRET } - response = http.post("/webhooks/okta", payload.to_json, headers) - - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + headers = json_headers("Authorization" => FAKE_SHARED_SECRET) + response = make_request(:post, "/webhooks/okta", payload.to_json, headers) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("success") end end @@ -464,10 +389,10 @@ it "successfully validates using a custom auth plugin" do payload = {}.to_json headers = { "Authorization" => "Bearer octoawesome-shared-secret" } - response = http.post("/webhooks/with_custom_auth_plugin", payload, headers) + response = make_request(:post, "/webhooks/with_custom_auth_plugin", payload, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["status"]).to eq("test_success") expect(body["handler"]).to eq("TestHandler") end @@ -475,19 +400,15 @@ it "rejects requests with invalid credentials using custom auth plugin" do payload = {}.to_json headers = { "Authorization" => "Bearer wrong-secret" } - response = http.post("/webhooks/with_custom_auth_plugin", payload, headers) - - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + response = make_request(:post, "/webhooks/with_custom_auth_plugin", payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end it "rejects requests with missing credentials using custom auth plugin" do payload = {}.to_json headers = {} - response = http.post("/webhooks/with_custom_auth_plugin", payload, headers) - - expect(response).to be_a(Net::HTTPUnauthorized) - expect(response.body).to include("authentication failed") + response = make_request(:post, "/webhooks/with_custom_auth_plugin", payload, headers) + expect_response(response, Net::HTTPUnauthorized, "authentication failed") end end @@ -495,10 +416,8 @@ it "sends a POST request to the /webhooks/boomtown endpoint and it explodes" do payload = {}.to_json headers = {} - response = http.post("/webhooks/boomtown", payload, headers) - - expect(response).to be_a(Net::HTTPInternalServerError) - expect(response.body).to include("Boomtown error occurred") + response = make_request(:post, "/webhooks/boomtown", payload, headers) + expect_response(response, Net::HTTPInternalServerError, "Boomtown error occurred") end end @@ -506,27 +425,25 @@ it "sends a POST request to the /webhooks/okta_webhook_setup endpoint and it fails because it is not a GET" do payload = {}.to_json headers = {} - response = http.post("/webhooks/okta_webhook_setup", payload, headers) - - expect(response).to be_a(Net::HTTPMethodNotAllowed) - expect(response.body).to include("405 Not Allowed") + response = make_request(:post, "/webhooks/okta_webhook_setup", payload, headers) + expect_response(response, Net::HTTPMethodNotAllowed, "405 Not Allowed") end it "sends a GET request to the /webhooks/okta_webhook_setup endpoint and it returns the verification challenge" do headers = { "x-okta-verification-challenge" => "test-challenge" } - response = http.get("/webhooks/okta_webhook_setup", headers) + response = make_request(:get, "/webhooks/okta_webhook_setup", nil, headers) - expect(response).to be_a(Net::HTTPSuccess) - body = JSON.parse(response.body) + expect_response(response, Net::HTTPSuccess) + body = parse_json_response(response) expect(body["verification"]).to eq("test-challenge") end it "sends a GET request to the /webhooks/okta_webhook_setup endpoint but it is missing the verification challenge header" do - response = http.get("/webhooks/okta_webhook_setup") + response = make_request(:get, "/webhooks/okta_webhook_setup") - expect(response).to be_a(Net::HTTPSuccess) + expect_response(response, Net::HTTPSuccess) expect(response.code).to eq("200") - body = JSON.parse(response.body) + body = parse_json_response(response) expect(body["error"]).to eq("Missing verification challenge header") expect(body["expected_header"]).to eq("x-okta-verification-challenge") end