hooks-ruby 0.3.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: efc93a22af173a3085d2af45a0787501cf4e520e0aa917c7b4b8ee477a477820
4
- data.tar.gz: 06c6177f4356d06e59ceaf2f7e9c62848752ee1a5d298981c2820c1732906424
3
+ metadata.gz: 1f6bca4c82b0d3c507e2f772257a0e6ed5a30a072bb51b61da45d1c2688d99ba
4
+ data.tar.gz: c0d8a9f0aea0b3c5f039b7cb3354d418b79fab87aa14a4738ffb7ee04b554a2d
5
5
  SHA512:
6
- metadata.gz: e4bed95c7e1f41cd7188847773b83e1d7e1a463122545cc5c0330348539f81b2ed254c9e7ff161b2922b8f064ec46174288b3557be4c2d63604405ab931adb0d
7
- data.tar.gz: fe7cdde0b508f5bcf1e354f6c3b2583530ad27f27d63686712d05830581b849fc2d8e61cad5ba1dd8d4283e182b823625d4e164bd9fa4909ae6be7be3d0ce360
6
+ metadata.gz: 0afe8a3b475163b154d7016b939fcf4fcdd40312534ea732018cc8cfe407823b3266d7c31881d010725bfa670927dfe6e6fd84663ca68ccb54dc27179fb01293
7
+ data.tar.gz: '04844e2020cc32371f889986c1eadc5c4cbd83bcac06a566466784ff306525e02713eb7f75838a304aa283b230b08b61760dc80f1e4fef993b2d163aaf587fbd'
data/lib/hooks/app/api.rb CHANGED
@@ -4,7 +4,7 @@ require "grape"
4
4
  require "json"
5
5
  require "securerandom"
6
6
  require_relative "helpers"
7
- #require_relative "network/ip_filtering"
7
+ require_relative "../core/network/ip_filtering"
8
8
  require_relative "auth/auth"
9
9
  require_relative "rack_env_builder"
10
10
  require_relative "../plugins/handlers/base"
@@ -83,12 +83,12 @@ module Hooks
83
83
  plugin.on_request(rack_env)
84
84
  end
85
85
 
86
- # TODO: IP filtering before processing the request if defined
86
+ # IP filtering before processing the request if defined
87
87
  # If IP filtering is enabled at either global or endpoint level, run the filtering rules
88
88
  # before processing the request
89
- #if config[:ip_filtering] || endpoint_config[:ip_filtering]
90
- #ip_filtering!(headers, endpoint_config, config, request_context, rack_env)
91
- #end
89
+ if config[:ip_filtering] || endpoint_config[:ip_filtering]
90
+ ip_filtering!(headers, endpoint_config, config, request_context, rack_env)
91
+ end
92
92
 
93
93
  enforce_request_limits(config, request_context)
94
94
  request.body.rewind
@@ -3,6 +3,7 @@
3
3
  require "securerandom"
4
4
  require_relative "../security"
5
5
  require_relative "../core/plugin_loader"
6
+ require_relative "../core/network/ip_filtering"
6
7
 
7
8
  module Hooks
8
9
  module App
@@ -88,6 +89,28 @@ module Hooks
88
89
  return handler_class.new
89
90
  end
90
91
 
