hooks-ruby 0.2.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae1757617b84df588de7eb343799b93d1dfc6326c3d54ab1a2d578aa2d3e3b8e
4
- data.tar.gz: a39cfa8162346d536ab5a543dfcf7517162282ba3a74098cd276bc76c636a41c
3
+ metadata.gz: 47555e043e83129f208e5925c22dbbf12aadba0358a70272461952355251c869
4
+ data.tar.gz: b2760979c328b79cf82fe8b5a37a67261f2b5085208488ca928662e1915d9f06
5
5
  SHA512:
6
- metadata.gz: da5353fda88b8d348e7b743e155642376ad886b5e77a38cb08148a9a6fbb5bece88c099125bb4738d41a09e3d3e0b5c29bd317bafa3789caa595d02f1c7217a2
7
- data.tar.gz: 0ba9c613e80a889da7b1fd7b6b4b84b53190aae6db27184d7caa0a90806c3d266f0fff06c4bff1cef702c0879f772056fde5c1b697d817852130167eb0724ebd
6
+ metadata.gz: 90b666eb68b986092e56e6db8140af3e6aae5b03a59e0e546beeee4a314a97ed75aaf4122687087cc58b258d88462a6fc5829339f56510e9d123f9b0d7c414ed
7
+ data.tar.gz: e50173bf684c3b458fe489667b05a65737658160c5a01603f7b8061419945fe41b07d04e61861524c8af5b445e2d63aa78b90ac1a3a07bb2fed481c50cc04406
@@ -45,6 +45,11 @@ module Hooks
45
45
  optional(:format).filled(:string)
46
46
  optional(:version_prefix).filled(:string)
47
47
  optional(:payload_template).filled(:string)
48
+ optional(:header_format).filled(:string)
49
+ optional(:signature_key).filled(:string)
50
+ optional(:timestamp_key).filled(:string)
51
+ optional(:structured_header_separator).filled(:string)
52
+ optional(:key_value_separator).filled(:string)
48
53
  end
49
54
 
50
55
  optional(:opts).hash
@@ -14,6 +14,10 @@ module Hooks
14
14
  class Base
15
15
  extend Hooks::Core::ComponentAccess
16
16
 
17
+ # Security constants shared across auth validators
18
+ MAX_HEADER_VALUE_LENGTH = ENV.fetch("HOOKS_MAX_HEADER_VALUE_LENGTH", 1024).to_i # Prevent DoS attacks via large header values
19
+ MAX_PAYLOAD_SIZE = ENV.fetch("HOOKS_MAX_PAYLOAD_SIZE", 10 * 1024 * 1024).to_i # 10MB limit for payload validation
20
+
17
21
  # Validate request
18
22
  #
19
23
  # @param payload [String] Raw request body
@@ -67,6 +71,61 @@ module Hooks
67
71
  end
68
72
  nil
69
73
  end
74
+
75
+ # Validate headers object for security issues
76
+ #
77
+ # @param headers [Object] Headers to validate
78
+ # @return [Boolean] true if headers are valid
79
+ def self.valid_headers?(headers)
80
+ unless headers.respond_to?(:each)
81
+ log.warn("Auth validation failed: Invalid headers object")
82
+ return false
83
+ end
84
+ true
85
+ end
86
+
87
+ # Validate payload size for security issues
88
+ #
89
+ # @param payload [String] Payload to validate
90
+ # @return [Boolean] true if payload is valid
91
+ def self.valid_payload_size?(payload)
92
+ return true if payload.nil?
93
+
94
+ if payload.bytesize > MAX_PAYLOAD_SIZE
95
+ log.warn("Auth validation failed: Payload size exceeds maximum limit of #{MAX_PAYLOAD_SIZE} bytes")
96
+ return false
97
+ end
98
+ true
99
+ end
100
+
101
+ # Validate header value for security issues
102
+ #
103
+ # @param header_value [String] Header value to validate
104
+ # @param header_name [String] Header name for logging
105
+ # @return [Boolean] true if header value is valid
106
+ def self.valid_header_value?(header_value, header_name)
107
+ return false if header_value.nil? || header_value.empty?
108
+
109
+ # Check length to prevent DoS
110
+ if header_value.length > MAX_HEADER_VALUE_LENGTH
111
+ log.warn("Auth validation failed: #{header_name} exceeds maximum length")
112
+ return false
113
+ end
114
+
115
+ # Check for whitespace tampering
116
+ if header_value != header_value.strip
117
+ log.warn("Auth validation failed: #{header_name} contains leading/trailing whitespace")
118
+ return false
119
+ end
120
+
121
+ # Check for control characters
122
+ if header_value.match?(/[\u0000-\u001f\u007f-\u009f]/)
123
+ log.warn("Auth validation failed: #{header_name} contains control characters")
124
+ return false
125
+ end
126
+
127
+ true
128
+ end
70
129
  end
