hooks-ruby 0.2.1 → 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/README.md +8 -6
- data/lib/hooks/app/api.rb +8 -0
- data/lib/hooks/app/helpers.rb +24 -1
- data/lib/hooks/app/rack_env_builder.rb +2 -0
- data/lib/hooks/core/config_validator.rb +17 -4
- data/lib/hooks/core/network/ip_filtering.rb +270 -0
- data/lib/hooks/core/plugin_loader.rb +4 -2
- data/lib/hooks/plugins/auth/base.rb +8 -0
- data/lib/hooks/plugins/auth/hmac.rb +0 -9
- 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/README.md
CHANGED
@@ -50,9 +50,11 @@ Here is a very high-level overview of how Hooks works:
|
|
50
50
|
```yaml
|
51
51
|
# file: config/endpoints/hello.yml
|
52
52
|
path: /hello
|
53
|
-
handler:
|
53
|
+
handler: my_custom_handler # This is a custom handler plugin you would define in the plugins/handlers directory (snake_case)
|
54
54
|
```
|
55
55
|
|
56
|
+
> Note: If your handler's class name is `MyCustomHandler`, you would define it in the `plugins/handlers/my_custom_handler.rb` file. The `handler` field in the endpoint configuration file should be the snake_case version of the class name. So if your handler class is `MyCustomHandler`, you would use `my_custom_handler` in the endpoint configuration file.
|
57
|
+
|
56
58
|
3. Now create a corresponding handler plugin in the `plugins/handlers` directory. Here is an example of a simple handler plugin:
|
57
59
|
|
58
60
|
```ruby
|
@@ -64,7 +66,7 @@ Here is a very high-level overview of how Hooks works:
|
|
64
66
|
# For this example, we will just return a success message
|
65
67
|
{
|
66
68
|
status: "success",
|
67
|
-
handler: "
|
69
|
+
handler: "my_custom_handler",
|
68
70
|
payload_received: payload,
|
69
71
|
timestamp: Time.now.utc.iso8601
|
70
72
|
}
|
@@ -208,16 +210,16 @@ Endpoint configurations are defined in the `config/endpoints` directory. Each en
|
|
208
210
|
```yaml
|
209
211
|
# file: config/endpoints/hello.yml
|
210
212
|
path: /hello # becomes /webhooks/hello based on the root_path in hooks.yml
|
211
|
-
handler:
|
213
|
+
handler: hello_handler # This is a custom handler plugin you would define in the plugins/handlers
|
212
214
|
```
|
213
215
|
|
214
216
|
```yaml
|
215
217
|
# file: config/endpoints/goodbye.yml
|
216
218
|
path: /goodbye # becomes /webhooks/goodbye based on the root_path in hooks.yml
|
217
|
-
handler:
|
219
|
+
handler: goodbye_handler # This is another custom handler plugin you would define in the plugins/handlers
|
218
220
|
|
219
221
|
auth:
|
220
|
-
type:
|
222
|
+
type: goodbye # This is a custom authentication plugin you would define in the plugins/auth
|
221
223
|
secret_env_key: GOODBYE_API_KEY # the name of the environment variable containing the secret
|
222
224
|
header: Authorization
|
223
225
|
|
@@ -255,7 +257,7 @@ class GoodbyeHandler < Hooks::Plugins::Handlers::Base
|
|
255
257
|
# Ditto for the goodbye endpoint
|
256
258
|
{
|
257
259
|
message: "goodbye webhook processed successfully",
|
258
|
-
handler: "
|
260
|
+
handler: "goodbye_handler",
|
259
261
|
timestamp: Time.now.utc.iso8601
|
260
262
|
}
|
261
263
|
end
|
data/lib/hooks/app/api.rb
CHANGED
@@ -4,6 +4,7 @@ require "grape"
|
|
4
4
|
require "json"
|
5
5
|
require "securerandom"
|
6
6
|
require_relative "helpers"
|
7
|
+
require_relative "../core/network/ip_filtering"
|
7
8
|
require_relative "auth/auth"
|
8
9
|
require_relative "rack_env_builder"
|
9
10
|
require_relative "../plugins/handlers/base"
|
@@ -82,6 +83,13 @@ module Hooks
|
|
82
83
|
plugin.on_request(rack_env)
|
83
84
|
end
|
84
85
|
|
86
|
+
# IP filtering before processing the request if defined
|
87
|
+
# If IP filtering is enabled at either global or endpoint level, run the filtering rules
|
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
|
92
|
+
|
85
93
|
enforce_request_limits(config, request_context)
|
86
94
|
request.body.rewind
|
87
95
|
raw_body = request.body.read
|
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
|
@@ -74,7 +75,7 @@ module Hooks
|
|
74
75
|
|
75
76
|
# Load handler class
|
76
77
|
#
|
77
|
-
# @param handler_class_name [String] The name of the handler
|
78
|
+
# @param handler_class_name [String] The name of the handler in snake_case (e.g., "github_handler")
|
78
79
|
# @return [Object] An instance of the loaded handler class
|
79
80
|
# @raise [StandardError] If handler cannot be found
|
80
81
|
def load_handler(handler_class_name)
|
@@ -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
|
@@ -72,6 +72,8 @@ module Hooks
|
|
72
72
|
end
|
73
73
|
|
74
74
|
# Add HTTP headers to the environment with proper Rack naming convention
|
75
|
+
# Note: This will generally add headers like HTTP_X_CUSTOM_HEADER. For example, the HTTP_X_FORWARDED_FOR
|
76
|
+
# is a common header that is used to pass the original client IP address through proxies.
|
75
77
|
#
|
76
78
|
# @param rack_env [Hash] Environment hash to modify
|
77
79
|
def add_http_headers(rack_env)
|
@@ -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
|
|
@@ -125,11 +137,12 @@ module Hooks
|
|
125
137
|
# Must not be empty or only whitespace
|
126
138
|
return false if handler_name.strip.empty?
|
127
139
|
|
128
|
-
# Must match
|
129
|
-
return false unless handler_name.match?(/\A[
|
140
|
+
# Must match strict snake_case pattern: starts with lowercase, no trailing/consecutive underscores
|
141
|
+
return false unless handler_name.match?(/\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/)
|
130
142
|
|
131
|
-
#
|
132
|
-
|
143
|
+
# Convert to PascalCase for security check (since DANGEROUS_CLASSES uses PascalCase)
|
144
|
+
pascal_case_name = handler_name.split("_").map(&:capitalize).join("")
|
145
|
+
return false if Hooks::Security::DANGEROUS_CLASSES.include?(pascal_case_name)
|
133
146
|
|
134
147
|
true
|
135
148
|
end
|
@@ -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
|
@@ -61,11 +61,13 @@ module Hooks
|
|
61
61
|
|
62
62
|
# Get handler plugin class by name
|
63
63
|
#
|
64
|
-
# @param handler_name [String] Name of the handler (e.g., "
|
64
|
+
# @param handler_name [String] Name of the handler in snake_case (e.g., "github_handler", "team_1_handler")
|
65
65
|
# @return [Class] The handler plugin class
|
66
66
|
# @raise [StandardError] if handler not found
|
67
67
|
def get_handler_plugin(handler_name)
|
68
|
-
|
68
|
+
# Convert snake_case to PascalCase for registry lookup
|
69
|
+
pascal_case_name = handler_name.split("_").map(&:capitalize).join("")
|
70
|
+
plugin_class = @handler_plugins[pascal_case_name]
|
69
71
|
|
70
72
|
unless plugin_class
|
71
73
|
raise StandardError, "Handler plugin '#{handler_name}' not found. Available handlers: #{@handler_plugins.keys.join(', ')}"
|
@@ -4,6 +4,7 @@ require "rack/utils"
|
|
4
4
|
require_relative "../../core/log"
|
5
5
|
require_relative "../../core/global_components"
|
6
6
|
require_relative "../../core/component_access"
|
7
|
+
require_relative "timestamp_validator"
|
7
8
|
|
8
9
|
module Hooks
|
9
10
|
module Plugins
|
@@ -53,6 +54,13 @@ module Hooks
|
|
53
54
|
return secret.strip
|
54
55
|
end
|
55
56
|
|
57
|
+
# Get timestamp validator instance
|
58
|
+
#
|
59
|
+
# @return [TimestampValidator] Singleton timestamp validator instance
|
60
|
+
def self.timestamp_validator
|
61
|
+
TimestampValidator.new
|
62
|
+
end
|
63
|
+
|
56
64
|
# Find a header value by name with case-insensitive matching
|
57
65
|
#
|
58
66
|
# @param headers [Hash] HTTP headers from the request
|
@@ -3,7 +3,6 @@
|
|
3
3
|
require "openssl"
|
4
4
|
require "time"
|
5
5
|
require_relative "base"
|
6
|
-
require_relative "timestamp_validator"
|
7
6
|
|
8
7
|
module Hooks
|
9
8
|
module Plugins
|
@@ -271,14 +270,6 @@ module Hooks
|
|
271
270
|
timestamp_validator.valid?(timestamp_value, tolerance)
|
272
271
|
end
|
273
272
|
|
274
|
-
# Get timestamp validator instance
|
275
|
-
#
|
276
|
-
# @return [TimestampValidator] Singleton timestamp validator instance
|
277
|
-
# @api private
|
278
|
-
def self.timestamp_validator
|
279
|
-
@timestamp_validator ||= TimestampValidator.new
|
280
|
-
end
|
281
|
-
|
282
273
|
# Compute HMAC signature based on configuration requirements
|
283
274
|
#
|
284
275
|
# Generates the expected HMAC signature for the given payload using the
|
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.
|
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
|