92
+ # Verifies the incoming request passes the configured IP filtering rules.
93
+ #
94
+ # This method assumes that the client IP address is available in the request headers (e.g., `X-Forwarded-For`).
95
+ # The headers that is used is configurable via the endpoint configuration.
96
+ # It checks the IP address against the allowed and denied lists defined in the endpoint configuration.
97
+ # If the IP address is not allowed, it instantly returns an error response via the `error!` method.
98
+ # If the IP filtering configuration is missing or invalid, it raises an error.
99
+ # If IP filtering is configured at the global level, it will also check against the global configuration first,
100
+ # and then against the endpoint-specific configuration.
101
+ #
102
+ # @param headers [Hash] The request headers.
103
+ # @param endpoint_config [Hash] The endpoint configuration, must include :ip_filtering key.
104
+ # @param global_config [Hash] The global configuration (optional, for compatibility).
105
+ # @param request_context [Hash] Context for the request, e.g. request ID, path, handler (optional).
106
+ # @param env [Hash] The Rack environment
107
+ # @raise [StandardError] Raises error if IP filtering fails or is misconfigured.
108
+ # @return [void]
109
+ # @note This method will halt execution with an error if IP filtering rules fail.
110
+ def ip_filtering!(headers, endpoint_config, global_config, request_context, env)
111
+ Hooks::Core::Network::IpFiltering.ip_filtering!(headers, endpoint_config, global_config, request_context, env)
112
+ end
113
+
91
114
  private
92
115
 
93
116
  # Safely parse JSON
@@ -27,6 +27,12 @@ module Hooks
27
27
  optional(:endpoints_dir).filled(:string)
28
28
  optional(:use_catchall_route).filled(:bool)
29
29
  optional(:normalize_headers).filled(:bool)
30
+
31
+ optional(:ip_filtering).hash do
32
+ optional(:ip_header).filled(:string)
33
+ optional(:allowlist).array(:string)
34
+ optional(:blocklist).array(:string)
35
+ end
30
36
  end
31
37
 
32
38
  # Endpoint configuration schema
@@ -52,6 +58,12 @@ module Hooks
52
58
  optional(:key_value_separator).filled(:string)
53
59
  end
54
60
 
61
+ optional(:ip_filtering).hash do
62
+ optional(:ip_header).filled(:string)
63
+ optional(:allowlist).array(:string)
64
+ optional(:blocklist).array(:string)
65
+ end
66
+
55
67
  optional(:opts).hash
56
68
  end
