Skip to content

Commit 90496ef

Browse files
committed
making hmac generic and not github specific
1 parent bd21031 commit 90496ef

File tree

6 files changed

+205
-66
lines changed

6 files changed

+205
-66
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ bin/*
55
coverage/
66
logs/
77
tmp/
8+
spec/integration/tmp/
89
tarballs/
910
vendor/gems/
1011
.idea

lib/hooks/app/api.rb

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def enforce_request_limits(config)
5959
# Verify the incoming request
6060
def validate_request(payload, headers, endpoint_config)
6161
request_validator_config = endpoint_config[:request_validator]
62-
validator_type = request_validator_config[:type]
62+
validator_type = request_validator_config[:type].downcase
6363
secret_env_key = request_validator_config[:secret_env_key]
6464

6565
return unless secret_env_key
@@ -72,8 +72,8 @@ def validate_request(payload, headers, endpoint_config)
7272
validator_class = nil
7373

7474
case validator_type
75-
when "GitHubWebhooks"
76-
validator_class = Plugins::RequestValidator::GitHubWebhooks
75+
when "hmac"
76+
validator_class = Plugins::RequestValidator::HMAC
7777
else
7878
error!("Custom validators not implemented in POC", 500)
7979
end
@@ -196,7 +196,7 @@ def determine_error_code(exception)
196196
raw_body = request.body.read
197197

198198
# Verify/validate request if configured
199-
log.info "validating request (id: #{request_id}, handler: #{handler_class_name})"
199+
log.info "validating request (id: #{request_id}, handler: #{handler_class_name})" if endpoint_config[:request_validator]
200200
validate_request(raw_body, headers, endpoint_config) if endpoint_config[:request_validator]
201201

202202
# Parse payload (symbolize_payload is true by default)

lib/hooks/plugins/request_validator/github_webhooks.rb

Lines changed: 0 additions & 51 deletions
This file was deleted.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# frozen_string_literal: true
2+
3+
require "openssl"
4+
require "rack/utils"
5+
require "time"
6+
require_relative "base"
7+
8+
module Hooks
9+
module Plugins
10+
module RequestValidator
11+
# Generic HMAC signature validator for webhooks
12+
#
13+
# This validator supports multiple webhook providers with different signature formats:
14+
# - GitHub: X-Hub-Signature-256: sha256=abc123...
15+
# - Shopify: X-Shopify-Hmac-Sha256: abc123... (hash only)
16+
# - Slack: X-Slack-Signature: v0=abc123... (with timestamp validation)
17+
# - And any other HMAC-based webhook provider
18+
#
19+
# @example Basic GitHub-style configuration
20+
# request_validator:
21+
# type: HMAC
22+
# secret_env_key: WEBHOOK_SECRET
23+
# header: X-Hub-Signature-256
24+
# algorithm: sha256
25+
# format: "algorithm=signature"
26+
#
27+
# @example Slack-style with timestamp validation
28+
# request_validator:
29+
# type: HMAC
30+
# secret_env_key: SLACK_SIGNING_SECRET
31+
# header: X-Slack-Signature
32+
# timestamp_header: X-Slack-Request-Timestamp
33+
# timestamp_tolerance: 300 # 5 minutes
34+
# algorithm: sha256
35+
# format: "version=signature"
36+
# version_prefix: "v0"
37+
# payload_template: "{version}:{timestamp}:{body}"
38+
class HMAC < Base
39+
# Default configuration values
40+
DEFAULT_CONFIG = {
41+
algorithm: "sha256",
42+
format: "algorithm=signature", # GitHub default
43+
timestamp_tolerance: 300, # 5 minutes for Slack
44+
version_prefix: "v0" # Slack default
45+
}.freeze
46+
47+
# Supported signature formats
48+
FORMATS = {
49+
"algorithm=signature" => :github_style, # "sha256=abc123..."
50+
"signature_only" => :shopify_style, # "abc123..."
51+
"version=signature" => :slack_style # "v0=abc123..."
52+
}.freeze
53+
54+
# Validate HMAC signature from webhook requests
55+
#
56+
# @param payload [String] Raw request body
57+
# @param headers [Hash<String, String>] HTTP headers
58+
# @param secret [String] Secret key for HMAC validation
59+
# @param config [Hash] Endpoint configuration with signature settings
60+
# @return [Boolean] true if signature is valid
61+
def self.valid?(payload:, headers:, secret:, config:)
62+
return false if secret.nil? || secret.empty?
63+
64+
validator_config = build_config(config)
65+
normalized_headers = normalize_headers(headers)
66+
67+
# Get signature from headers
68+
signature_header = validator_config[:header]
69+
provided_signature = normalized_headers[signature_header.downcase]
70+
return false if provided_signature.nil? || provided_signature.empty?
71+
72+
# Validate timestamp if required (for Slack and others)
73+
if validator_config[:timestamp_header]
74+
return false unless valid_timestamp?(normalized_headers, validator_config)
75+
end
76+
77+
# Compute expected signature
78+
computed_signature = compute_signature(
79+
payload: payload,
80+
headers: normalized_headers,
81+
secret: secret,
82+
config: validator_config
83+
)
84+
85+
# Use secure comparison to prevent timing attacks
86+
Rack::Utils.secure_compare(computed_signature, provided_signature)
87+
rescue StandardError => _e
88+
# Log error in production - for now just return false
89+
false
90+
end
91+
92+
private
93+
94+
# Build final configuration by merging defaults with provided config
95+
def self.build_config(config)
96+
validator_config = config.dig(:request_validator) || {}
97+
98+
DEFAULT_CONFIG.merge({
99+
header: validator_config[:header] || "X-Signature",
100+
timestamp_header: validator_config[:timestamp_header],
101+
timestamp_tolerance: validator_config[:timestamp_tolerance] || DEFAULT_CONFIG[:timestamp_tolerance],
102+
algorithm: validator_config[:algorithm] || DEFAULT_CONFIG[:algorithm],
103+
format: validator_config[:format] || DEFAULT_CONFIG[:format],
104+
version_prefix: validator_config[:version_prefix] || DEFAULT_CONFIG[:version_prefix],
105+
payload_template: validator_config[:payload_template]
106+
})
107+
end
108+
109+
# Normalize headers using the Utils::Normalize class
110+
def self.normalize_headers(headers)
111+
Utils::Normalize.headers(headers) || {}
112+
end
113+
114+
# Validate timestamp if timestamp validation is configured
115+
def self.valid_timestamp?(headers, config)
116+
timestamp_header = config[:timestamp_header].downcase
117+
timestamp_value = headers[timestamp_header]
118+
119+
return false unless timestamp_value
120+
121+
timestamp = timestamp_value.to_i
122+
current_time = Time.now.to_i
123+
tolerance = config[:timestamp_tolerance]
124+
125+
(current_time - timestamp).abs <= tolerance
126+
end
127+
128+
# Compute HMAC signature based on provider requirements
129+
def self.compute_signature(payload:, headers:, secret:, config:)
130+
# Determine what to sign based on payload template
131+
signing_payload = build_signing_payload(
132+
payload: payload,
133+
headers: headers,
134+
config: config
135+
)
136+
137+
# Compute HMAC hash
138+
algorithm = config[:algorithm]
139+
computed_hash = OpenSSL::HMAC.hexdigest(
140+
OpenSSL::Digest.new(algorithm),
141+
secret,
142+
signing_payload
143+
)
144+
145+
# Format according to provider requirements
146+
format_signature(computed_hash, config)
147+
end
148+
149+
# Build the payload string to sign (handles Slack's special requirements)
150+
def self.build_signing_payload(payload:, headers:, config:)
151+
template = config[:payload_template]
152+
153+
if template
154+
# Slack-style: "v0:timestamp:body"
155+
timestamp = headers[config[:timestamp_header].downcase]
156+
template
157+
.gsub("{version}", config[:version_prefix])
158+
.gsub("{timestamp}", timestamp.to_s)
159+
.gsub("{body}", payload)
160+
else
161+
# Standard: just the payload
162+
payload
163+
end
164+
end
165+
166+
# Format the computed signature based on provider requirements
167+
def self.format_signature(hash, config)
168+
format_style = FORMATS[config[:format]]
169+
170+
case format_style
171+
when :github_style
172+
# GitHub: "sha256=abc123..."
173+
"#{config[:algorithm]}=#{hash}"
174+
when :shopify_style
175+
# Shopify: just the hash
176+
hash
177+
when :slack_style
178+
# Slack: "v0=abc123..."
179+
"#{config[:version_prefix]}=#{hash}"
180+
else
181+
# Default to GitHub style
182+
"#{config[:algorithm]}=#{hash}"
183+
end
184+
end
185+
end
186+
end
187+
end
188+
end

spec/acceptance/config/endpoints/github.yaml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ handler: GithubHandler
44

55
# GitHub uses HMAC SHA256 signature validation
66
request_validator:
7-
type: GitHubWebhooks
7+
type: hmac
88
secret_env_key: GITHUB_WEBHOOK_SECRET
9-
# header: X-Hub-Signature-256 # Optional, defaults to X-Hub-Signature-256
10-
# algorithm: sha256 # Optional, defaults to sha256
9+
header: X-Hub-Signature-256
10+
algorithm: sha256
11+
format: "algorithm=signature" # produces "sha256=abc123..."
1112

1213
# Options for GitHub webhook handling
1314
opts:

spec/integration/hooks_integration_spec.rb

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,33 +12,33 @@
1212
def app
1313
@app ||= Hooks.build(
1414
config: {
15-
handler_dir: "./handlers",
15+
handler_dir: "./spec/integration/tmp/handlers",
1616
log_level: "error", # Reduce noise in tests
1717
request_limit: 1048576,
1818
request_timeout: 15,
1919
root_path: "/webhooks",
2020
health_path: "/health",
2121
version_path: "/version",
2222
environment: "development",
23-
endpoints_dir: "./spec/fixtures/endpoints",
23+
endpoints_dir: "./spec/integration/tmp/endpoints",
2424
use_catchall_route: true # Enable catch-all route for testing
2525
}
2626
)
2727
end
2828

2929
before(:all) do
3030
# Create test endpoint config
31-
FileUtils.mkdir_p("./spec/fixtures/endpoints")
32-
File.write("./spec/fixtures/endpoints/test.yaml", {
31+
FileUtils.mkdir_p("./spec/integration/tmp/endpoints")
32+
File.write("./spec/integration/tmp/endpoints/test.yaml", {
3333
path: "/test",
3434
handler: "TestHandler",
3535
opts: { test_mode: true }
3636
}.to_yaml)
3737

3838
# Create test handler
39-
FileUtils.mkdir_p("./handlers")
40-
File.write("./handlers/test_handler.rb", <<~RUBY)
41-
require_relative "../lib/hooks/handlers/base"
39+
FileUtils.mkdir_p("./spec/integration/tmp/handlers")
40+
File.write("./spec/integration/tmp/handlers/test_handler.rb", <<~RUBY)
41+
require_relative "../../../../lib/hooks/handlers/base"
4242
4343
class TestHandler < Hooks::Handlers::Base
4444
def call(payload:, headers:, config:)
@@ -55,7 +55,7 @@ def call(payload:, headers:, config:)
5555

5656
after(:all) do
5757
# Clean up test files
58-
FileUtils.rm_rf("./spec/fixtures")
58+
FileUtils.rm_rf("./spec/integration/tmp")
5959
end
6060

6161
describe "operational endpoints" do

0 commit comments

Comments
 (0)