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 +4 -4
- data/lib/hooks/core/config_validator.rb +5 -0
- data/lib/hooks/plugins/auth/base.rb +59 -0
- data/lib/hooks/plugins/auth/hmac.rb +112 -19
- data/lib/hooks/plugins/auth/shared_secret.rb +7 -21
- data/lib/hooks/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 47555e043e83129f208e5925c22dbbf12aadba0358a70272461952355251c869
|
4
|
+
data.tar.gz: b2760979c328b79cf82fe8b5a37a67261f2b5085208488ca928662e1915d9f06
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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"
|
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
|
95
|
-
unless
|
96
|
-
|
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
|
-
#
|
111
|
-
|
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
|
-
|
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
|
65
|
-
unless
|
66
|
-
|
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
|
-
|
81
|
-
|
82
|
-
|
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
|
-
|
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