57
69
 
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require_relative "../../plugins/handlers/error"
5
+
6
+ module Hooks
7
+ module Core
8
+ module Network
9
+ # Application-level IP filtering functionality for HTTP requests.
10
+ #
11
+ # This class provides robust IP filtering capabilities supporting both allowlist
12
+ # and blocklist filtering with CIDR notation support. It can extract client IP
13
+ # addresses from various HTTP headers and validate them against configured rules.
14
+ #
15
+ # The filtering logic follows these rules:
16
+ # 1. If a blocklist is configured and the IP matches, access is denied
17
+ # 2. If an allowlist is configured, the IP must match to be allowed
18
+ # 3. If no allowlist is configured and IP is not blocked, access is allowed
19
+ #
20
+ # @example Basic usage with endpoint configuration
21
+ # config = {
22
+ # ip_filtering: {
23
+ # allowlist: ["192.168.1.0/24", "10.0.0.1"],
24
+ # blocklist: ["192.168.1.100"],
25
+ # ip_header: "X-Real-IP"
26
+ # }
27
+ # }
28
+ # IpFiltering.ip_filtering!(headers, config, {}, {}, env)
29
+ #
30
+ # @note This class is designed to work with Rack-based applications and
31
+ # expects headers to be in a Hash format.
32
+ class IpFiltering
33
+ # Default HTTP header to check for client IP address.
34
+ # @return [String] the default header name
35
+ DEFAULT_IP_HEADER = "X-Forwarded-For"
36
+
37
+ # Verifies that an incoming request passes the configured IP filtering rules.
38
+ #
39
+ # This method extracts the client IP address from request headers and validates
40
+ # it against configured allowlist and blocklist rules. The method will halt
41
+ # execution by raising an error if the IP filtering rules fail.
42
+ #
43
+ # The IP filtering configuration can be defined at both global and endpoint levels,
44
+ # with endpoint configuration taking precedence. If no IP filtering is configured,
45
+ # the method returns early without performing any checks.
46
+ #
47
+ # The client IP is extracted from HTTP headers, with support for configurable
48
+ # header names. The default header is X-Forwarded-For, which can contain multiple
49
+ # comma-separated IPs (the first IP is used as the original client).
50
+ #
51
+ # @param headers [Hash] The request headers as key-value pairs
52
+ # @param endpoint_config [Hash] The endpoint-specific configuration containing :ip_filtering
53
+ # @param global_config [Hash] The global configuration (optional, for compatibility)
54
+ # @param request_context [Hash] Context information for the request (e.g., request_id, path, handler)
55
+ # @param env [Hash] The Rack environment hash
56
+ #
57
+ # @raise [Hooks::Plugins::Handlers::Error] Raises a 403 error if IP filtering rules fail
58
+ # @return [void] Returns nothing if IP filtering passes or is not configured
59
+ #
60
+ # @example Successful IP filtering
61
+ # headers = { "X-Forwarded-For" => "192.168.1.50" }
62
+ # config = { ip_filtering: { allowlist: ["192.168.1.0/24"] } }
63
+ # IpFiltering.ip_filtering!(headers, config, {}, { request_id: "123" }, env)
64
+ #
65
+ # @example IP filtering failure
66
+ # headers = { "X-Forwarded-For" => "10.0.0.1" }
67
+ # config = { ip_filtering: { allowlist: ["192.168.1.0/24"] } }
68
+ # # Raises Hooks::Plugins::Handlers::Error with 403 status
69
+ # IpFiltering.ip_filtering!(headers, config, {}, { request_id: "123" }, env)
70
+ #
71
+ # @note This method assumes that the client IP address is available in the request headers
72
+ # @note If the IP filtering configuration is missing or invalid, it raises an error
73
+ # @note This method will halt execution with an error if IP filtering rules fail
74
+ def self.ip_filtering!(headers, endpoint_config, global_config, request_context, env)
75
+ # Determine which IP filtering configuration to use
76
+ ip_config = resolve_ip_config(endpoint_config, global_config)
77
+ return unless ip_config # No IP filtering configured
78
+
79
+ # Extract client IP from headers
80
+ client_ip = extract_client_ip(headers, ip_config)
81
+ return unless client_ip # No client IP found
82
+
83
+ # Validate IP against filtering rules
84
+ unless ip_allowed?(client_ip, ip_config)
85
+ request_id = request_context&.dig(:request_id) || request_context&.dig("request_id")
86
+ error_msg = {
87
+ error: "ip_filtering_failed",
88
+ message: "IP address not allowed",
89
+ request_id: request_id
90
+ }
91
+ raise Hooks::Plugins::Handlers::Error.new(error_msg, 403)
92
+ end
93
+ end
94
+
95
+ # Resolves the IP filtering configuration to use for the current request.
96
+ #
97
+ # This method determines which IP filtering configuration should be applied
98
+ # by checking endpoint-specific configuration first, then falling back to
99
+ # global configuration. This allows for flexible configuration inheritance
100
+ # with endpoint-level overrides.
101
+ #
102
+ # @param endpoint_config [Hash] The endpoint-specific configuration
103
+ # @param global_config [Hash] The global application configuration
104
+ #
105
+ # @return [Hash, nil] The IP filtering configuration hash, or nil if none configured
106
+ #
107
+ # @example With endpoint configuration
108
+ # endpoint_config = { ip_filtering: { allowlist: ["192.168.1.0/24"] } }
109
+ # global_config = { ip_filtering: { allowlist: ["10.0.0.0/8"] } }
110
+ # resolve_ip_config(endpoint_config, global_config)
111
+ # # => { allowlist: ["192.168.1.0/24"] }
112
+ #
113
+ # @example With only global configuration
114
+ # endpoint_config = {}
115
+ # global_config = { ip_filtering: { allowlist: ["10.0.0.0/8"] } }
116
+ # resolve_ip_config(endpoint_config, global_config)
117
+ # # => { allowlist: ["10.0.0.0/8"] }
118
+ #
119
+ # @note Endpoint-level configuration takes precedence over global configuration
120
+ private_class_method def self.resolve_ip_config(endpoint_config, global_config)
121
+ # Endpoint-level configuration takes precedence over global configuration
122
+ endpoint_config[:ip_filtering] || global_config[:ip_filtering]
123
+ end
124
+
125
+ # Extracts the client IP address from request headers.
126
+ #
127
+ # This method looks for the client IP in the specified header (or default
128
+ # X-Forwarded-For header). It performs case-insensitive header matching
129
+ # and handles comma-separated IP lists by taking the first IP address,
130
+ # which represents the original client in proxy chains.
131
+ #
132
+ # @param headers [Hash] The request headers as key-value pairs
133
+ # @param ip_config [Hash] The IP filtering configuration containing :ip_header
134
+ #
135
+ # @return [String, nil] The client IP address, or nil if not found or empty
136
+ #
137
+ # @example Extracting from X-Forwarded-For
138
+ # headers = { "X-Forwarded-For" => "192.168.1.50, 10.0.0.1" }
139
+ # ip_config = { ip_header: "X-Forwarded-For" }
140
+ # extract_client_ip(headers, ip_config)
141
+ # # => "192.168.1.50"
142
+ #
143
+ # @example Extracting from custom header
144
+ # headers = { "X-Real-IP" => "203.0.113.45" }
145
+ # ip_config = { ip_header: "X-Real-IP" }
146
+ # extract_client_ip(headers, ip_config)
147
+ # # => "203.0.113.45"
148
+ #
149
+ # @note Case-insensitive header lookup is performed
150
+ # @note For comma-separated IP lists, only the first IP is returned
151
+ private_class_method def self.extract_client_ip(headers, ip_config)
152
+ # Use configured header or default to X-Forwarded-For
153
+ ip_header = ip_config[:ip_header] || DEFAULT_IP_HEADER
154
+
155
+ # Case-insensitive header lookup
156
+ headers.each do |key, value|
157
+ if key.to_s.downcase == ip_header.downcase
158
+ # X-Forwarded-For can contain multiple IPs, take the first one (original client)
159
+ client_ip = value.to_s.split(",").first&.strip
160
+ return client_ip unless client_ip.nil? || client_ip.empty?
161
+ end
162
+ end
163
+
164
+ nil
165
+ end
166
+
167
+ # Determines if a client IP address is allowed based on filtering rules.
168
+ #
169
+ # This method implements the core IP filtering logic by checking the client
170
+ # IP against configured blocklist and allowlist rules. The filtering follows
171
+ # these precedence rules:
172
+ # 1. If blocklist exists and IP matches, deny access (return false)
173
+ # 2. If allowlist exists, IP must match to be allowed (return true/false)
174
+ # 3. If no allowlist exists and IP not blocked, allow access (return true)
175
+ #
176
+ # @param client_ip [String] The client IP address to validate
177
+ # @param ip_config [Hash] The IP filtering configuration containing :blocklist and/or :allowlist
178
+ #
179
+ # @return [Boolean] true if IP is allowed, false if blocked or invalid
180
+ #
181
+ # @example IP allowed by allowlist
182
+ # client_ip = "192.168.1.50"
183
+ # ip_config = { allowlist: ["192.168.1.0/24"] }
184
+ # ip_allowed?(client_ip, ip_config)
185
+ # # => true
186
+ #
187
+ # @example IP blocked by blocklist
188
+ # client_ip = "192.168.1.100"
189
+ # ip_config = { blocklist: ["192.168.1.100"] }
190
+ # ip_allowed?(client_ip, ip_config)
191
+ # # => false
192
+ #
193
+ # @example Invalid IP format
194
+ # client_ip = "invalid-ip"
195
+ # ip_config = { allowlist: ["192.168.1.0/24"] }
196
+ # ip_allowed?(client_ip, ip_config)
197
+ # # => false
198
+ #
199
+ # @note Invalid IP addresses are automatically denied
200
+ # @note Blocklist rules take precedence over allowlist rules
201
+ private_class_method def self.ip_allowed?(client_ip, ip_config)
202
+ # Parse client IP
203
+ begin
204
+ client_addr = IPAddr.new(client_ip)
205
+ rescue IPAddr::InvalidAddressError
206
+ return false # Invalid IP format
207
+ end
208
+
209
+ # Check blocklist first (if IP is blocked, deny immediately)
210
+ if ip_config[:blocklist]&.any?
211
+ return false if ip_matches_list?(client_addr, ip_config[:blocklist])
212
+ end
213
+
214
+ # Check allowlist (if defined, IP must be in allowlist)
215
+ if ip_config[:allowlist]&.any?
216
+ return ip_matches_list?(client_addr, ip_config[:allowlist])
217
+ end
218
+
219
+ # If no allowlist is defined and IP is not in blocklist, allow
220
+ true
221
+ end
222
+
223
+ # Checks if a client IP address matches any pattern in an IP list.
224
+ #
225
+ # This method iterates through a list of IP patterns (which can include
226
+ # individual IPs or CIDR ranges) and determines if the client IP matches
227
+ # any of them. It uses Ruby's IPAddr class for robust IP address and
228
+ # CIDR range matching, with error handling for invalid IP patterns.
229
+ #
230
+ # @param client_addr [IPAddr] The client IP address as an IPAddr object
231
+ # @param ip_list [Array<String>] Array of IP patterns (IPs or CIDR ranges)
232
+ #
233
+ # @return [Boolean] true if client IP matches any pattern in the list, false otherwise
234
+ #
235
+ # @example Matching individual IP
236
+ # client_addr = IPAddr.new("192.168.1.50")
237
+ # ip_list = ["192.168.1.50", "10.0.0.1"]
238
+ # ip_matches_list?(client_addr, ip_list)
239
+ # # => true
240
+ #
241
+ # @example Matching CIDR range
242
+ # client_addr = IPAddr.new("192.168.1.50")
243
+ # ip_list = ["192.168.1.0/24", "10.0.0.0/8"]
244
+ # ip_matches_list?(client_addr, ip_list)
245
+ # # => true
246
+ #
247
+ # @example No match found
248
+ # client_addr = IPAddr.new("203.0.113.45")
249
+ # ip_list = ["192.168.1.0/24", "10.0.0.0/8"]
250
+ # ip_matches_list?(client_addr, ip_list)
251
+ # # => false
252
+ #
253
+ # @note Invalid IP patterns in the list are silently skipped
254
+ # @note Supports both IPv4 and IPv6 addresses and ranges
255
+ private_class_method def self.ip_matches_list?(client_addr, ip_list)
256
+ ip_list.each do |ip_pattern|
257
+ begin
258
+ pattern_addr = IPAddr.new(ip_pattern.to_s)
259
+ return true if pattern_addr.include?(client_addr)
260
+ rescue IPAddr::InvalidAddressError
261
+ # Skip invalid IP patterns
262
+ next
263
+ end
264
+ end
265
+ false
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -205,7 +205,11 @@ module Hooks
205
205
  require file_path
