1+ # frozen_string_literal: true
2+
3+ require "ipaddr"
4+ require_relative "../../plugins/handlers/error"
5+
6+ module Hooks
7+ module App
8+ module Network
9+ # Application-level IP filtering functionality
10+ # Provides both allowlist and blocklist filtering with CIDR support
11+ class IpFiltering
12+ # Default IP header to check for client IP
13+ DEFAULT_IP_HEADER = "X-Forwarded-For"
14+
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.
33+ def self . ip_filtering! ( headers , endpoint_config , global_config , request_context , env )
34+ # Determine which IP filtering configuration to use
35+ ip_config = resolve_ip_config ( endpoint_config , global_config )
36+ return unless ip_config # No IP filtering configured
37+
38+ # Extract client IP from headers
39+ client_ip = extract_client_ip ( headers , ip_config )
40+ return unless client_ip # No client IP found
41+
42+ # Validate IP against filtering rules
43+ unless ip_allowed? ( client_ip , ip_config )
44+ request_id = request_context &.dig ( :request_id ) || request_context &.dig ( "request_id" )
45+ error_msg = {
46+ error : "ip_filtering_failed" ,
47+ message : "IP address not allowed" ,
48+ request_id : request_id
49+ }
50+ raise Hooks ::Plugins ::Handlers ::Error . new ( error_msg , 403 )
51+ end
52+ end
53+
54+ private_class_method def self . resolve_ip_config ( endpoint_config , global_config )
55+ # Endpoint-level configuration takes precedence over global configuration
56+ endpoint_config [ :ip_filtering ] || global_config [ :ip_filtering ]
57+ end
58+
59+ private_class_method def self . extract_client_ip ( headers , ip_config )
60+ # Use configured header or default to X-Forwarded-For
61+ ip_header = ip_config [ :ip_header ] || DEFAULT_IP_HEADER
62+
63+ # Case-insensitive header lookup
64+ headers . each do |key , value |
65+ if key . to_s . downcase == ip_header . downcase
66+ # X-Forwarded-For can contain multiple IPs, take the first one (original client)
67+ client_ip = value . to_s . split ( "," ) . first &.strip
68+ return client_ip unless client_ip . nil? || client_ip . empty?
69+ end
70+ end
71+
72+ nil
73+ end
74+
75+ private_class_method def self . ip_allowed? ( client_ip , ip_config )
76+ # Parse client IP
77+ begin
78+ client_addr = IPAddr . new ( client_ip )
79+ rescue IPAddr ::InvalidAddressError
80+ return false # Invalid IP format
81+ end
82+
83+ # Check blocklist first (if IP is blocked, deny immediately)
84+ if ip_config [ :blocklist ] &.any?
85+ return false if ip_matches_list? ( client_addr , ip_config [ :blocklist ] )
86+ end
87+
88+ # Check allowlist (if defined, IP must be in allowlist)
89+ if ip_config [ :allowlist ] &.any?
90+ return ip_matches_list? ( client_addr , ip_config [ :allowlist ] )
91+ end
92+
93+ # If no allowlist is defined and IP is not in blocklist, allow
94+ true
95+ end
96+
97+ private_class_method def self . ip_matches_list? ( client_addr , ip_list )
98+ ip_list . each do |ip_pattern |
99+ begin
100+ pattern_addr = IPAddr . new ( ip_pattern . to_s )
101+ return true if pattern_addr . include? ( client_addr )
102+ rescue IPAddr ::InvalidAddressError
103+ # Skip invalid IP patterns
104+ next
105+ end
106+ end
107+ false
108+ end
109+ end
110+ end
111+ end
112+ end
0 commit comments