Skip to content

Implement application-level IP filtering with allowlist/blocklist support #57

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Jun 18, 2025
Merged
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
hooks-ruby (0.3.0)
hooks-ruby (0.3.1)
dry-schema (~> 1.14, >= 1.14.1)
grape (~> 2.3)
puma (~> 6.6)
Expand Down
2 changes: 2 additions & 0 deletions docs/auth_plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -465,3 +465,5 @@ opts:
- "<ALLOWED_IP_2>"
- "<ALLOWED_IP_3>"
```

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).
195 changes: 195 additions & 0 deletions docs/ip_filtering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# IP Filtering

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.

## Overview

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.

## ⚠️ Security Considerations

**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:

1. **Header Trust**: The service trusts proxy headers, which can be spoofed by malicious clients
2. **Network-Level Protection**: For production security, consider implementing IP filtering at the network or load balancer level
3. **Proper Proxy Configuration**: Ensure your reverse proxy/load balancer is properly configured to set accurate IP headers
4. **Defense in Depth**: Use this feature as part of a broader security strategy, not as the sole protection mechanism

## Configuration

### Global Configuration

Configure IP filtering globally to apply rules to all endpoints:

```yaml
# hooks.yml or your main configuration file
ip_filtering:
ip_header: X-Forwarded-For # Optional, defaults to X-Forwarded-For
allowlist:
- "10.0.0.0/8" # Allow entire private network
- "172.16.0.0/12" # Allow another private range
- "192.168.1.100" # Allow specific IP
blocklist:
- "192.168.1.200" # Block specific IP even if in allowlist
- "203.0.113.0/24" # Block entire subnet
```

### Endpoint-Level Configuration

Configure IP filtering for specific endpoints:

> If a global configuration is set, endpoint-level settings will override it.

```yaml
# config/endpoints/secure-endpoint.yml
path: /secure-webhook
handler: my_secure_handler

ip_filtering:
ip_header: X-Real-IP # Optional, defaults to X-Forwarded-For
allowlist:
- "127.0.0.1" # Allow localhost
- "192.168.1.0/24" # Allow local network
blocklist:
- "192.168.1.100" # Block specific IP in the allowed range
```

## Configuration Options

### `ip_header` (optional)

- **Default**: `X-Forwarded-For`
- **Description**: HTTP header to check for the client IP address
- **Common alternatives**: `X-Real-IP`, `CF-Connecting-IP`, `X-Client-IP`

### `allowlist` (optional)

- **Type**: Array of strings
- **Description**: List of allowed IP addresses or CIDR ranges
- **Behavior**: If specified, only IPs in this list are allowed access
- **Format**: Individual IPs (`192.168.1.1`) or CIDR notation (`192.168.1.0/24`)

### `blocklist` (optional)

- **Type**: Array of strings
- **Description**: List of blocked IP addresses or CIDR ranges
- **Behavior**: IPs in this list are denied access, even if they appear in the allowlist
- **Format**: Individual IPs (`192.168.1.1`) or CIDR notation (`192.168.1.0/24`)

## Filtering Logic

The IP filtering follows this precedence order:

1. **Extract Client IP**: Get the client IP from the configured header (case-insensitive lookup)
2. **Check Blocklist**: If the IP matches any entry in the blocklist, deny immediately
3. **Check Allowlist**: If an allowlist is configured, the IP must match an entry to be allowed
4. **Default Allow**: If no allowlist is configured and IP is not blocked, allow the request

### Precedence Rules

- **Endpoint-level configuration** takes precedence over global configuration
- **Blocklist rules** take precedence over allowlist rules
- **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)

## CIDR Notation Support

The service supports CIDR (Classless Inter-Domain Routing) notation for specifying IP ranges:

```yaml
ip_filtering:
allowlist:
- "192.168.1.0/24" # Allows 192.168.1.1 through 192.168.1.254
- "10.0.0.0/8" # Allows 10.0.0.1 through 10.255.255.254
- "172.16.0.0/12" # Allows 172.16.0.1 through 172.31.255.254
blocklist:
- "192.168.1.100/32" # Blocks specific IP (equivalent to 192.168.1.100)
- "203.0.113.0/24" # Blocks entire test network range
```

## Examples

### Example 1: Basic Allowlist

```yaml
# Allow only specific IPs
path: /secure-webhook
handler: secure_handler

ip_filtering:
allowlist:
- "127.0.0.1"
- "192.168.1.50"
```

### Example 2: CIDR Range with Exceptions

```yaml
# Allow local network but block specific troublemaker
path: /internal-webhook
handler: internal_handler

