Skip to content

Commit 19bc900

Browse files
committed
working github webhook auth
1 parent 6bf9d0f commit 19bc900

File tree

11 files changed

+257
-160
lines changed

11 files changed

+257
-160
lines changed

lib/hooks/app/api.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,9 @@ def enforce_request_limits(config)
5757
end
5858

5959
# Verify the incoming request
60-
def request_validator(payload, headers, endpoint_config)
60+
def validate_request(payload, headers, endpoint_config)
6161
request_validator_config = endpoint_config[:request_validator]
62-
validator_type = request_validator_config[:type] || "default"
62+
validator_type = request_validator_config[:type]
6363
secret_env_key = request_validator_config[:secret_env_key]
6464

6565
return unless secret_env_key
@@ -115,8 +115,7 @@ def load_handler(handler_class_name, handler_dir)
115115
require file_path
116116
Object.const_get(handler_class_name).new
117117
else
118-
# Create a default handler for POC
119-
DefaultHandler.new
118+
raise LoadError, "Handler #{handler_class_name} not found at #{file_path}"
120119
end
121120
rescue => e
122121
error!("failed to load handler #{handler_class_name}: #{e.message}", 500)
@@ -193,6 +192,9 @@ def determine_error_code(exception)
193192
raw_body = request.body.read
194193

195194
# Verify/validate request if configured
195+
logger.info "validating request (id: #{request_id}, handler: #{handler_class_name})"
196+
logger.debug "raw body: #{raw_body.inspect}"
197+
logger.debug "headers: #{headers.inspect}"
196198
validate_request(raw_body, headers, endpoint_config) if endpoint_config[:request_validator]
197199

198200
# Parse payload

lib/hooks/plugins/request_validator/github_webhooks.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def self.valid?(payload:, headers:, secret:, config:)
2626
signature_header = config.dig(:request_validator, :header) || "X-Hub-Signature-256"
2727
algorithm = config.dig(:request_validator, :algorithm) || "sha256"
2828

29+
signature_header = Utils::Normalize.header(signature_header)
30+
headers = Utils::Normalize.headers(headers)
31+
2932
provided_signature = headers[signature_header]
3033
return false if provided_signature.nil? || provided_signature.empty?
3134

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# frozen_string_literal: true
2+
3+
module Hooks
4+
module Plugins
5+
module Utils
6+
# Utility class for normalizing HTTP headers
7+
#
8+
# Provides a robust method to consistently format HTTP headers
9+
# across the application, handling various edge cases and formats.
10+
class Normalize
11+
# Normalize a hash of HTTP headers
12+
#
13+
# @param headers [Hash, #each] Headers hash or hash-like object
14+
# @return [Hash] Normalized headers hash with downcased keys and trimmed values
15+
#
16+
# @example Hash of headers normalization
17+
# headers = { "Content-Type" => " application/json ", "X-GitHub-Event" => "push" }
18+
# normalized = Normalize.headers(headers)
19+
# # => { "content-type" => "application/json", "x-github-event" => "push" }
20+
#
21+
# @example Handle various input types
22+
# Normalize.headers(nil) # => nil
23+
# Normalize.headers({}) # => {}
24+
# Normalize.headers({ "KEY" => ["a", "b"] }) # => { "key" => "a" }
25+
# Normalize.headers({ "Key" => 123 }) # => { "key" => "123" }
26+
def self.headers(headers)
27+
# Handle nil input
28+
return nil if headers.nil?
29+
30+
# Fast path for non-enumerable inputs (numbers, etc.)
31+
return {} unless headers.respond_to?(:each)
32+
33+
normalized = {}
34+
35+
headers.each do |key, value|
36+
# Skip nil keys or values entirely
37+
next if key.nil? || value.nil?
38+
39+
# Convert key to string, downcase, and strip in one operation
40+
normalized_key = key.to_s.downcase.strip
41+
next if normalized_key.empty?
42+
43+
# Handle different value types efficiently
44+
normalized_value = case value
45+
when String
46+
value.strip
47+
when Array
48+
# Take first non-empty element for multi-value headers
49+
first_valid = value.find { |v| v && !v.to_s.strip.empty? }
50+
first_valid ? first_valid.to_s.strip : nil
51+
else
52+
value.to_s.strip
53+
end
54+
55+
# Only add if we have a non-empty value
56+
normalized[normalized_key] = normalized_value if normalized_value && !normalized_value.empty?
57+
end
58+
59+
normalized
60+
end
61+
62+
# Normalize a single HTTP header name
63+
#
64+
# @param header [String] Header name to normalize
65+
# @return [String, nil] Normalized header name (downcased and trimmed), or nil if input is nil
66+
#
67+
# @example Single header normalization
68+
# Normalize.header(" Content-Type ") # => "content-type"
69+
# Normalize.header("X-GitHub-Event") # => "x-github-event"
70+
# Normalize.header("") # => ""
71+
# Normalize.header(nil) # => nil
72+
#
73+
# @raise [ArgumentError] If input is not a String or nil
74+
def self.header(header)
75+
return nil if header.nil?
76+
if header.is_a?(String)
77+
header.downcase.strip
78+
else
79+
raise ArgumentError, "Expected a String for header normalization"
80+
end
81+
end
82+
end
83+
end
84+
end
85+
end

