66module Hooks
77 module App
88 module Network
9- # Application-level IP filtering functionality
10- # Provides both allowlist and blocklist filtering with CIDR support
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.
1132 class IpFiltering
12- # Default IP header to check for client IP
33+ # Default HTTP header to check for client IP address.
34+ # @return [String] the default header name
1335 DEFAULT_IP_HEADER = "X-Forwarded-For"
1436
15- # Verifies the incoming request passes the configured IP filtering rules.
16- #
17- # This method assumes that the client IP address is available in the request headers (e.g., `X-Forwarded-For`).
18- # The headers that is used is configurable via the endpoint configuration.
19- # It checks the IP address against the allowed and denied lists defined in the endpoint configuration.
20- # If the IP address is not allowed, it instantly returns an error response via the `error!` method.
21- # If the IP filtering configuration is missing or invalid, it raises an error.
22- # If IP filtering is configured at the global level, it will also check against the global configuration first,
23- # and then against the endpoint-specific configuration.
24- #
25- # @param headers [Hash] The request headers.
26- # @param endpoint_config [Hash] The endpoint configuration, must include :ip_filtering key.
27- # @param global_config [Hash] The global configuration (optional, for compatibility).
28- # @param request_context [Hash] Context for the request, e.g. request ID, path, handler (optional).
29- # @param env [Hash] The Rack environment
30- # @raise [StandardError] Raises error if IP filtering fails or is misconfigured.
31- # @return [void]
32- # @note This method will halt execution with an error if IP filtering rules fail.
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
3374 def self . ip_filtering! ( headers , endpoint_config , global_config , request_context , env )
3475 # Determine which IP filtering configuration to use
3576 ip_config = resolve_ip_config ( endpoint_config , global_config )
@@ -51,11 +92,62 @@ def self.ip_filtering!(headers, endpoint_config, global_config, request_context,
5192 end
5293 end
5394
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
54120 private_class_method def self . resolve_ip_config ( endpoint_config , global_config )
55121 # Endpoint-level configuration takes precedence over global configuration
56122 endpoint_config [ :ip_filtering ] || global_config [ :ip_filtering ]
57123 end
58124
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
59151 private_class_method def self . extract_client_ip ( headers , ip_config )
60152 # Use configured header or default to X-Forwarded-For
61153 ip_header = ip_config [ :ip_header ] || DEFAULT_IP_HEADER
@@ -72,6 +164,40 @@ def self.ip_filtering!(headers, endpoint_config, global_config, request_context,
72164 nil
73165 end
74166
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
75201 private_class_method def self . ip_allowed? ( client_ip , ip_config )
76202 # Parse client IP
77203 begin
@@ -94,6 +220,38 @@ def self.ip_filtering!(headers, endpoint_config, global_config, request_context,
94220 true
95221 end
96222
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
97255 private_class_method def self . ip_matches_list? ( client_addr , ip_list )
98256 ip_list . each do |ip_pattern |
99257 begin
0 commit comments