hooks-ruby 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +295 -0
- data/bin/bundle +109 -0
- data/bin/erb +27 -0
- data/bin/hooks +27 -0
- data/bin/htmldiff +27 -0
- data/bin/irb +27 -0
- data/bin/ldiff +27 -0
- data/bin/puma +27 -0
- data/bin/pumactl +27 -0
- data/bin/racc +27 -0
- data/bin/rdoc +27 -0
- data/bin/ri +27 -0
- data/bin/rspec +27 -0
- data/bin/rubocop +27 -0
- data/bin/ruby-parse +27 -0
- data/bin/ruby-rewrite +27 -0
- data/bin/rubygems-await +27 -0
- data/bin/sigstore-cli +27 -0
- data/bin/thor +27 -0
- data/config.ru +6 -0
- data/hooks.gemspec +36 -0
- data/lib/hooks/app/api.rb +112 -0
- data/lib/hooks/app/auth/auth.rb +48 -0
- data/lib/hooks/app/endpoints/catchall.rb +84 -0
- data/lib/hooks/app/endpoints/health.rb +20 -0
- data/lib/hooks/app/endpoints/version.rb +18 -0
- data/lib/hooks/app/helpers.rb +95 -0
- data/lib/hooks/core/builder.rb +97 -0
- data/lib/hooks/core/config_loader.rb +152 -0
- data/lib/hooks/core/config_validator.rb +131 -0
- data/lib/hooks/core/log.rb +9 -0
- data/lib/hooks/core/logger_factory.rb +99 -0
- data/lib/hooks/core/plugin_loader.rb +250 -0
- data/lib/hooks/plugins/auth/base.rb +62 -0
- data/lib/hooks/plugins/auth/hmac.rb +313 -0
- data/lib/hooks/plugins/auth/shared_secret.rb +115 -0
- data/lib/hooks/plugins/handlers/base.rb +35 -0
- data/lib/hooks/plugins/handlers/default.rb +21 -0
- data/lib/hooks/plugins/lifecycle.rb +33 -0
- data/lib/hooks/security.rb +17 -0
- data/lib/hooks/utils/normalize.rb +83 -0
- data/lib/hooks/version.rb +5 -0
- data/lib/hooks.rb +29 -0
- metadata +189 -0
@@ -0,0 +1,250 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pathname"
|
4
|
+
require_relative "../security"
|
5
|
+
|
6
|
+
module Hooks
|
7
|
+
module Core
|
8
|
+
# Loads and caches all plugins (auth + handlers) at boot time
|
9
|
+
class PluginLoader
|
10
|
+
# Class-level registries for loaded plugins
|
11
|
+
@auth_plugins = {}
|
12
|
+
@handler_plugins = {}
|
13
|
+
|
14
|
+
class << self
|
15
|
+
attr_reader :auth_plugins, :handler_plugins
|
16
|
+
|
17
|
+
# Load all plugins at boot time
|
18
|
+
#
|
19
|
+
# @param config [Hash] Global configuration containing plugin directories
|
20
|
+
# @return [void]
|
21
|
+
def load_all_plugins(config)
|
22
|
+
# Clear existing registries
|
23
|
+
@auth_plugins = {}
|
24
|
+
@handler_plugins = {}
|
25
|
+
|
26
|
+
# Load built-in plugins first
|
27
|
+
load_builtin_plugins
|
28
|
+
|
29
|
+
# Load custom plugins if directories are configured
|
30
|
+
load_custom_auth_plugins(config[:auth_plugin_dir]) if config[:auth_plugin_dir]
|
31
|
+
load_custom_handler_plugins(config[:handler_plugin_dir]) if config[:handler_plugin_dir]
|
32
|
+
|
33
|
+
# Log loaded plugins
|
34
|
+
log_loaded_plugins
|
35
|
+
end
|
36
|
+
|
37
|
+
# Get auth plugin class by name
|
38
|
+
#
|
39
|
+
# @param plugin_name [String] Name of the auth plugin (e.g., "hmac", "shared_secret", "custom_auth")
|
40
|
+
# @return [Class] The auth plugin class
|
41
|
+
# @raise [StandardError] if plugin not found
|
42
|
+
def get_auth_plugin(plugin_name)
|
43
|
+
plugin_key = plugin_name.downcase
|
44
|
+
plugin_class = @auth_plugins[plugin_key]
|
45
|
+
|
46
|
+
unless plugin_class
|
47
|
+
raise StandardError, "Auth plugin '#{plugin_name}' not found. Available plugins: #{@auth_plugins.keys.join(', ')}"
|
48
|
+
end
|
49
|
+
|
50
|
+
plugin_class
|
51
|
+
end
|
52
|
+
|
53
|
+
# Get handler plugin class by name
|
54
|
+
#
|
55
|
+
# @param handler_name [String] Name of the handler (e.g., "DefaultHandler", "Team1Handler")
|
56
|
+
# @return [Class] The handler plugin class
|
57
|
+
# @raise [StandardError] if handler not found
|
58
|
+
def get_handler_plugin(handler_name)
|
59
|
+
plugin_class = @handler_plugins[handler_name]
|
60
|
+
|
61
|
+
unless plugin_class
|
62
|
+
raise StandardError, "Handler plugin '#{handler_name}' not found. Available handlers: #{@handler_plugins.keys.join(', ')}"
|
63
|
+
end
|
64
|
+
|
65
|
+
plugin_class
|
66
|
+
end
|
67
|
+
|
68
|
+
# Clear all loaded plugins (for testing purposes)
|
69
|
+
#
|
70
|
+
# @return [void]
|
71
|
+
def clear_plugins
|
72
|
+
@auth_plugins = {}
|
73
|
+
@handler_plugins = {}
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
# Load built-in plugins into registries
|
79
|
+
#
|
80
|
+
# @return [void]
|
81
|
+
def load_builtin_plugins
|
82
|
+
# Load built-in auth plugins
|
83
|
+
@auth_plugins["hmac"] = Hooks::Plugins::Auth::HMAC
|
84
|
+
@auth_plugins["shared_secret"] = Hooks::Plugins::Auth::SharedSecret
|
85
|
+
|
86
|
+
# Load built-in handler plugins
|
87
|
+
@handler_plugins["DefaultHandler"] = DefaultHandler
|
88
|
+
end
|
89
|
+
|
90
|
+
# Load custom auth plugins from directory
|
91
|
+
#
|
92
|
+
# @param auth_plugin_dir [String] Directory containing custom auth plugins
|
93
|
+
# @return [void]
|
94
|
+
def load_custom_auth_plugins(auth_plugin_dir)
|
95
|
+
return unless auth_plugin_dir && Dir.exist?(auth_plugin_dir)
|
96
|
+
|
97
|
+
Dir.glob(File.join(auth_plugin_dir, "*.rb")).sort.each do |file_path|
|
98
|
+
begin
|
99
|
+
load_custom_auth_plugin(file_path, auth_plugin_dir)
|
100
|
+
rescue => e
|
101
|
+
raise StandardError, "Failed to load auth plugin from #{file_path}: #{e.message}"
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
# Load custom handler plugins from directory
|
107
|
+
#
|
108
|
+
# @param handler_plugin_dir [String] Directory containing custom handler plugins
|
109
|
+
# @return [void]
|
110
|
+
def load_custom_handler_plugins(handler_plugin_dir)
|
111
|
+
return unless handler_plugin_dir && Dir.exist?(handler_plugin_dir)
|
112
|
+
|
113
|
+
Dir.glob(File.join(handler_plugin_dir, "*.rb")).sort.each do |file_path|
|
114
|
+
begin
|
115
|
+
load_custom_handler_plugin(file_path, handler_plugin_dir)
|
116
|
+
rescue => e
|
117
|
+
raise StandardError, "Failed to load handler plugin from #{file_path}: #{e.message}"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Load a single custom auth plugin file
|
123
|
+
#
|
124
|
+
# @param file_path [String] Path to the auth plugin file
|
125
|
+
# @param auth_plugin_dir [String] Base directory for auth plugins
|
126
|
+
# @return [void]
|
127
|
+
def load_custom_auth_plugin(file_path, auth_plugin_dir)
|
128
|
+
# Security: Ensure the file path doesn't escape the auth plugin directory
|
129
|
+
normalized_auth_plugin_dir = Pathname.new(File.expand_path(auth_plugin_dir))
|
130
|
+
normalized_file_path = Pathname.new(File.expand_path(file_path))
|
131
|
+
unless normalized_file_path.descend.any? { |path| path == normalized_auth_plugin_dir }
|
132
|
+
raise SecurityError, "Auth plugin path outside of auth plugin directory: #{file_path}"
|
133
|
+
end
|
134
|
+
|
135
|
+
# Extract plugin name from file (e.g., custom_auth.rb -> CustomAuth)
|
136
|
+
file_name = File.basename(file_path, ".rb")
|
137
|
+
class_name = file_name.split("_").map(&:capitalize).join("")
|
138
|
+
|
139
|
+
# Security: Validate class name
|
140
|
+
unless valid_auth_plugin_class_name?(class_name)
|
141
|
+
raise StandardError, "Invalid auth plugin class name: #{class_name}"
|
142
|
+
end
|
143
|
+
|
144
|
+
# Load the file
|
145
|
+
require file_path
|
146
|
+
|
147
|
+
# Get the class and validate it
|
148
|
+
auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{class_name}")
|
149
|
+
unless auth_plugin_class < Hooks::Plugins::Auth::Base
|
150
|
+
raise StandardError, "Auth plugin class must inherit from Hooks::Plugins::Auth::Base: #{class_name}"
|
151
|
+
end
|
152
|
+
|
153
|
+
# Register the plugin (using the file_name as the key for lookup)
|
154
|
+
@auth_plugins[file_name] = auth_plugin_class
|
155
|
+
end
|
156
|
+
|
157
|
+
# Load a single custom handler plugin file
|
158
|
+
#
|
159
|
+
# @param file_path [String] Path to the handler plugin file
|
160
|
+
# @param handler_plugin_dir [String] Base directory for handler plugins
|
161
|
+
# @return [void]
|
162
|
+
def load_custom_handler_plugin(file_path, handler_plugin_dir)
|
163
|
+
# Security: Ensure the file path doesn't escape the handler plugin directory
|
164
|
+
normalized_handler_dir = Pathname.new(File.expand_path(handler_plugin_dir))
|
165
|
+
normalized_file_path = Pathname.new(File.expand_path(file_path))
|
166
|
+
unless normalized_file_path.descend.any? { |path| path == normalized_handler_dir }
|
167
|
+
raise SecurityError, "Handler plugin path outside of handler plugin directory: #{file_path}"
|
168
|
+
end
|
169
|
+
|
170
|
+
# Extract class name from file (e.g., team1_handler.rb -> Team1Handler)
|
171
|
+
file_name = File.basename(file_path, ".rb")
|
172
|
+
class_name = file_name.split("_").map(&:capitalize).join("")
|
173
|
+
|
174
|
+
# Security: Validate class name
|
175
|
+
unless valid_handler_class_name?(class_name)
|
176
|
+
raise StandardError, "Invalid handler class name: #{class_name}"
|
177
|
+
end
|
178
|
+
|
179
|
+
# Load the file
|
180
|
+
require file_path
|
181
|
+
|
182
|
+
# Get the class and validate it
|
183
|
+
handler_class = Object.const_get(class_name)
|
184
|
+
unless handler_class < Hooks::Plugins::Handlers::Base
|
185
|
+
raise StandardError, "Handler class must inherit from Hooks::Plugins::Handlers::Base: #{class_name}"
|
186
|
+
end
|
187
|
+
|
188
|
+
# Register the handler (using the class name as the key for lookup)
|
189
|
+
@handler_plugins[class_name] = handler_class
|
190
|
+
end
|
191
|
+
|
192
|
+
# Log summary of loaded plugins
|
193
|
+
#
|
194
|
+
# @return [void]
|
195
|
+
def log_loaded_plugins
|
196
|
+
return unless defined?(Hooks::Log) && Hooks::Log.instance
|
197
|
+
|
198
|
+
log = Hooks::Log.instance
|
199
|
+
# Skip logging if the logger is a test double (class name contains "Double")
|
200
|
+
return if log.class.name.include?("Double")
|
201
|
+
|
202
|
+
log.info "Loaded #{@auth_plugins.size} auth plugins: #{@auth_plugins.keys.join(', ')}"
|
203
|
+
log.info "Loaded #{@handler_plugins.size} handler plugins: #{@handler_plugins.keys.join(', ')}"
|
204
|
+
end
|
205
|
+
|
206
|
+
# Validate that an auth plugin class name is safe to load
|
207
|
+
#
|
208
|
+
# @param class_name [String] The class name to validate
|
209
|
+
# @return [Boolean] true if the class name is safe, false otherwise
|
210
|
+
def valid_auth_plugin_class_name?(class_name)
|
211
|
+
# Must be a string
|
212
|
+
return false unless class_name.is_a?(String)
|
213
|
+
|
214
|
+
# Must not be empty or only whitespace
|
215
|
+
return false if class_name.strip.empty?
|
216
|
+
|
217
|
+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
|
218
|
+
# Examples: MyAuthPlugin, SomeCoolAuthPlugin, CustomAuth
|
219
|
+
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
|
220
|
+
|
221
|
+
# Must not be a system/built-in class name
|
222
|
+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
|
223
|
+
|
224
|
+
true
|
225
|
+
end
|
226
|
+
|
227
|
+
# Validate that a handler class name is safe to load
|
228
|
+
#
|
229
|
+
# @param class_name [String] The class name to validate
|
230
|
+
# @return [Boolean] true if the class name is safe, false otherwise
|
231
|
+
def valid_handler_class_name?(class_name)
|
232
|
+
# Must be a string
|
233
|
+
return false unless class_name.is_a?(String)
|
234
|
+
|
235
|
+
# Must not be empty or only whitespace
|
236
|
+
return false if class_name.strip.empty?
|
237
|
+
|
238
|
+
# Must match a safe pattern: alphanumeric + underscore, starting with uppercase
|
239
|
+
# Examples: MyHandler, Team1Handler, GitHubHandler
|
240
|
+
return false unless class_name.match?(/\A[A-Z][a-zA-Z0-9_]*\z/)
|
241
|
+
|
242
|
+
# Must not be a system/built-in class name
|
243
|
+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(class_name)
|
244
|
+
|
245
|
+
true
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rack/utils"
|
4
|
+
require_relative "../../core/log"
|
5
|
+
|
6
|
+
module Hooks
|
7
|
+
module Plugins
|
8
|
+
module Auth
|
9
|
+
# Abstract base class for request validators via authentication
|
10
|
+
#
|
11
|
+
# All custom Auth plugins must inherit from this class
|
12
|
+
class Base
|
13
|
+
# Validate request
|
14
|
+
#
|
15
|
+
# @param payload [String] Raw request body
|
16
|
+
# @param headers [Hash<String, String>] HTTP headers
|
17
|
+
# @param config [Hash] Endpoint configuration
|
18
|
+
# @return [Boolean] true if request is valid
|
19
|
+
# @raise [NotImplementedError] if not implemented by subclass
|
20
|
+
def self.valid?(payload:, headers:, config:)
|
21
|
+
raise NotImplementedError, "Validator must implement .valid? class method"
|
22
|
+
end
|
23
|
+
|
24
|
+
# Short logger accessor for all subclasses
|
25
|
+
# @return [Hooks::Log] Logger instance for request validation
|
26
|
+
#
|
27
|
+
# Provides a convenient way for validators to log messages without needing
|
28
|
+
# to reference the full Hooks::Log namespace.
|
29
|
+
#
|
30
|
+
# @example Logging an error in an inherited class
|
31
|
+
# log.error("oh no an error occured")
|
32
|
+
def self.log
|
33
|
+
Hooks::Log.instance
|
34
|
+
end
|
35
|
+
|
36
|
+
# Retrieve the secret from the environment variable based on the key set in the configuration
|
37
|
+
#
|
38
|
+
# Note: This method is intended to be used by subclasses
|
39
|
+
# It is a helper method and may not work with all authentication types
|
40
|
+
#
|
41
|
+
# @param config [Hash] Configuration hash containing :auth key
|
42
|
+
# @param secret_env_key [Symbol] The key to look up in the config for the environment variable name
|
43
|
+
# @return [String] The secret
|
44
|
+
# @raise [StandardError] if secret_env_key is missing or empty
|
45
|
+
def self.fetch_secret(config, secret_env_key_name: :secret_env_key)
|
46
|
+
secret_env_key = config.dig(:auth, secret_env_key_name)
|
47
|
+
if secret_env_key.nil? || !secret_env_key.is_a?(String) || secret_env_key.strip.empty?
|
48
|
+
raise StandardError, "authentication configuration incomplete: missing secret_env_key"
|
49
|
+
end
|
50
|
+
|
51
|
+
secret = ENV[secret_env_key]
|
52
|
+
|
53
|
+
if secret.nil? || !secret.is_a?(String) || secret.strip.empty?
|
54
|
+
raise StandardError, "authentication configuration incomplete: missing secret value bound to #{secret_env_key_name}"
|
55
|
+
end
|
56
|
+
|
57
|
+
return secret.strip
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,313 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "openssl"
|
4
|
+
require "time"
|
5
|
+
require_relative "base"
|
6
|
+
|
7
|
+
module Hooks
|
8
|
+
module Plugins
|
9
|
+
module Auth
|
10
|
+
# Generic HMAC signature validator for webhooks
|
11
|
+
#
|
12
|
+
# This validator supports multiple webhook providers with different signature formats.
|
13
|
+
# It provides flexible configuration options to handle various HMAC-based authentication schemes.
|
14
|
+
#
|
15
|
+
# @example Basic configuration with algorithm prefix
|
16
|
+
# auth:
|
17
|
+
# type: HMAC
|
18
|
+
# secret_env_key: WEBHOOK_SECRET
|
19
|
+
# header: X-Hub-Signature-256
|
20
|
+
# algorithm: sha256
|
21
|
+
# format: "algorithm=signature"
|
22
|
+
#
|
23
|
+
# @example Configuration with timestamp validation
|
24
|
+
# auth:
|
25
|
+
# type: HMAC
|
26
|
+
# secret_env_key: WEBHOOK_SECRET
|
27
|
+
# header: X-Signature
|
28
|
+
# timestamp_header: X-Request-Timestamp
|
29
|
+
# timestamp_tolerance: 300 # 5 minutes
|
30
|
+
# algorithm: sha256
|
31
|
+
# format: "version=signature"
|
32
|
+
# version_prefix: "v0"
|
33
|
+
# payload_template: "{version}:{timestamp}:{body}"
|
34
|
+
class HMAC < Base
|
35
|
+
# Default configuration values for HMAC validation
|
36
|
+
#
|
37
|
+
# @return [Hash<Symbol, String|Integer>] Default configuration settings
|
38
|
+
# @note These values provide sensible defaults for most webhook implementations
|
39
|
+
DEFAULT_CONFIG = {
|
40
|
+
algorithm: "sha256",
|
41
|
+
format: "algorithm=signature", # Format: algorithm=hash
|
42
|
+
timestamp_tolerance: 300, # 5 minutes tolerance for timestamp validation
|
43
|
+
version_prefix: "v0" # Default version prefix for versioned signatures
|
44
|
+
}.freeze
|
45
|
+
|
46
|
+
# Mapping of signature format strings to internal format symbols
|
47
|
+
#
|
48
|
+
# @return [Hash<String, Symbol>] Format string to symbol mapping
|
49
|
+
# @note Supports three common webhook signature formats:
|
50
|
+
# - algorithm=signature: "sha256=abc123..." (GitHub, GitLab style)
|
51
|
+
# - signature_only: "abc123..." (Shopify style)
|
52
|
+
# - version=signature: "v0=abc123..." (Slack style)
|
53
|
+
FORMATS = {
|
54
|
+
"algorithm=signature" => :algorithm_prefixed, # "sha256=abc123..."
|
55
|
+
"signature_only" => :hash_only, # "abc123..."
|
56
|
+
"version=signature" => :version_prefixed # "v0=abc123..."
|
57
|
+
}.freeze
|
58
|
+
|
59
|
+
# Validate HMAC signature from webhook requests
|
60
|
+
#
|
61
|
+
# Performs comprehensive HMAC signature validation with support for multiple
|
62
|
+
# signature formats and optional timestamp validation. Uses secure comparison
|
63
|
+
# to prevent timing attacks.
|
64
|
+
#
|
65
|
+
# @param payload [String] Raw request body to validate
|
66
|
+
# @param headers [Hash<String, String>] HTTP headers from the request
|
67
|
+
# @param config [Hash] Endpoint configuration containing validator settings
|
68
|
+
# @option config [Hash] :auth Validator-specific configuration
|
69
|
+
# @option config [String] :header ('X-Signature') Header containing the signature
|
70
|
+
# @option config [String] :timestamp_header Header containing timestamp (optional)
|
71
|
+
# @option config [Integer] :timestamp_tolerance (300) Timestamp tolerance in seconds
|
72
|
+
# @option config [String] :algorithm ('sha256') HMAC algorithm to use
|
73
|
+
# @option config [String] :format ('algorithm=signature') Signature format
|
74
|
+
# @option config [String] :version_prefix ('v0') Version prefix for versioned signatures
|
75
|
+
# @option config [String] :payload_template Template for payload construction
|
76
|
+
# @return [Boolean] true if signature is valid, false otherwise
|
77
|
+
# @raise [StandardError] Rescued internally, returns false on any error
|
78
|
+
# @note This method is designed to be safe and will never raise exceptions
|
79
|
+
# @note Uses Rack::Utils.secure_compare to prevent timing attacks
|
80
|
+
# @example Basic validation
|
81
|
+
# HMAC.valid?(
|
82
|
+
# payload: request_body,
|
83
|
+
# headers: request.headers,
|
84
|
+
# config: { auth: { header: 'X-Signature' } }
|
85
|
+
# )
|
86
|
+
def self.valid?(payload:, headers:, config:)
|
87
|
+
# fetch the required secret from environment variable as specified in the config
|
88
|
+
secret = fetch_secret(config)
|
89
|
+
|
90
|
+
validator_config = build_config(config)
|
91
|
+
|
92
|
+
# Security: Check raw headers BEFORE normalization to detect tampering
|
93
|
+
return false unless headers.respond_to?(:each)
|
94
|
+
|
95
|
+
signature_header = validator_config[:header]
|
96
|
+
|
97
|
+
# Find the signature header with case-insensitive matching but preserve original value
|
98
|
+
raw_signature = nil
|
99
|
+
headers.each do |key, value|
|
100
|
+
if key.to_s.downcase == signature_header.downcase
|
101
|
+
raw_signature = value.to_s
|
102
|
+
break
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
return false if raw_signature.nil? || raw_signature.empty?
|
107
|
+
|
108
|
+
# Security: Reject signatures with leading/trailing whitespace
|
109
|
+
return false if raw_signature != raw_signature.strip
|
110
|
+
|
111
|
+
# Security: Reject signatures containing null bytes or other control characters
|
112
|
+
return false if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/)
|
113
|
+
|
114
|
+
# Now we can safely normalize headers for the rest of the validation
|
115
|
+
normalized_headers = normalize_headers(headers)
|
116
|
+
provided_signature = normalized_headers[signature_header.downcase]
|
117
|
+
|
118
|
+
# Validate timestamp if required (for services that include timestamp validation)
|
119
|
+
if validator_config[:timestamp_header]
|
120
|
+
return false unless valid_timestamp?(normalized_headers, validator_config)
|
121
|
+
end
|
122
|
+
|
123
|
+
# Compute expected signature
|
124
|
+
computed_signature = compute_signature(
|
125
|
+
payload:,
|
126
|
+
headers: normalized_headers,
|
127
|
+
secret:,
|
128
|
+
config: validator_config
|
129
|
+
)
|
130
|
+
|
131
|
+
# Use secure comparison to prevent timing attacks
|
132
|
+
Rack::Utils.secure_compare(computed_signature, provided_signature)
|
133
|
+
rescue StandardError => e
|
134
|
+
log.error("Auth::HMAC validation failed: #{e.message}")
|
135
|
+
false
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
# Build final configuration by merging defaults with provided config
|
141
|
+
#
|
142
|
+
# Combines default configuration values with user-provided settings,
|
143
|
+
# ensuring all required configuration keys are present with sensible defaults.
|
144
|
+
#
|
145
|
+
# @param config [Hash] Raw endpoint configuration
|
146
|
+
# @return [Hash<Symbol, Object>] Merged configuration with defaults applied
|
147
|
+
# @note Missing configuration values are filled with DEFAULT_CONFIG values
|
148
|
+
# @api private
|
149
|
+
def self.build_config(config)
|
150
|
+
validator_config = config.dig(:auth) || {}
|
151
|
+
|
152
|
+
algorithm = validator_config[:algorithm] || DEFAULT_CONFIG[:algorithm]
|
153
|
+
tolerance = validator_config[:timestamp_tolerance] || DEFAULT_CONFIG[:timestamp_tolerance]
|
154
|
+
|
155
|
+
DEFAULT_CONFIG.merge({
|
156
|
+
header: validator_config[:header] || "X-Signature",
|
157
|
+
timestamp_header: validator_config[:timestamp_header],
|
158
|
+
timestamp_tolerance: tolerance,
|
159
|
+
algorithm: algorithm,
|
160
|
+
format: validator_config[:format] || DEFAULT_CONFIG[:format],
|
161
|
+
version_prefix: validator_config[:version_prefix] || DEFAULT_CONFIG[:version_prefix],
|
162
|
+
payload_template: validator_config[:payload_template]
|
163
|
+
})
|
164
|
+
end
|
165
|
+
|
166
|
+
# Normalize headers using the Utils::Normalize class
|
167
|
+
#
|
168
|
+
# Converts header hash to normalized format with lowercase keys for
|
169
|
+
# case-insensitive header matching.
|
170
|
+
#
|
171
|
+
# @param headers [Hash<String, String>] Raw HTTP headers
|
172
|
+
# @return [Hash<String, String>] Normalized headers with lowercase keys
|
173
|
+
# @note Returns empty hash if headers is nil
|
174
|
+
# @api private
|
175
|
+
def self.normalize_headers(headers)
|
176
|
+
Utils::Normalize.headers(headers) || {}
|
177
|
+
end
|
178
|
+
|
179
|
+
# Validate timestamp if timestamp validation is configured
|
180
|
+
#
|
181
|
+
# Checks if the provided timestamp is within the configured tolerance
|
182
|
+
# of the current time. This prevents replay attacks using old requests.
|
183
|
+
#
|
184
|
+
# @param headers [Hash<String, String>] Normalized HTTP headers
|
185
|
+
# @param config [Hash<Symbol, Object>] Validator configuration
|
186
|
+
# @return [Boolean] true if timestamp is valid or not required, false otherwise
|
187
|
+
# @note Returns false if timestamp header is missing when required
|
188
|
+
# @note Tolerance is applied as absolute difference (past or future)
|
189
|
+
# @api private
|
190
|
+
def self.valid_timestamp?(headers, config)
|
191
|
+
timestamp_header = config[:timestamp_header]
|
192
|
+
return false unless timestamp_header
|
193
|
+
|
194
|
+
timestamp_header = timestamp_header.downcase
|
195
|
+
timestamp_value = headers[timestamp_header]
|
196
|
+
|
197
|
+
return false unless timestamp_value
|
198
|
+
|
199
|
+
# Security: Strict timestamp validation - must be only digits with no leading zeros
|
200
|
+
return false unless timestamp_value.match?(/\A[1-9]\d*\z/) || timestamp_value == "0"
|
201
|
+
|
202
|
+
timestamp = timestamp_value.to_i
|
203
|
+
|
204
|
+
# Ensure timestamp is a positive integer (reject zero and negative)
|
205
|
+
return false unless timestamp > 0
|
206
|
+
|
207
|
+
current_time = Time.now.to_i
|
208
|
+
tolerance = config[:timestamp_tolerance]
|
209
|
+
|
210
|
+
(current_time - timestamp).abs <= tolerance
|
211
|
+
end
|
212
|
+
|
213
|
+
# Compute HMAC signature based on configuration requirements
|
214
|
+
#
|
215
|
+
# Generates the expected HMAC signature for the given payload using the
|
216
|
+
# specified algorithm and formatting rules.
|
217
|
+
#
|
218
|
+
# @param payload [String] Raw request body
|
219
|
+
# @param headers [Hash<String, String>] Normalized HTTP headers
|
220
|
+
# @param secret [String] Secret key for HMAC computation
|
221
|
+
# @param config [Hash<Symbol, Object>] Validator configuration
|
222
|
+
# @return [String] Formatted HMAC signature
|
223
|
+
# @note The returned signature format depends on the configured format style
|
224
|
+
# @api private
|
225
|
+
def self.compute_signature(payload:, headers:, secret:, config:)
|
226
|
+
# Determine what to sign based on payload template
|
227
|
+
signing_payload = build_signing_payload(
|
228
|
+
payload:,
|
229
|
+
headers:,
|
230
|
+
config:
|
231
|
+
)
|
232
|
+
|
233
|
+
# Compute HMAC hash
|
234
|
+
algorithm = config[:algorithm]
|
235
|
+
computed_hash = OpenSSL::HMAC.hexdigest(
|
236
|
+
OpenSSL::Digest.new(algorithm),
|
237
|
+
secret,
|
238
|
+
signing_payload
|
239
|
+
)
|
240
|
+
|
241
|
+
# Format according to provider requirements
|
242
|
+
format_signature(computed_hash, config)
|
243
|
+
end
|
244
|
+
|
245
|
+
# Build the payload string to sign (handles templated payload requirements)
|
246
|
+
#
|
247
|
+
# Constructs the signing payload based on configuration. Some webhook services
|
248
|
+
# require specific payload formats that include metadata like timestamps.
|
249
|
+
#
|
250
|
+
# @param payload [String] Raw request body
|
251
|
+
# @param headers [Hash<String, String>] Normalized HTTP headers
|
252
|
+
# @param config [Hash<Symbol, Object>] Validator configuration
|
253
|
+
# @return [String] Payload string ready for HMAC computation
|
254
|
+
# @note When payload_template is provided, it supports variable substitution:
|
255
|
+
# - {version}: Replaced with version_prefix
|
256
|
+
# - {timestamp}: Replaced with timestamp from headers
|
257
|
+
# - {body}: Replaced with the raw payload
|
258
|
+
# @example Template usage
|
259
|
+
# template: "{version}:{timestamp}:{body}"
|
260
|
+
# result: "v0:1609459200:{"event":"push"}"
|
261
|
+
# @api private
|
262
|
+
def self.build_signing_payload(payload:, headers:, config:)
|
263
|
+
template = config[:payload_template]
|
264
|
+
|
265
|
+
if template
|
266
|
+
# Templated payload format (e.g., "v0:timestamp:body" for timestamp-based validation)
|
267
|
+
timestamp = headers[config[:timestamp_header].downcase]
|
268
|
+
template
|
269
|
+
.gsub("{version}", config[:version_prefix])
|
270
|
+
.gsub("{timestamp}", timestamp.to_s)
|
271
|
+
.gsub("{body}", payload)
|
272
|
+
else
|
273
|
+
# Standard: just the payload
|
274
|
+
payload
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
# Format the computed signature based on configuration requirements
|
279
|
+
#
|
280
|
+
# Applies the appropriate formatting to the computed HMAC hash based on
|
281
|
+
# the configured signature format style.
|
282
|
+
#
|
283
|
+
# @param hash [String] Raw HMAC hash (hexadecimal string)
|
284
|
+
# @param config [Hash<Symbol, Object>] Validator configuration
|
285
|
+
# @return [String] Formatted signature string
|
286
|
+
# @note Supported formats:
|
287
|
+
# - :algorithm_prefixed: "sha256=abc123..." (GitHub style)
|
288
|
+
# - :hash_only: "abc123..." (Shopify style)
|
289
|
+
# - :version_prefixed: "v0=abc123..." (Slack style)
|
290
|
+
# @note Defaults to algorithm_prefixed format for unknown format styles
|
291
|
+
# @api private
|
292
|
+
def self.format_signature(hash, config)
|
293
|
+
format_style = FORMATS[config[:format]]
|
294
|
+
|
295
|
+
case format_style
|
296
|
+
when :algorithm_prefixed
|
297
|
+
# Algorithm-prefixed format: "sha256=abc123..." (used by GitHub, GitLab, etc.)
|
298
|
+
"#{config[:algorithm]}=#{hash}"
|
299
|
+
when :hash_only
|
300
|
+
# Hash-only format: "abc123..." (used by Shopify, etc.)
|
301
|
+
hash
|
302
|
+
when :version_prefixed
|
303
|
+
# Version-prefixed format: "v0=abc123..." (used by Slack, etc.)
|
304
|
+
"#{config[:version_prefix]}=#{hash}"
|
305
|
+
else
|
306
|
+
# Default to algorithm-prefixed format
|
307
|
+
"#{config[:algorithm]}=#{hash}"
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|