@@ -10,54 +10,82 @@ module Plugins
1010 module RequestValidator
1111 # Generic HMAC signature validator for webhooks
1212 #
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
13+ # This validator supports multiple webhook providers with different signature formats.
14+ # It provides flexible configuration options to handle various HMAC-based authentication schemes.
1815 #
19- # @example Basic GitHub-style configuration
16+ # @example Basic configuration with algorithm prefix
2017 # request_validator:
2118 # type: HMAC
2219 # secret_env_key: WEBHOOK_SECRET
2320 # header: X-Hub-Signature-256
2421 # algorithm: sha256
2522 # format: "algorithm=signature"
2623 #
27- # @example Slack-style with timestamp validation
24+ # @example Configuration with timestamp validation
2825 # request_validator:
2926 # type: HMAC
30- # secret_env_key: SLACK_SIGNING_SECRET
31- # header: X-Slack- Signature
32- # timestamp_header: X-Slack- Request-Timestamp
27+ # secret_env_key: WEBHOOK_SECRET
28+ # header: X-Signature
29+ # timestamp_header: X-Request-Timestamp
3330 # timestamp_tolerance: 300 # 5 minutes
3431 # algorithm: sha256
3532 # format: "version=signature"
3633 # version_prefix: "v0"
3734 # payload_template: "{version}:{timestamp}:{body}"
3835 class HMAC < Base
39- # Default configuration values
36+ # Default configuration values for HMAC validation
37+ #
38+ # @return [Hash<Symbol, String|Integer>] Default configuration settings
39+ # @note These values provide sensible defaults for most webhook implementations
4040 DEFAULT_CONFIG = {
4141 algorithm : "sha256" ,
42- format : "algorithm=signature" , # GitHub default
43- timestamp_tolerance : 300 , # 5 minutes for Slack
44- version_prefix : "v0" # Slack default
42+ format : "algorithm=signature" , # Format: algorithm=hash
43+ timestamp_tolerance : 300 , # 5 minutes tolerance for timestamp validation
44+ version_prefix : "v0" # Default version prefix for versioned signatures
4545 } . freeze
4646
47- # Supported signature formats
47+ # Mapping of signature format strings to internal format symbols
48+ #
49+ # @return [Hash<String, Symbol>] Format string to symbol mapping
50+ # @note Supports three common webhook signature formats:
51+ # - algorithm=signature: "sha256=abc123..." (GitHub, GitLab style)
52+ # - signature_only: "abc123..." (Shopify style)
53+ # - version=signature: "v0=abc123..." (Slack style)
4854 FORMATS = {
49- "algorithm=signature" => :github_style , # "sha256=abc123..."
50- "signature_only" => :shopify_style , # "abc123..."
51- "version=signature" => :slack_style # "v0=abc123..."
55+ "algorithm=signature" => :algorithm_prefixed , # "sha256=abc123..."
56+ "signature_only" => :hash_only , # "abc123..."
57+ "version=signature" => :version_prefixed # "v0=abc123..."
5258 } . freeze
5359
5460 # Validate HMAC signature from webhook requests
5561 #
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
62+ # Performs comprehensive HMAC signature validation with support for multiple
63+ # signature formats and optional timestamp validation. Uses secure comparison
64+ # to prevent timing attacks.
65+ #
66+ # @param payload [String] Raw request body to validate
67+ # @param headers [Hash<String, String>] HTTP headers from the request
68+ # @param secret [String] Secret key for HMAC computation
69+ # @param config [Hash] Endpoint configuration containing validator settings
70+ # @option config [Hash] :request_validator Validator-specific configuration
71+ # @option config [String] :header ('X-Signature') Header containing the signature
72+ # @option config [String] :timestamp_header Header containing timestamp (optional)
73+ # @option config [Integer] :timestamp_tolerance (300) Timestamp tolerance in seconds
74+ # @option config [String] :algorithm ('sha256') HMAC algorithm to use
75+ # @option config [String] :format ('algorithm=signature') Signature format
76+ # @option config [String] :version_prefix ('v0') Version prefix for versioned signatures
77+ # @option config [String] :payload_template Template for payload construction
78+ # @return [Boolean] true if signature is valid, false otherwise
79+ # @raise [StandardError] Rescued internally, returns false on any error
80+ # @note This method is designed to be safe and will never raise exceptions
81+ # @note Uses Rack::Utils.secure_compare to prevent timing attacks
82+ # @example Basic validation
83+ # HMAC.valid?(
84+ # payload: request_body,
85+ # headers: request.headers,
86+ # secret: ENV['WEBHOOK_SECRET'],
87+ # config: { request_validator: { header: 'X-Signature' } }
88+ # )
6189 def self . valid? ( payload :, headers :, secret :, config :)
6290 return false if secret . nil? || secret . empty?
6391
@@ -69,16 +97,16 @@ def self.valid?(payload:, headers:, secret:, config:)
6997 provided_signature = normalized_headers [ signature_header . downcase ]
7098 return false if provided_signature . nil? || provided_signature . empty?
7199
72- # Validate timestamp if required (for Slack and others )
100+ # Validate timestamp if required (for services that include timestamp validation )
73101 if validator_config [ :timestamp_header ]
74102 return false unless valid_timestamp? ( normalized_headers , validator_config )
75103 end
76104
77105 # Compute expected signature
78106 computed_signature = compute_signature (
79- payload : payload ,
107+ payload :,
80108 headers : normalized_headers ,
81- secret : secret ,
109+ secret :,
82110 config : validator_config
83111 )
84112
@@ -92,6 +120,14 @@ def self.valid?(payload:, headers:, secret:, config:)
92120 private
93121
94122 # Build final configuration by merging defaults with provided config
123+ #
124+ # Combines default configuration values with user-provided settings,
125+ # ensuring all required configuration keys are present with sensible defaults.
126+ #
127+ # @param config [Hash] Raw endpoint configuration
128+ # @return [Hash<Symbol, Object>] Merged configuration with defaults applied
129+ # @note Missing configuration values are filled with DEFAULT_CONFIG values
130+ # @api private
95131 def self . build_config ( config )
96132 validator_config = config . dig ( :request_validator ) || { }
97133
@@ -107,11 +143,29 @@ def self.build_config(config)
107143 end
108144
109145 # Normalize headers using the Utils::Normalize class
146+ #
147+ # Converts header hash to normalized format with lowercase keys for
148+ # case-insensitive header matching.
149+ #
150+ # @param headers [Hash<String, String>] Raw HTTP headers
151+ # @return [Hash<String, String>] Normalized headers with lowercase keys
152+ # @note Returns empty hash if headers is nil
153+ # @api private
110154 def self . normalize_headers ( headers )
111155 Utils ::Normalize . headers ( headers ) || { }
112156 end
113157
114158 # Validate timestamp if timestamp validation is configured
159+ #
160+ # Checks if the provided timestamp is within the configured tolerance
161+ # of the current time. This prevents replay attacks using old requests.
162+ #
163+ # @param headers [Hash<String, String>] Normalized HTTP headers
164+ # @param config [Hash<Symbol, Object>] Validator configuration
165+ # @return [Boolean] true if timestamp is valid or not required, false otherwise
166+ # @note Returns false if timestamp header is missing when required
167+ # @note Tolerance is applied as absolute difference (past or future)
168+ # @api private
115169 def self . valid_timestamp? ( headers , config )
116170 timestamp_header = config [ :timestamp_header ] . downcase
117171 timestamp_value = headers [ timestamp_header ]
@@ -125,13 +179,24 @@ def self.valid_timestamp?(headers, config)
125179 ( current_time - timestamp ) . abs <= tolerance
126180 end
127181
128- # Compute HMAC signature based on provider requirements
182+ # Compute HMAC signature based on configuration requirements
183+ #
184+ # Generates the expected HMAC signature for the given payload using the
185+ # specified algorithm and formatting rules.
186+ #
187+ # @param payload [String] Raw request body
188+ # @param headers [Hash<String, String>] Normalized HTTP headers
189+ # @param secret [String] Secret key for HMAC computation
190+ # @param config [Hash<Symbol, Object>] Validator configuration
191+ # @return [String] Formatted HMAC signature
192+ # @note The returned signature format depends on the configured format style
193+ # @api private
129194 def self . compute_signature ( payload :, headers :, secret :, config :)
130195 # Determine what to sign based on payload template
131196 signing_payload = build_signing_payload (
132- payload : payload ,
133- headers : headers ,
134- config : config
197+ payload :,
198+ headers :,
199+ config :
135200 )
136201
137202 # Compute HMAC hash
@@ -146,12 +211,28 @@ def self.compute_signature(payload:, headers:, secret:, config:)
146211 format_signature ( computed_hash , config )
147212 end
148213
149- # Build the payload string to sign (handles Slack's special requirements)
214+ # Build the payload string to sign (handles templated payload requirements)
215+ #
216+ # Constructs the signing payload based on configuration. Some webhook services
217+ # require specific payload formats that include metadata like timestamps.
218+ #
219+ # @param payload [String] Raw request body
220+ # @param headers [Hash<String, String>] Normalized HTTP headers
221+ # @param config [Hash<Symbol, Object>] Validator configuration
222+ # @return [String] Payload string ready for HMAC computation
223+ # @note When payload_template is provided, it supports variable substitution:
224+ # - {version}: Replaced with version_prefix
225+ # - {timestamp}: Replaced with timestamp from headers
226+ # - {body}: Replaced with the raw payload
227+ # @example Template usage
228+ # template: "{version}:{timestamp}:{body}"
229+ # result: "v0:1609459200:{"event":"push"}"
230+ # @api private
150231 def self . build_signing_payload ( payload :, headers :, config :)
151232 template = config [ :payload_template ]
152233
153234 if template
154- # Slack-style: "v0:timestamp:body"
235+ # Templated payload format (e.g., "v0:timestamp:body" for timestamp-based validation)
155236 timestamp = headers [ config [ :timestamp_header ] . downcase ]
156237 template
157238 . gsub ( "{version}" , config [ :version_prefix ] )
@@ -163,22 +244,35 @@ def self.build_signing_payload(payload:, headers:, config:)
163244 end
164245 end
165246
166- # Format the computed signature based on provider requirements
247+ # Format the computed signature based on configuration requirements
248+ #
249+ # Applies the appropriate formatting to the computed HMAC hash based on
250+ # the configured signature format style.
251+ #
252+ # @param hash [String] Raw HMAC hash (hexadecimal string)
253+ # @param config [Hash<Symbol, Object>] Validator configuration
254+ # @return [String] Formatted signature string
255+ # @note Supported formats:
256+ # - :algorithm_prefixed: "sha256=abc123..." (GitHub style)
257+ # - :hash_only: "abc123..." (Shopify style)
258+ # - :version_prefixed: "v0=abc123..." (Slack style)
259+ # @note Defaults to algorithm_prefixed format for unknown format styles
260+ # @api private
167261 def self . format_signature ( hash , config )
168262 format_style = FORMATS [ config [ :format ] ]
169263
170264 case format_style
171- when :github_style
172- # GitHub : "sha256=abc123..."
265+ when :algorithm_prefixed
266+ # Algorithm-prefixed format : "sha256=abc123..." (used by GitHub, GitLab, etc.)
173267 "#{ config [ :algorithm ] } =#{ hash } "
174- when :shopify_style
175- # Shopify: just the hash
268+ when :hash_only
269+ # Hash-only format: "abc123..." (used by Shopify, etc.)
176270 hash
177- when :slack_style
178- # Slack : "v0=abc123..."
271+ when :version_prefixed
272+ # Version-prefixed format : "v0=abc123..." (used by Slack, etc.)
179273 "#{ config [ :version_prefix ] } =#{ hash } "
180274 else
181- # Default to GitHub style
275+ # Default to algorithm-prefixed format
182276 "#{ config [ :algorithm ] } =#{ hash } "
183277 end
184278 end
0 commit comments