hooks-ruby 0.3.0 → 0.3.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: efc93a22af173a3085d2af45a0787501cf4e520e0aa917c7b4b8ee477a477820
4
- data.tar.gz: 06c6177f4356d06e59ceaf2f7e9c62848752ee1a5d298981c2820c1732906424
3
+ metadata.gz: 8cd5b56450a97783c263526f9a1a9e3003f1ed587a0da12bfb7a21b428091d2b
4
+ data.tar.gz: 67954e277623e99daafc8b3d86c9bdfa62b871319f4bff759789cd32048e752f
5
5
  SHA512:
6
- metadata.gz: e4bed95c7e1f41cd7188847773b83e1d7e1a463122545cc5c0330348539f81b2ed254c9e7ff161b2922b8f064ec46174288b3557be4c2d63604405ab931adb0d
7
- data.tar.gz: fe7cdde0b508f5bcf1e354f6c3b2583530ad27f27d63686712d05830581b849fc2d8e61cad5ba1dd8d4283e182b823625d4e164bd9fa4909ae6be7be3d0ce360
6
+ metadata.gz: 41cbc5a653f452e9c6da4ffd526a1b7ea37532712ae820223ccbdb93809789e949d4370fe5ba18036a0a08b8446280ff44471088703c296f769d8c83b6003cfa
7
+ data.tar.gz: 99619a010a1fca90a1bda4f78850c66f705390be9677a1006cfdf72904e9ae0443bd1c0fed02dbbe014515c4df707a84b305460e111eb086fca76af3dec1ad61
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
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.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.3.0
4
+ version: 0.3.1
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