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 +4 -4
- data/lib/hooks/app/api.rb +5 -5
- data/lib/hooks/app/helpers.rb +23 -0
- data/lib/hooks/core/config_validator.rb +12 -0
- data/lib/hooks/core/network/ip_filtering.rb +270 -0
- data/lib/hooks/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8cd5b56450a97783c263526f9a1a9e3003f1ed587a0da12bfb7a21b428091d2b
|
4
|
+
data.tar.gz: 67954e277623e99daafc8b3d86c9bdfa62b871319f4bff759789cd32048e752f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
-
#
|
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
|
-
|
90
|
-
|
91
|
-
|
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
|
data/lib/hooks/app/helpers.rb
CHANGED
@@ -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
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.
|
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
|