ip_filtering:
allowlist:
- "192.168.1.0/24"
blocklist:
- "192.168.1.100" # Block this specific IP
```

### Example 3: Custom IP Header

```yaml
# Use Cloudflare's connecting IP header
path: /cloudflare-webhook
handler: cf_handler

ip_filtering:
ip_header: CF-Connecting-IP
allowlist:
- "203.0.113.0/24"
```

### Example 4: Multiple CIDR Ranges

```yaml
# Allow multiple office networks
path: /office-webhook
handler: office_handler

ip_filtering:
allowlist:
- "192.168.1.0/24" # Main office
- "192.168.2.0/24" # Branch office
- "10.0.100.0/24" # VPN range
blocklist:
- "192.168.1.200" # Compromised machine
```

## Error Responses

When IP filtering fails, the service returns an HTTP 403 Forbidden response:

```json
{
"error": "ip_filtering_failed",
"message": "IP address not allowed",
"request_id": "<uuid>"
}
```

## Testing Your Configuration

You can test your IP filtering configuration using curl:

```bash
# Test with allowed IP
curl -H "X-Forwarded-For: 192.168.1.50" \
-H "Content-Type: application/json" \
-d '{"test": "data"}' \
http://localhost:8080/webhooks/secure-endpoint

# Test with blocked IP
curl -H "X-Forwarded-For: 192.168.1.100" \
-H "Content-Type: application/json" \
-d '{"test": "data"}' \
http://localhost:8080/webhooks/secure-endpoint
```
10 changes: 5 additions & 5 deletions lib/hooks/app/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
require "json"
require "securerandom"
require_relative "helpers"
#require_relative "network/ip_filtering"
require_relative "../core/network/ip_filtering"
require_relative "auth/auth"
require_relative "rack_env_builder"
require_relative "../plugins/handlers/base"
Expand Down Expand Up @@ -83,12 +83,12 @@ def self.create(config:, endpoints:, log:)
plugin.on_request(rack_env)
end

# TODO: IP filtering before processing the request if defined
# IP filtering before processing the request if defined
# If IP filtering is enabled at either global or endpoint level, run the filtering rules
# before processing the request
#if config[:ip_filtering] || endpoint_config[:ip_filtering]
#ip_filtering!(headers, endpoint_config, config, request_context, rack_env)
#end
if config[:ip_filtering] || endpoint_config[:ip_filtering]
ip_filtering!(headers, endpoint_config, config, request_context, rack_env)
end

enforce_request_limits(config, request_context)
request.body.rewind
Expand Down
23 changes: 23 additions & 0 deletions lib/hooks/app/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
require "securerandom"
require_relative "../security"
require_relative "../core/plugin_loader"
require_relative "../core/network/ip_filtering"

module Hooks
module App
Expand Down Expand Up @@ -88,6 +89,28 @@ def load_handler(handler_class_name)
return handler_class.new
end

# Verifies the incoming request passes the configured IP filtering rules.
#
# This method assumes that the client IP address is available in the request headers (e.g., `X-Forwarded-For`).
# The headers that is used is configurable via the endpoint configuration.
# It checks the IP address against the allowed and denied lists defined in the endpoint configuration.
# If the IP address is not allowed, it instantly returns an error response via the `error!` method.
# If the IP filtering configuration is missing or invalid, it raises an error.
# If IP filtering is configured at the global level, it will also check against the global configuration first,
# and then against the endpoint-specific configuration.
#
# @param headers [Hash] The request headers.
# @param endpoint_config [Hash] The endpoint configuration, must include :ip_filtering key.
# @param global_config [Hash] The global configuration (optional, for compatibility).
# @param request_context [Hash] Context for the request, e.g. request ID, path, handler (optional).
# @param env [Hash] The Rack environment
# @raise [StandardError] Raises error if IP filtering fails or is misconfigured.
# @return [void]
# @note This method will halt execution with an error if IP filtering rules fail.
def ip_filtering!(headers, endpoint_config, global_config, request_context, env)
Hooks::Core::Network::IpFiltering.ip_filtering!(headers, endpoint_config, global_config, request_context, env)
end

private

# Safely parse JSON
Expand Down
12 changes: 12 additions & 0 deletions lib/hooks/core/config_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ class ValidationError < StandardError; end
optional(:endpoints_dir).filled(:string)
optional(:use_catchall_route).filled(:bool)
optional(:normalize_headers).filled(:bool)

optional(:ip_filtering).hash do
optional(:ip_header).filled(:string)
optional(:allowlist).array(:string)
optional(:blocklist).array(:string)
end
end

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

optional(:ip_filtering).hash do
optional(:ip_header).filled(:string)
optional(:allowlist).array(:string)
optional(:blocklist).array(:string)
end

optional(:opts).hash
end

Expand Down
Loading