206
206
 
207
207
  # Get the class and validate it
208
- auth_plugin_class = Object.const_get("Hooks::Plugins::Auth::#{class_name}")
208
+ auth_plugin_class = begin
209
+ Hooks::Plugins::Auth.const_get(class_name, false) # false = don't inherit from ancestors
210
+ rescue NameError
211
+ raise StandardError, "Auth plugin class not found in Hooks::Plugins::Auth namespace: #{class_name}"
212
+ end
209
213
  unless auth_plugin_class < Hooks::Plugins::Auth::Base
210
214
  raise StandardError, "Auth plugin class must inherit from Hooks::Plugins::Auth::Base: #{class_name}"
211
215
  end
@@ -239,8 +243,13 @@ module Hooks
239
243
  # Load the file
240
244
  require file_path
241
245
 
242
- # Get the class and validate it
243
- handler_class = Object.const_get(class_name)
246
+ # Get the class and validate it - use safe constant lookup
247
+ handler_class = begin
248
+ # Check if the constant exists in the global namespace for handlers
249
+ Object.const_get(class_name, false) # false = don't inherit from ancestors
250
+ rescue NameError
251
+ raise StandardError, "Handler class not found: #{class_name}"
252
+ end
244
253
  unless handler_class < Hooks::Plugins::Handlers::Base
