Skip to content

Commit 0926359

Browse files
CopilotGrantBirki
andcommitted
Implement application-level IP filtering with allowlist/blocklist support
Co-authored-by: GrantBirki <[email protected]>
1 parent e614133 commit 0926359

File tree

8 files changed

+639
-5
lines changed

8 files changed

+639
-5
lines changed

lib/hooks/app/api.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
require "json"
55
require "securerandom"
66
require_relative "helpers"
7-
#require_relative "network/ip_filtering"
7+
require_relative "network/ip_filtering"
88
require_relative "auth/auth"
99
require_relative "rack_env_builder"
1010
require_relative "../plugins/handlers/base"
@@ -83,12 +83,12 @@ def self.create(config:, endpoints:, log:)
8383
plugin.on_request(rack_env)
8484
end
8585

86-
# TODO: IP filtering before processing the request if defined
86+
# IP filtering before processing the request if defined
8787
# If IP filtering is enabled at either global or endpoint level, run the filtering rules
8888
# 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
9292

9393
enforce_request_limits(config, request_context)
9494
request.body.rewind

lib/hooks/app/helpers.rb

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
require "securerandom"
44
require_relative "../security"
55
require_relative "../core/plugin_loader"
6+
require_relative "network/ip_filtering"
67

78
module Hooks
89
module App
@@ -88,6 +89,28 @@ def load_handler(handler_class_name)
8889
return handler_class.new
8990
end
9091

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+
Network::IpFiltering.ip_filtering!(headers, endpoint_config, global_config, request_context, env)
112+
end
113+
91114
private
92115

93116
# Safely parse JSON

lib/hooks/app/network/ip_filtering.rb

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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

lib/hooks/core/config_validator.rb

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ class ValidationError < StandardError; end
2727
optional(:endpoints_dir).filled(:string)
2828
optional(:use_catchall_route).filled(:bool)
2929
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
3036
end
3137

3238
# Endpoint configuration schema
@@ -52,6 +58,12 @@ class ValidationError < StandardError; end
5258
optional(:key_value_separator).filled(:string)
5359
end
5460

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+
5567
optional(:opts).hash
5668
end
5769

spec/acceptance/acceptance_tests.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -587,5 +587,86 @@ def expired_unix_timestamp(seconds_ago = 600)
587587
expect_response(response, Net::HTTPUnauthorized, "authentication failed")
588588
end
589589
end
590+
591+
describe "application-level IP filtering" do
592+
it "allows requests from IPs in allowlist" do
593+
payload = {}.to_json
594+
headers = { "Content-Type" => "application/json", "X-Forwarded-For" => "127.0.0.1" }
595+
response = make_request(:post, "/webhooks/ip_filtering_direct", payload, headers)
596+
597+
expect_response(response, Net::HTTPSuccess)
598+
body = parse_json_response(response)
599+
expect(body["status"]).to eq("success")
600+
end
601+
602+
it "allows requests from IPs in CIDR range" do
603+
payload = {}.to_json
604+
headers = { "Content-Type" => "application/json", "X-Forwarded-For" => "192.168.1.50" }
605+
response = make_request(:post, "/webhooks/ip_filtering_direct", payload, headers)
606+
607+
expect_response(response, Net::HTTPSuccess)
608+
body = parse_json_response(response)
609+
expect(body["status"]).to eq("success")
610+
end
611+
612+
it "blocks requests from IPs in blocklist even if in allowlist" do
613+
payload = {}.to_json
614+
headers = { "Content-Type" => "application/json", "X-Forwarded-For" => "192.168.1.100" }
615+
response = make_request(:post, "/webhooks/ip_filtering_direct", payload, headers)
616+
617+
expect_response(response, Net::HTTPForbidden)
618+
body = parse_json_response(response)
619+
expect(body["error"]).to eq("ip_filtering_failed")
620+
expect(body["message"]).to eq("IP address not allowed")
621+
end
622+
623+
it "blocks requests from IPs not in allowlist" do
624+
payload = {}.to_json
625+
headers = { "Content-Type" => "application/json", "X-Forwarded-For" => "203.0.113.1" }
626+
response = make_request(:post, "/webhooks/ip_filtering_direct", payload, headers)
627+
628+
expect_response(response, Net::HTTPForbidden)
629+
body = parse_json_response(response)
630+
expect(body["error"]).to eq("ip_filtering_failed")
631+
end
632+
633+
it "uses custom IP header when configured" do
634+
payload = {}.to_json
635+
headers = {
636+
"Content-Type" => "application/json",
637+
"X-Real-IP" => "10.0.0.1",
638+
"X-Forwarded-For" => "203.0.113.1"
639+
}
640+
response = make_request(:post, "/webhooks/ip_filtering_custom_header", payload, headers)
641+
642+
expect_response(response, Net::HTTPSuccess)
643+
body = parse_json_response(response)
644+
expect(body["status"]).to eq("success")
645+
end
646+
647+
it "blocks requests when custom IP header has disallowed IP" do
648+
payload = {}.to_json
649+
headers = {
650+
"Content-Type" => "application/json",
651+
"X-Real-IP" => "203.0.113.1",
652+
"X-Forwarded-For" => "10.0.0.1"
653+
}
654+
response = make_request(:post, "/webhooks/ip_filtering_custom_header", payload, headers)
655+
656+
expect_response(response, Net::HTTPForbidden)
657+
body = parse_json_response(response)
658+
expect(body["error"]).to eq("ip_filtering_failed")
659+
end
660+
661+
it "allows requests when no IP filtering is configured" do
662+
payload = {}.to_json
663+
headers = { "Content-Type" => "application/json", "X-Forwarded-For" => "203.0.113.1" }
664+
response = make_request(:post, "/webhooks/hello", payload, headers)
665+
666+
expect_response(response, Net::HTTPSuccess)
667+
body = parse_json_response(response)
668+
expect(body["status"]).to eq("success")
669+
end
670+
end
590671
end
591672
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
path: /ip_filtering_custom_header
2+
handler: hello
3+
4+
ip_filtering:
5+
ip_header: X-Real-IP
6+
allowlist:
7+
- "10.0.0.0/8"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
path: /ip_filtering_direct
2+
handler: hello
3+
4+
ip_filtering:
5+
allowlist:
6+
- "127.0.0.1"
7+
- "192.168.1.0/24"
8+
blocklist:
9+
- "192.168.1.100"

0 commit comments

Comments
 (0)