script/server

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
#! /usr/bin/env bash
22

3-
# usage: script/server [--dev]
3+
# usage: script/server
44

55
set -e
66

77
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd )"
88
cd "$DIR"
99

10-
# check for --dev flag
11-
if [[ "$1" == "--dev" ]]; then
12-
source script/dev
13-
shift # remove the --dev flag from the arguments
14-
fi
15-
1610
bundle exec puma -C spec/acceptance/config/puma.rb --tag hooks

spec/acceptance/acceptance_tests.rb

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require_relative "../spec_helper"
4+
35
require "rspec"
46
require "net/http"
57
require "json"
@@ -69,5 +71,36 @@
6971
expect(body).to have_key("timestamp")
7072
end
7173
end
74+
75+
describe "github" do
76+
it "receives a POST request but contains an invalid HMAC signature" do
77+
payload = { action: "push", repository: { name: "test-repo" } }
78+
headers = { "Content-Type" => "application/json", "X-Hub-Signature-256" => "sha256=invalidsignature" }
79+
response = http.post("/webhooks/github", payload.to_json, headers)
80+
81+
expect(response).to be_a(Net::HTTPUnauthorized)
82+
expect(response.body).to include("request validation failed")
83+
end
84+
85+
it "receives a POST request but there is no HMAC related header" do
86+
payload = { action: "push", repository: { name: "test-repo" } }
87+
headers = { "Content-Type" => "application/json" }
88+
response = http.post("/webhooks/github", payload.to_json, headers)
89+
expect(response).to be_a(Net::HTTPUnauthorized)
90+
expect(response.body).to include("request validation failed")
91+
end
92+
93+
it "successfully processes a valid POST request with HMAC signature" do
94+
payload = { action: "push", repository: { name: "test-repo" } }
95+
headers = {
96+
"Content-Type" => "application/json",
97+
"X-Hub-Signature-256" => "sha256=" + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), FAKE_HMAC_SECRET, payload.to_json)
98+
}
99+
response = http.post("/webhooks/github", payload.to_json, headers)
100+
expect(response).to be_a(Net::HTTPSuccess)
101+
body = JSON.parse(response.body)
102+
expect(body["status"]).to eq("success")
103+
end
104+
end
72105
end
73106
end

spec/acceptance/config/endpoints/github.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Sample endpoint configuration for GitHub webhooks
22
path: /github
3-
handler: GitHubHandler
3+
handler: GithubHandler
44

55
# GitHub uses HMAC SHA256 signature validation
66
request_validator:

spec/acceptance/config/hooks.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Sample configuration for Hooks webhook server
22
handler_dir: ./spec/acceptance/handlers
3-
log_level: info
3+
log_level: debug
44

55
# Request handling
66
request_limit: 1048576 # 1MB max body size

spec/acceptance/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ services:
88
- "8080:8080"
99
environment:
1010
LOG_LEVEL: DEBUG
11+
GITHUB_WEBHOOK_SECRET: "octoawesome-secret"
1112
command: ["script/server"]
1213
healthcheck:
1314
test: ["CMD", "curl", "-f", "http://0.0.0.0:8080/health"]
Lines changed: 3 additions & 147 deletions
Original file line numberDiff line numberDiff line change
@@ -1,160 +1,16 @@
11
# frozen_string_literal: true
22