245
254
  raise StandardError, "Handler class must inherit from Hooks::Plugins::Handlers::Base: #{class_name}"
246
255
  end
@@ -274,8 +283,12 @@ module Hooks
274
283
  # Load the file
275
284
  require file_path
276
285
 
277
- # Get the class and validate it
278
- lifecycle_class = Object.const_get(class_name)
286
+ # Get the class and validate it - use safe constant lookup
287
+ lifecycle_class = begin
288
+ Object.const_get(class_name, false) # false = don't inherit from ancestors
289
+ rescue NameError
290
+ raise StandardError, "Lifecycle plugin class not found: #{class_name}"
291
+ end
279
292
  unless lifecycle_class < Hooks::Plugins::Lifecycle
280
293
  raise StandardError, "Lifecycle plugin class must inherit from Hooks::Plugins::Lifecycle: #{class_name}"
281
294
  end
@@ -309,8 +322,12 @@ module Hooks
309
322
  # Load the file
310
323
  require file_path
311
324
 
312
- # Get the class and validate it
313
- instrument_class = Object.const_get(class_name)
325
+ # Get the class and validate it - use safe constant lookup
326
+ instrument_class = begin
327
+ Object.const_get(class_name, false) # false = don't inherit from ancestors
328
+ rescue NameError
329
+ raise StandardError, "Instrument plugin class not found: #{class_name}"
330
+ end
314
331
 