71
130
  end
72
131
  end
@@ -32,7 +32,23 @@ module Hooks
32
32
  # format: "version=signature"
33
33
  # version_prefix: "v0"
34
34
  # payload_template: "{version}:{timestamp}:{body}"
35
+ #
36
+ # @example Configuration for Tailscale-style structured headers
37
+ # auth:
38
+ # type: HMAC
39
+ # secret_env_key: WEBHOOK_SECRET
40
+ # header: Tailscale-Webhook-Signature
41
+ # algorithm: sha256
42
+ # format: "signature_only"
43
+ # header_format: "structured"
44
+ # signature_key: "v1"
45
+ # timestamp_key: "t"
46
+ # payload_template: "{timestamp}.{body}"
47
+ # timestamp_tolerance: 300 # 5 minutes
35
48
  class HMAC < Base
49
+ # Security constants
50
+ MAX_SIGNATURE_LENGTH = ENV.fetch("HOOKS_MAX_SIGNATURE_LENGTH", 1024).to_i # Prevent DoS attacks via large signatures
51
+
36
52
  # Default configuration values for HMAC validation
37
53
  #
38
54
  # @return [Hash<Symbol, String|Integer>] Default configuration settings
@@ -42,7 +58,8 @@ module Hooks
42
58
  format: "algorithm=signature", # Format: algorithm=hash
43
59
  header: "X-Signature", # Default header containing the signature
44
60
  timestamp_tolerance: 300, # 5 minutes tolerance for timestamp validation
45
- version_prefix: "v0" # Default version prefix for versioned signatures
61
+ version_prefix: "v0", # Default version prefix for versioned signatures
62
+ header_format: "simple" # Header format: "simple" or "structured"
46
63
  }.freeze
47
64
 
48
65
  # Mapping of signature format strings to internal format symbols
@@ -75,6 +92,11 @@ module Hooks
75
92
  # @option config [String] :format ('algorithm=signature') Signature format
76
93
  # @option config [String] :version_prefix ('v0') Version prefix for versioned signatures
77
94
  # @option config [String] :payload_template Template for payload construction
95
+ # @option config [String] :header_format ('simple') Header format: 'simple' or 'structured'
96
+ # @option config [String] :signature_key ('v1') Key for signature in structured headers
97
+ # @option config [String] :timestamp_key ('t') Key for timestamp in structured headers
98
+ # @option config [String] :structured_header_separator (',') Separator for structured headers
99
+ # @option config [String] :key_value_separator ('=') Separator for key-value pairs in structured headers
78
100
  # @return [Boolean] true if signature is valid, false otherwise
79
101
  # @raise [StandardError] Rescued internally, returns false on any error
80
102
  # @note This method is designed to be safe and will never raise exceptions
@@ -91,11 +113,9 @@ module Hooks
91
113
 
92
114
  validator_config = build_config(config)
93
115
 
94
- # Security: Check raw headers BEFORE normalization to detect tampering
95
- unless headers.respond_to?(:each)
96
- log.warn("Auth::HMAC validation failed: Invalid headers object")
97
- return false
98
- end
116
+ # Security: Check raw headers and payload BEFORE processing
117
+ return false unless valid_headers?(headers)
118
+ return false unless valid_payload_size?(payload)
99
119
 
100
120
  signature_header = validator_config[:header]
101
121
 
@@ -107,23 +127,37 @@ module Hooks
107
127
  return false
108
128
  end
