Skip to content

Commit dc156bf

Browse files
committed
update hmac module
1 parent 90496ef commit dc156bf

1 file changed

Lines changed: 134 additions & 40 deletions

File tree

  • lib/hooks/plugins/request_validator

lib/hooks/plugins/request_validator/hmac.rb

Lines changed: 134 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)