315
332
  # Determine instrument type based on inheritance
316
333
  if instrument_class < Hooks::Plugins::Instruments::StatsBase
@@ -68,23 +68,21 @@ module Hooks
68
68
  secret_header = validator_config[:header]
69
69
 
70
70
  # Find the secret header with case-insensitive matching
71
- raw_secret = find_header_value(headers, secret_header)
71
+ provided_secret = find_header_value(headers, secret_header)
72
72
 
73
- if raw_secret.nil? || raw_secret.empty?
73
+ if provided_secret.nil? || provided_secret.empty?
74
74
  log.warn("Auth::SharedSecret validation failed: Missing or empty secret header '#{secret_header}'")
75
75
  return false
76
76
  end
77
77
 
78
78
  # Validate secret format using shared validation
79
- unless valid_header_value?(raw_secret, "Secret")
79
+ unless valid_header_value?(provided_secret, "Secret")
80
80
  log.warn("Auth::SharedSecret validation failed: Invalid secret format")
81
81
  return false
82
82
  end
83
83
 
84
- stripped_secret = raw_secret.strip
85
-
86
84
  # Use secure comparison to prevent timing attacks
87
- result = Rack::Utils.secure_compare(secret, stripped_secret)
85
+ result = Rack::Utils.secure_compare(secret, provided_secret)
88
86
  if result
89
87
  log.debug("Auth::SharedSecret validation successful for header '#{secret_header}'")
90
88
  else
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.3.0".freeze
7
+ VERSION = "0.3.2".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.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - github
@@ -138,6 +138,7 @@ files:
138
138
  - lib/hooks/core/global_components.rb
139
139
  - lib/hooks/core/log.rb
140
140
  - lib/hooks/core/logger_factory.rb
141
+ - lib/hooks/core/network/ip_filtering.rb
141
142
  - lib/hooks/core/plugin_loader.rb
142
143
  - lib/hooks/core/stats.rb
143
144
  - lib/hooks/plugins/auth/base.rb