109
129
 
110
- # Security: Reject signatures with leading/trailing whitespace
111
- if raw_signature != raw_signature.strip
112
- log.warn("Auth::HMAC validation failed: Signature contains leading/trailing whitespace")
113
- return false
114
- end
115
-
116
- # Security: Reject signatures containing null bytes or other control characters
117
- if raw_signature.match?(/[\u0000-\u001f\u007f-\u009f]/)
118
- log.warn("Auth::HMAC validation failed: Signature contains control characters")
119
- return false
120
- end
130
+ # Validate signature format using shared validation but with HMAC-specific length limit
131
+ return false unless validate_signature_format(raw_signature)
121
132
 
122
133
  # Now we can safely normalize headers for the rest of the validation
123
134
  normalized_headers = normalize_headers(headers)
124
- provided_signature = normalized_headers[signature_header.downcase]
135
+
136
+ # Handle structured headers (e.g., Tailscale format: "t=123,v1=abc")
137
+ if validator_config[:header_format] == "structured"
138
+ parsed_signature_data = parse_structured_header(raw_signature, validator_config)
139
+ if parsed_signature_data.nil?
140
+ log.warn("Auth::HMAC validation failed: Could not parse structured signature header")
141
+ return false
142
+ end
143
+
144
+ provided_signature = parsed_signature_data[:signature]
145
+
146
+ # For structured headers, timestamp comes from the signature header itself
147
+ if parsed_signature_data[:timestamp]
148
+ normalized_headers = normalized_headers.merge(
149
+ "extracted_timestamp" => parsed_signature_data[:timestamp]
150
+ )
151
+ # Override timestamp_header to use our extracted timestamp
152
+ validator_config = validator_config.merge(timestamp_header: "extracted_timestamp")
153
+ end
154
+ else
155
+ provided_signature = normalized_headers[signature_header.downcase]
156
+ end
125
157
 
126
158
  # Validate timestamp if required (for services that include timestamp validation)
159
+ # It should be noted that not all HMAC implementations require timestamp validation,
160
+ # so this is optional based on configuration.
127
161
  if validator_config[:timestamp_header]
128
162
  unless valid_timestamp?(normalized_headers, validator_config)
129
163
  log.warn("Auth::HMAC validation failed: Invalid timestamp")
@@ -154,6 +188,22 @@ module Hooks
154
188
 
155
189
  private
156
190
 
191
+ # Validate signature format for HMAC (uses HMAC-specific length limit)
192
+ #
193
+ # @param signature [String] Raw signature to validate
194
+ # @return [Boolean] true if signature is valid
195
+ # @api private
196
+ def self.validate_signature_format(signature)
197
+ # Check signature length with HMAC-specific limit
198
+ if signature.length > MAX_SIGNATURE_LENGTH
199
+ log.warn("Auth::HMAC validation failed: Signature length exceeds maximum limit of #{MAX_SIGNATURE_LENGTH} characters")
200
+ return false
201
+ end
202
+
203
+ # Use shared validation for other checks
204
+ valid_header_value?(signature, "Signature")
205
+ end
206
+
157
207
  # Build final configuration by merging defaults with provided config
158
208
  #
159
209
  # Combines default configuration values with user-provided settings,
@@ -176,7 +226,12 @@ module Hooks
176
226
  algorithm: algorithm,
177
227
  format: validator_config[:format] || DEFAULT_CONFIG[:format],
178
228
  version_prefix: validator_config[:version_prefix] || DEFAULT_CONFIG[:version_prefix],
179
- payload_template: validator_config[:payload_template]
229
+ payload_template: validator_config[:payload_template],
230
+ header_format: validator_config[:header_format] || DEFAULT_CONFIG[:header_format],
231
+ signature_key: validator_config[:signature_key] || "v1",
232
+ timestamp_key: validator_config[:timestamp_key] || "t",
233
+ structured_header_separator: validator_config[:structured_header_separator] || ",",
234
+ key_value_separator: validator_config[:key_value_separator] || "="
180
235
  })
181
236
  end
182
237
 
@@ -321,6 +376,44 @@ module Hooks
321
376
  "#{config[:algorithm]}=#{hash}"
