Skip to content

Commit 6c5329d

Browse files
authored
Merge pull request #57 from github/copilot/fix-56
Implement application-level IP filtering with allowlist/blocklist support
2 parents 5e4ce33 + aca58fa commit 6c5329d

File tree

13 files changed

+1016
-7
lines changed

13 files changed

+1016
-7
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
hooks-ruby (0.3.0)
4+
hooks-ruby (0.3.1)
55
dry-schema (~> 1.14, >= 1.14.1)
66
grape (~> 2.3)
77
puma (~> 6.6)

docs/auth_plugins.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,5 @@ opts:
465465
- "<ALLOWED_IP_2>"
466466
- "<ALLOWED_IP_3>"
467467
```
468+
469+
To use the built-in IP filtering feature (rather than trying to implement your own like this example above), check out the [IP Filtering documentation](ip_filtering.md).

docs/ip_filtering.md

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
# IP Filtering
2+
3+
The Hooks service provides comprehensive application-level IP filtering functionality that allows you to control access to your webhooks based on client IP addresses. This feature supports both allowlist and blocklist configurations with CIDR notation support.
4+
5+
## Overview
6+
7+
IP filtering operates as a "pre-flight" check in the request processing pipeline, validating incoming requests before they reach your webhook handlers. The filtering can be configured both globally (for all endpoints) and at the individual endpoint level.
8+
9+
## ⚠️ Security Considerations
10+
11+
**Important**: This IP filtering operates at the application layer and relies on HTTP headers (like `X-Forwarded-For`) to determine client IP addresses. This approach has important security implications:
12+
13+
1. **Header Trust**: The service trusts proxy headers, which can be spoofed by malicious clients
14+
2. **Network-Level Protection**: For production security, consider implementing IP filtering at the network or load balancer level
15+
3. **Proper Proxy Configuration**: Ensure your reverse proxy/load balancer is properly configured to set accurate IP headers
16+
4. **Defense in Depth**: Use this feature as part of a broader security strategy, not as the sole protection mechanism
17+
18+
## Configuration
19+
20+
### Global Configuration
21+
22+
Configure IP filtering globally to apply rules to all endpoints:
23+
24+
```yaml
25+
# hooks.yml or your main configuration file
26+
ip_filtering:
27+
ip_header: X-Forwarded-For # Optional, defaults to X-Forwarded-For
28+
allowlist:
29+
- "10.0.0.0/8" # Allow entire private network
30+
- "172.16.0.0/12" # Allow another private range
31+
- "192.168.1.100" # Allow specific IP
32+
blocklist:
33+
- "192.168.1.200" # Block specific IP even if in allowlist
34+
- "203.0.113.0/24" # Block entire subnet
35+
```
36+
37+
### Endpoint-Level Configuration
38+
39+
Configure IP filtering for specific endpoints:
40+
41+
> If a global configuration is set, endpoint-level settings will override it.
42+
43+
```yaml
44+
# config/endpoints/secure-endpoint.yml
45+
path: /secure-webhook
46+
handler: my_secure_handler
47+
48+
ip_filtering:
49+
ip_header: X-Real-IP # Optional, defaults to X-Forwarded-For
50+
allowlist:
51+
- "127.0.0.1" # Allow localhost
52+
- "192.168.1.0/24" # Allow local network
53+
blocklist:
54+
- "192.168.1.100" # Block specific IP in the allowed range
55+
```
56+
57+
## Configuration Options
58+
59+
### `ip_header` (optional)
60+
61+
- **Default**: `X-Forwarded-For`
62+
- **Description**: HTTP header to check for the client IP address
63+
- **Common alternatives**: `X-Real-IP`, `CF-Connecting-IP`, `X-Client-IP`
64+
65+
### `allowlist` (optional)
66+
67+
- **Type**: Array of strings
68+
- **Description**: List of allowed IP addresses or CIDR ranges
69+
- **Behavior**: If specified, only IPs in this list are allowed access
70+
- **Format**: Individual IPs (`192.168.1.1`) or CIDR notation (`192.168.1.0/24`)
71+
72+
### `blocklist` (optional)
73+
74+
- **Type**: Array of strings
75+
- **Description**: List of blocked IP addresses or CIDR ranges
76+
- **Behavior**: IPs in this list are denied access, even if they appear in the allowlist
77+
- **Format**: Individual IPs (`192.168.1.1`) or CIDR notation (`192.168.1.0/24`)
78+
79+
## Filtering Logic
80+
81+
The IP filtering follows this precedence order:
82+
83+
1. **Extract Client IP**: Get the client IP from the configured header (case-insensitive lookup)
84+
2. **Check Blocklist**: If the IP matches any entry in the blocklist, deny immediately
85+
3. **Check Allowlist**: If an allowlist is configured, the IP must match an entry to be allowed
86+
4. **Default Allow**: If no allowlist is configured and IP is not blocked, allow the request
87+
88+
### Precedence Rules
89+
90+
- **Endpoint-level configuration** takes precedence over global configuration
91+
- **Blocklist rules** take precedence over allowlist rules
92+
- **First IP in comma-separated list** is used (e.g., in `X-Forwarded-For: 192.168.1.1, 10.0.0.1`, only `192.168.1.1` is checked)
93+
94+
## CIDR Notation Support
95+
96+
The service supports CIDR (Classless Inter-Domain Routing) notation for specifying IP ranges:
97+
98+
```yaml
99+
ip_filtering:
100+
allowlist:
101+
- "192.168.1.0/24" # Allows 192.168.1.1 through 192.168.1.254
102+
- "10.0.0.0/8" # Allows 10.0.0.1 through 10.255.255.254
103+
- "172.16.0.0/12" # Allows 172.16.0.1 through 172.31.255.254
104+
blocklist:
105+
- "192.168.1.100/32" # Blocks specific IP (equivalent to 192.168.1.100)
106+
- "203.0.113.0/24" # Blocks entire test network range
107+
```
108+
109+
## Examples
110+
111+
### Example 1: Basic Allowlist
112+
113+
```yaml
114+
# Allow only specific IPs
115+
path: /secure-webhook
116+
handler: secure_handler
117+
118+
ip_filtering:
119+
allowlist:
120+
- "127.0.0.1"
121+
- "192.168.1.50"
122+
```
123+
124+
### Example 2: CIDR Range with Exceptions
125+
126+
```yaml
127+
# Allow local network but block specific troublemaker
128+
path: /internal-webhook
129+
handler: internal_handler
130+
131+
ip_filtering:
132+
allowlist:
133+
- "192.168.1.0/24"
134+
blocklist:
135+
- "192.168.1.100" # Block this specific IP
136+
```
137+
138+
### Example 3: Custom IP Header
139+
140+
```yaml
141+
# Use Cloudflare's connecting IP header
142+
path: /cloudflare-webhook
143+
handler: cf_handler
144+
145+
ip_filtering:
146+
ip_header: CF-Connecting-IP
147+
allowlist:
148+
- "203.0.113.0/24"
149+
```
150+
151+
### Example 4: Multiple CIDR Ranges
152+
153+
```yaml
154+
# Allow multiple office networks
155+
path: /office-webhook
156+
handler: office_handler
157+
158+
ip_filtering:
159+
allowlist:
160+
- "192.168.1.0/24" # Main office
161+
- "192.168.2.0/24" # Branch office
162+
- "10.0.100.0/24" # VPN range
163+
blocklist:
164+
- "192.168.1.200" # Compromised machine
165+
```
166+
167+
## Error Responses
168+
169+
When IP filtering fails, the service returns an HTTP 403 Forbidden response:
170+
171+
```json
172+
{
173+
"error": "ip_filtering_failed",
174+
"message": "IP address not allowed",
175+
"request_id": "<uuid>"
176+
}
177+
```
178+
179+
## Testing Your Configuration
180+
181+
You can test your IP filtering configuration using curl:
182+
183+
```bash
184+
# Test with allowed IP
185+
curl -H "X-Forwarded-For: 192.168.1.50" \
186+
-H "Content-Type: application/json" \
187+
-d '{"test": "data"}' \
188+
http://localhost:8080/webhooks/secure-endpoint
189+
190+
# Test with blocked IP
191+
curl -H "X-Forwarded-For: 192.168.1.100" \
192+
-H "Content-Type: application/json" \
193+
-d '{"test": "data"}' \
194+
http://localhost:8080/webhooks/secure-endpoint
195+
```

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

93116
# Safely parse JSON

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

0 commit comments

Comments
 (0)