33
# Example handler for GitHub webhooks
4-
class GitHubHandler < Hooks::Handlers::Base
4+
class GithubHandler < Hooks::Handlers::Base
55
# Process GitHub webhook
66
#
77
# @param payload [Hash, String] GitHub webhook payload
88
# @param headers [Hash<String, String>] HTTP headers
99
# @param config [Hash] Endpoint configuration
1010
# @return [Hash] Response data
1111
def call(payload:, headers:, config:)
12-
# GitHub sends event type in header
13-
event_type = headers["X-GitHub-Event"] || "unknown"
14-
15-
puts "GitHubHandler: Received #{event_type} event"
16-
17-
return handle_raw_payload(payload, config) unless payload.is_a?(Hash)
18-
19-
case event_type
20-
when "push"
21-
handle_push_event(payload, config)
22-
when "pull_request"
23-
handle_pull_request_event(payload, config)
24-
when "issues"
25-
handle_issues_event(payload, config)
26-
when "ping"
27-
handle_ping_event(payload, config)
28-
else
29-
handle_unknown_event(payload, event_type, config)
30-
end
31-
end
32-
33-
private
34-
35-
# Handle raw string payload
36-
#
37-
# @param payload [String] Raw payload
38-
# @param config [Hash] Configuration
39-
# @return [Hash] Response
40-
def handle_raw_payload(payload, config)
41-
{
42-
status: "raw_payload_processed",
43-
handler: "GitHubHandler",
44-
payload_size: payload.length,
45-
repository: config.dig(:opts, :repository),
46-
timestamp: Time.now.iso8601
47-
}
48-
end
49-
50-
# Handle push events
51-
#
52-
# @param payload [Hash] Push event payload
53-
# @param config [Hash] Configuration
54-
# @return [Hash] Response
55-
def handle_push_event(payload, config)
56-
ref = payload["ref"]
57-
branch = ref&.split("/")&.last
58-
commits_count = payload.dig("commits")&.length || 0
59-
60-
# Check if branch is in filter
61-
branch_filter = config.dig(:opts, :branch_filter)
62-
if branch_filter && !branch_filter.include?(branch)
63-
return {
64-
status: "ignored",
65-
handler: "GitHubHandler",
66-
reason: "branch_not_in_filter",
67-
branch: branch,
68-
filter: branch_filter,
69-
timestamp: Time.now.iso8601
70-
}
71-
end
72-
73-
{
74-
status: "push_processed",
75-
handler: "GitHubHandler",
76-
repository: payload.dig("repository", "full_name"),
77-
branch: branch,
78-
commits_count: commits_count,
79-
pusher: payload.dig("pusher", "name"),
80-
timestamp: Time.now.iso8601
81-
}
82-
end
83-
84-
# Handle pull request events
85-
#
86-
# @param payload [Hash] Pull request event payload
87-
# @param config [Hash] Configuration
88-
# @return [Hash] Response
89-
def handle_pull_request_event(payload, config)
90-
action = payload["action"]
91-
pr_number = payload.dig("pull_request", "number")
92-
pr_title = payload.dig("pull_request", "title")
93-
94-
{
95-
status: "pull_request_processed",
96-
handler: "GitHubHandler",
97-
action: action,
98-
repository: payload.dig("repository", "full_name"),
99-
pr_number: pr_number,
100-
pr_title: pr_title,
101-
author: payload.dig("pull_request", "user", "login"),
102-
timestamp: Time.now.iso8601
103-
}
104-
end
105-
106-
# Handle issues events
107-
#
108-
# @param payload [Hash] Issues event payload
109-
# @param config [Hash] Configuration
110-
# @return [Hash] Response
111-
def handle_issues_event(payload, config)
112-
action = payload["action"]
113-
issue_number = payload.dig("issue", "number")
114-
issue_title = payload.dig("issue", "title")
115-
116-
{
117-
status: "issue_processed",
118-
handler: "GitHubHandler",
119-
action: action,
120-
repository: payload.dig("repository", "full_name"),
121-
issue_number: issue_number,
122-
issue_title: issue_title,
123-
author: payload.dig("issue", "user", "login"),
124-
timestamp: Time.now.iso8601
125-
}
126-
end
127-
128-
# Handle ping events (webhook test)
129-
#
130-
# @param payload [Hash] Ping event payload
131-
# @param config [Hash] Configuration
132-
# @return [Hash] Response
133-
def handle_ping_event(payload, config)
134-
{
135-
status: "ping_acknowledged",
136-
handler: "GitHubHandler",
137-
repository: payload.dig("repository", "full_name"),
138-
hook_id: payload.dig("hook", "id"),
139-
zen: payload["zen"],
140-
timestamp: Time.now.iso8601
141-
}
142-
end
143-
144-
# Handle unknown events
145-
#
146-
# @param payload [Hash] Event payload
147-
# @param event_type [String] Event type
148-
# @param config [Hash] Configuration
149-
# @return [Hash] Response
150-
def handle_unknown_event(payload, event_type, config)
151-
{
152-
status: "unknown_event_processed",
153-
handler: "GitHubHandler",
154-
event_type: event_type,
155-
repository: payload.dig("repository", "full_name"),
156-
supported_events: config.dig(:opts, :events),
157-
timestamp: Time.now.iso8601
12+
return {
13+
status: "success"
15814
}
15915
end
16016
end

0 commit comments

Comments
 (0)