322
377
  end
323
378
  end
379
+
380
+ # Parse structured signature header containing comma-separated key-value pairs
381
+ #
382
+ # Parses signature headers like "t=1663781880,v1=0123456789abcdef..." used by
383
+ # providers like Tailscale that include multiple values in a single header.
384
+ #
385
+ # @param header_value [String] Raw signature header value
386
+ # @param config [Hash<Symbol, Object>] Validator configuration
387
+ # @return [Hash<Symbol, String>, nil] Parsed data with :signature and :timestamp keys, or nil if parsing fails
388
+ # @note Returns nil if the header format is invalid or required keys are missing
389
+ # @api private
390
+ def self.parse_structured_header(header_value, config)
391
+ signature_key = config[:signature_key]
392
+ timestamp_key = config[:timestamp_key]
393
+ separator = config[:structured_header_separator]
394
+ key_value_separator = config[:key_value_separator]
395
+
396
+ # Parse comma-separated key-value pairs
397
+ pairs = {}
398
+ header_value.split(separator).each do |pair|
399
+ key, value = pair.split(key_value_separator, 2)
400
+ return nil if key.nil? || value.nil?
401
+
402
+ pairs[key.strip] = value.strip
403
+ end
404
+
405
+ # Extract required signature
406
+ signature = pairs[signature_key]
407
+ return nil if signature.nil? || signature.empty?
408
+
409
+ result = { signature: signature }
410
+
411
+ # Extract optional timestamp
412
+ timestamp = pairs[timestamp_key]
413
+ result[:timestamp] = timestamp if timestamp && !timestamp.empty?
414
+
415
+ result
416
+ end
324
417
  end
325
418
  end
326
419
  end
@@ -61,11 +61,9 @@ module Hooks
61
61
 
62
62
  validator_config = build_config(config)
63
63
 
64
- # Security: Check raw headers BEFORE normalization to detect tampering
65
- unless headers.respond_to?(:each)
66
- log.warn("Auth::SharedSecret validation failed: Invalid headers object")
67
- return false
68
- end
64
+ # Security: Check raw headers and payload BEFORE processing
65
+ return false unless valid_headers?(headers)
66
+ return false unless valid_payload_size?(payload)
69
67
 
70
68
  secret_header = validator_config[:header]
71
69
 
@@ -77,19 +75,13 @@ module Hooks
77
75
  return false
78
76
  end
79
77
 
80
- stripped_secret = raw_secret.strip
81
-
82
- # Security: Reject secrets with leading/trailing whitespace
83
- if raw_secret != stripped_secret
84
- log.warn("Auth::SharedSecret validation failed: Secret contains leading/trailing whitespace")
78
+ # Validate secret format using shared validation
79
+ unless valid_header_value?(raw_secret, "Secret")
80
+ log.warn("Auth::SharedSecret validation failed: Invalid secret format")
85
81
  return false
86
82
  end
87
83
 
88
- # Security: Reject secrets containing null bytes or other control characters
89
- if raw_secret.match?(/[\u0000-\u001f\u007f-\u009f]/)
90
- log.warn("Auth::SharedSecret validation failed: Secret contains control characters")
91
- return false
92
- end
84
+ stripped_secret = raw_secret.strip
93
85
 
94
86
  # Use secure comparison to prevent timing attacks
95
87
  result = Rack::Utils.secure_compare(secret, stripped_secret)
@@ -106,12 +98,6 @@ module Hooks
106
98
 
107
99
  private
108
100
 
109
- # Short logger accessor for auth module
110
- # @return [Hooks::Log] Logger instance
111
- def self.log
112
- Hooks::Log.instance
113
- end
114
-
115
101
  # Build final configuration by merging defaults with provided config
116
102
  #
117
103
  # Combines default configuration values with user-provided settings,
data/lib/hooks/version.rb CHANGED
@@ -4,5 +4,5 @@
4
4
  module Hooks
5
5
  # Current version of the Hooks webhook framework
6
6
  # @return [String] The version string following semantic versioning
7
- VERSION = "0.2.0".freeze
7
+ VERSION = "0.2.1".freeze
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hooks-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - github