faraday-ssrf-filter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +175 -0
- data/lib/faraday/ssrf_filter/middleware.rb +217 -0
- data/lib/faraday/ssrf_filter/version.rb +7 -0
- data/lib/faraday/ssrf_filter.rb +11 -0
- metadata +72 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2dcc958c9132d2f306a52896efa1b21455232705fa5565cde86cf6205ac74c7e
|
|
4
|
+
data.tar.gz: 37663afac9a815654d26dda87d91ab901003d60a1c80f955871156a8af23f8b7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b882da77d01e75f0da42f90d6164c4e2b5af195ec0609e0a632b4f24275c687b921b74cb3aacd8beed3184f58534cd78ed33246863f02fccf7c630325cfe34a0
|
|
7
|
+
data.tar.gz: f073b6ffd4c3253ba46b550c66a882d5f4f4c8eebbc386eb92e1098e99844c0397238feac3178ae5f95cf397f7e3861b7442783769ddc19b81d6428aa654fd10
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-05-17)
|
|
4
|
+
|
|
5
|
+
- Initial release
|
|
6
|
+
- Block requests to private/reserved IPv4 and IPv6 ranges
|
|
7
|
+
- DNS resolution with hostname replacement (HTTP) to prevent DNS rebinding
|
|
8
|
+
- TLS SNI preservation for HTTPS requests
|
|
9
|
+
- Redirect validation — block 3xx responses pointing to private/reserved IPs
|
|
10
|
+
- IPv4-mapped/compatible/translated IPv6 address detection
|
|
11
|
+
- NAT64 well-known prefix detection
|
|
12
|
+
- Configurable allowlist/denylist
|
|
13
|
+
- Custom DNS resolver support
|
|
14
|
+
- Scheme validation
|
|
15
|
+
- CI with Ruby 3.0-3.4 x Faraday 1/2 matrix
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Quentin Rousseau
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Faraday SSRF Filter
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/faraday-ssrf-filter)
|
|
4
|
+
|
|
5
|
+
A [Faraday](https://github.com/lostisland/faraday) middleware that prevents Server-Side Request Forgery (SSRF) attacks by validating resolved IP addresses against known private and reserved IP ranges before allowing requests to proceed.
|
|
6
|
+
|
|
7
|
+
Inspired by [ssrf_filter](https://github.com/arkadiyt/ssrf_filter) (which uses `Net::HTTP` directly), this gem brings the same level of SSRF protection to any Faraday-based HTTP client.
|
|
8
|
+
|
|
9
|
+
## Features
|
|
10
|
+
|
|
11
|
+
- Blocks requests to **all private/reserved IPv4 and IPv6 ranges** (RFC 1918, RFC 6598, loopback, link-local, multicast, etc.)
|
|
12
|
+
- Detects **IPv4-mapped/compatible/translated IPv6 addresses** (e.g., `::ffff:127.0.0.1`)
|
|
13
|
+
- Detects **NAT64 well-known prefix** addresses (e.g., `64:ff9b::10.0.0.1`)
|
|
14
|
+
- **DNS resolution with IP pinning** to prevent DNS rebinding attacks
|
|
15
|
+
- Blocks **direct IP address** usage by default
|
|
16
|
+
- Configurable **allowlist/denylist** for fine-grained control
|
|
17
|
+
- **Custom DNS resolver** support
|
|
18
|
+
- **Scheme validation** (only `http`/`https` by default)
|
|
19
|
+
- Works with **all Faraday adapters**
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add to your Gemfile:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
gem 'faraday-ssrf-filter'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Or install directly:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
gem install faraday-ssrf-filter
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
### Basic Usage
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
require 'faraday/ssrf_filter'
|
|
41
|
+
|
|
42
|
+
conn = Faraday.new(url: 'https://api.example.com') do |f|
|
|
43
|
+
f.request :ssrf_filter
|
|
44
|
+
f.adapter Faraday.default_adapter
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Safe requests work normally
|
|
48
|
+
response = conn.get('/data')
|
|
49
|
+
|
|
50
|
+
# Requests to private IPs are blocked
|
|
51
|
+
# e.g., if evil.com resolves to 127.0.0.1
|
|
52
|
+
# => raises Faraday::SsrfFilter::PrivateIPError
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Configuration Options
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
conn = Faraday.new(url: 'https://api.example.com') do |f|
|
|
59
|
+
f.request :ssrf_filter,
|
|
60
|
+
# Allow specific private ranges (e.g., internal services)
|
|
61
|
+
allowlist: ['10.0.0.0/8'],
|
|
62
|
+
|
|
63
|
+
# Block specific public ranges
|
|
64
|
+
denylist: ['93.184.216.0/24'],
|
|
65
|
+
|
|
66
|
+
# Allow direct IP addresses in URLs (default: false)
|
|
67
|
+
allow_ip_addresses: true,
|
|
68
|
+
|
|
69
|
+
# Restrict allowed URI schemes (default: ['http', 'https'])
|
|
70
|
+
allowed_schemes: %w[http https],
|
|
71
|
+
|
|
72
|
+
# Custom DNS resolver (default: Resolv.getaddresses)
|
|
73
|
+
resolver: ->(hostname) { Resolv.getaddresses(hostname) }
|
|
74
|
+
|
|
75
|
+
f.adapter Faraday.default_adapter
|
|
76
|
+
end
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Error Handling
|
|
80
|
+
|
|
81
|
+
All errors inherit from `Faraday::SsrfFilter::SSRFError` (which inherits from `Faraday::Error`):
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
begin
|
|
85
|
+
conn.get('/data')
|
|
86
|
+
rescue Faraday::SsrfFilter::PrivateIPError => e
|
|
87
|
+
# Hostname resolved to a private/reserved IP
|
|
88
|
+
rescue Faraday::SsrfFilter::DirectIPError => e
|
|
89
|
+
# URL contains a direct IP address (blocked by default)
|
|
90
|
+
rescue Faraday::SsrfFilter::InvalidSchemeError => e
|
|
91
|
+
# URI scheme not in allowed list
|
|
92
|
+
rescue Faraday::SsrfFilter::DNSResolutionError => e
|
|
93
|
+
# Could not resolve hostname
|
|
94
|
+
rescue Faraday::SsrfFilter::UnsafeRedirectError => e
|
|
95
|
+
# Response redirects to a private/reserved IP or disallowed scheme
|
|
96
|
+
rescue Faraday::SsrfFilter::SSRFError => e
|
|
97
|
+
# Catch-all for any SSRF error
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Blocked IP Ranges
|
|
102
|
+
|
|
103
|
+
### IPv4
|
|
104
|
+
|
|
105
|
+
| Range | Purpose |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `0.0.0.0/8` | Current network |
|
|
108
|
+
| `10.0.0.0/8` | Private (RFC 1918) |
|
|
109
|
+
| `100.64.0.0/10` | Carrier-grade NAT (RFC 6598) |
|
|
110
|
+
| `127.0.0.0/8` | Loopback |
|
|
111
|
+
| `169.254.0.0/16` | Link-local (includes cloud metadata endpoints) |
|
|
112
|
+
| `172.16.0.0/12` | Private (RFC 1918) |
|
|
113
|
+
| `192.0.0.0/24` | IETF protocol assignments |
|
|
114
|
+
| `192.0.2.0/24` | TEST-NET-1 |
|
|
115
|
+
| `192.168.0.0/16` | Private (RFC 1918) |
|
|
116
|
+
| `198.18.0.0/15` | Benchmarking |
|
|
117
|
+
| `198.51.100.0/24` | TEST-NET-2 |
|
|
118
|
+
| `203.0.113.0/24` | TEST-NET-3 |
|
|
119
|
+
| `224.0.0.0/4` | Multicast |
|
|
120
|
+
| `240.0.0.0/4` | Reserved |
|
|
121
|
+
| `255.255.255.255/32` | Broadcast |
|
|
122
|
+
|
|
123
|
+
### IPv6
|
|
124
|
+
|
|
125
|
+
| Range | Purpose |
|
|
126
|
+
|---|---|
|
|
127
|
+
| `::/128` | Unspecified |
|
|
128
|
+
| `::1/128` | Loopback |
|
|
129
|
+
| `100::/64` | Discard prefix |
|
|
130
|
+
| `2001::/32` | Teredo tunneling |
|
|
131
|
+
| `2001:2::/48` | Benchmarking |
|
|
132
|
+
| `2001:10::/28` | ORCHID |
|
|
133
|
+
| `2001:20::/28` | ORCHIDv2 |
|
|
134
|
+
| `2001:db8::/32` | Documentation |
|
|
135
|
+
| `2002::/16` | 6to4 tunneling |
|
|
136
|
+
| `3fff::/20` | Documentation |
|
|
137
|
+
| `5f00::/16` | Segment Routing (SRv6) |
|
|
138
|
+
| `fc00::/7` | Unique local |
|
|
139
|
+
| `fe80::/10` | Link-local |
|
|
140
|
+
| `ff00::/8` | Multicast |
|
|
141
|
+
| `64:ff9b:1::/48` | NAT64 local prefix |
|
|
142
|
+
|
|
143
|
+
Additionally, all IPv4 blacklisted ranges are also blocked in their IPv4-compatible (`::x.x.x.x`), IPv4-mapped (`::ffff:x.x.x.x`), IPv4-translated (`::ffff:0:x.x.x.x`), and NAT64 (`64:ff9b::x.x.x.x`) IPv6 representations.
|
|
144
|
+
|
|
145
|
+
## How It Works
|
|
146
|
+
|
|
147
|
+
1. **Scheme validation** — Only `http` and `https` are allowed by default
|
|
148
|
+
2. **Direct IP blocking** — URLs with IP addresses instead of hostnames are blocked by default
|
|
149
|
+
3. **DNS resolution** — The hostname is resolved to IP addresses using `Resolv.getaddresses`
|
|
150
|
+
4. **IP validation** — Each resolved IP is checked against the comprehensive denylist
|
|
151
|
+
5. **Hostname replacement (HTTP)** — For HTTP requests, the URL hostname is replaced with the validated IP and the `Host` header is set to the original hostname, preventing DNS rebinding
|
|
152
|
+
6. **TLS preservation (HTTPS)** — For HTTPS requests, the hostname is preserved in the URL to maintain correct TLS SNI and certificate verification. The resolved IP is stored in the `X-Faraday-SSRF-Resolved-IP` header
|
|
153
|
+
7. **Redirect validation** — Redirect responses (3xx with `Location` header) are inspected. The redirect target is resolved and validated against the same denylist, raising `UnsafeRedirectError` if it points to a private/reserved IP
|
|
154
|
+
|
|
155
|
+
## Middleware Ordering
|
|
156
|
+
|
|
157
|
+
When using a redirect-following middleware (e.g., `faraday-follow_redirects`), place it **before** the SSRF filter so each redirect is validated:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
conn = Faraday.new(url: 'https://api.example.com') do |f|
|
|
161
|
+
f.response :follow_redirects # outer — follows redirects
|
|
162
|
+
f.request :ssrf_filter # inner — validates each request including redirects
|
|
163
|
+
f.adapter Faraday.default_adapter
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The SSRF filter also validates redirect `Location` headers in responses as defense-in-depth, regardless of middleware ordering.
|
|
168
|
+
|
|
169
|
+
## Acknowledgments
|
|
170
|
+
|
|
171
|
+
This gem is heavily inspired by [ssrf_filter](https://github.com/arkadiyt/ssrf_filter) by [Arkadiy Tetelman](https://github.com/arkadiyt). The comprehensive IP blacklist, IPv4-mapped IPv6 detection, and NAT64 handling are all based on his excellent work. Thank you for building such a thorough and well-tested SSRF protection library for the Ruby ecosystem.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ipaddr'
|
|
4
|
+
require 'resolv'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Faraday
|
|
8
|
+
module SsrfFilter
|
|
9
|
+
class SSRFError < Faraday::Error; end
|
|
10
|
+
class PrivateIPError < SSRFError; end
|
|
11
|
+
class DirectIPError < SSRFError; end
|
|
12
|
+
class InvalidSchemeError < SSRFError; end
|
|
13
|
+
class DNSResolutionError < SSRFError; end
|
|
14
|
+
class UnsafeRedirectError < SSRFError; end
|
|
15
|
+
|
|
16
|
+
class Middleware < Faraday::Middleware
|
|
17
|
+
DEFAULT_SCHEMES = %w[http https].freeze
|
|
18
|
+
REDIRECT_STATUSES = (300..399)
|
|
19
|
+
|
|
20
|
+
IPV4_DENYLIST = [
|
|
21
|
+
IPAddr.new('0.0.0.0/8'),
|
|
22
|
+
IPAddr.new('10.0.0.0/8'),
|
|
23
|
+
IPAddr.new('100.64.0.0/10'),
|
|
24
|
+
IPAddr.new('127.0.0.0/8'),
|
|
25
|
+
IPAddr.new('169.254.0.0/16'),
|
|
26
|
+
IPAddr.new('172.16.0.0/12'),
|
|
27
|
+
IPAddr.new('192.0.0.0/24'),
|
|
28
|
+
IPAddr.new('192.0.2.0/24'),
|
|
29
|
+
IPAddr.new('192.168.0.0/16'),
|
|
30
|
+
IPAddr.new('198.18.0.0/15'),
|
|
31
|
+
IPAddr.new('198.51.100.0/24'),
|
|
32
|
+
IPAddr.new('203.0.113.0/24'),
|
|
33
|
+
IPAddr.new('224.0.0.0/4'),
|
|
34
|
+
IPAddr.new('240.0.0.0/4'),
|
|
35
|
+
IPAddr.new('255.255.255.255/32')
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
IPV6_DENYLIST = [
|
|
39
|
+
IPAddr.new('::1/128'),
|
|
40
|
+
IPAddr.new('::/128'),
|
|
41
|
+
IPAddr.new('100::/64'),
|
|
42
|
+
IPAddr.new('2001::/32'),
|
|
43
|
+
IPAddr.new('2001:2::/48'),
|
|
44
|
+
IPAddr.new('2001:10::/28'),
|
|
45
|
+
IPAddr.new('2001:20::/28'),
|
|
46
|
+
IPAddr.new('2001:db8::/32'),
|
|
47
|
+
IPAddr.new('2002::/16'),
|
|
48
|
+
IPAddr.new('3fff::/20'),
|
|
49
|
+
IPAddr.new('5f00::/16'),
|
|
50
|
+
IPAddr.new('fc00::/7'),
|
|
51
|
+
IPAddr.new('fe80::/10'),
|
|
52
|
+
IPAddr.new('ff00::/8'),
|
|
53
|
+
IPAddr.new('64:ff9b:1::/48'),
|
|
54
|
+
*IPV4_DENYLIST.flat_map do |range|
|
|
55
|
+
pfx = range.prefix
|
|
56
|
+
ip = range.to_s
|
|
57
|
+
[
|
|
58
|
+
IPAddr.new("::#{ip}/#{pfx + 96}"),
|
|
59
|
+
IPAddr.new("::ffff:#{ip}/#{pfx + 96}"),
|
|
60
|
+
IPAddr.new("::ffff:0:#{ip}/#{pfx + 96}"),
|
|
61
|
+
IPAddr.new("64:ff9b::#{ip}/#{pfx + 96}")
|
|
62
|
+
]
|
|
63
|
+
end
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
def initialize(app, options = {})
|
|
67
|
+
super(app)
|
|
68
|
+
@schemes = (options[:allowed_schemes] || DEFAULT_SCHEMES).freeze
|
|
69
|
+
@resolver = options[:resolver] || method(:default_resolver)
|
|
70
|
+
@allow_ip_addresses = options[:allow_ip_addresses] == true
|
|
71
|
+
@allowlist = parse_ip_list(options[:allowlist]).freeze
|
|
72
|
+
@denylist = parse_ip_list(options[:denylist]).freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def call(env)
|
|
76
|
+
validate_and_pin!(env)
|
|
77
|
+
@app.call(env).on_complete { |response_env| validate_redirect!(response_env) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def default_resolver(hostname)
|
|
83
|
+
Resolv.getaddresses(hostname)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_ip_list(list)
|
|
87
|
+
(list || []).map { |r| r.is_a?(IPAddr) ? r : IPAddr.new(r) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_and_pin!(env)
|
|
91
|
+
validate_scheme!(env[:url])
|
|
92
|
+
hostname = env[:url].hostname
|
|
93
|
+
addr = parse_ip(hostname)
|
|
94
|
+
|
|
95
|
+
if addr
|
|
96
|
+
validate_direct_ip!(addr, hostname)
|
|
97
|
+
else
|
|
98
|
+
resolve_and_pin!(env, hostname)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_scheme!(uri)
|
|
103
|
+
raise InvalidSchemeError, "URI scheme '#{uri.scheme}' not allowed" unless @schemes.include?(uri.scheme)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_direct_ip!(addr, hostname)
|
|
107
|
+
raise DirectIPError, "Direct IP addresses are not allowed: #{hostname}" unless @allow_ip_addresses
|
|
108
|
+
raise PrivateIPError, "IP address '#{hostname}' is private/reserved" unless safe_addr?(addr)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def resolve_and_pin!(env, hostname)
|
|
112
|
+
addresses = Array(@resolver.call(hostname))
|
|
113
|
+
raise DNSResolutionError, "Could not resolve hostname: #{hostname}" if addresses.empty?
|
|
114
|
+
|
|
115
|
+
safe_address = addresses.find { |a| safe_ip?(a.to_s) }
|
|
116
|
+
raise PrivateIPError, "Hostname '#{hostname}' resolves to a private/reserved IP address" unless safe_address
|
|
117
|
+
|
|
118
|
+
pin_ip!(env, safe_address.to_s, hostname)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def pin_ip!(env, ip, original_hostname)
|
|
122
|
+
uri = env[:url]
|
|
123
|
+
env[:request_headers] ||= {}
|
|
124
|
+
env[:request_headers]['X-Faraday-SSRF-Original-Host'] = original_hostname
|
|
125
|
+
env[:request_headers]['X-Faraday-SSRF-Resolved-IP'] = ip
|
|
126
|
+
|
|
127
|
+
# Only rewrite hostname for HTTP. For HTTPS, rewriting breaks TLS SNI
|
|
128
|
+
# and certificate verification since the adapter would negotiate TLS
|
|
129
|
+
# with the IP address instead of the original hostname.
|
|
130
|
+
return unless uri.scheme == 'http'
|
|
131
|
+
|
|
132
|
+
env[:request_headers]['Host'] = normalized_host(uri)
|
|
133
|
+
env[:url] = uri.dup.tap { |u| u.hostname = ip }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_redirect!(env)
|
|
137
|
+
return unless REDIRECT_STATUSES.cover?(env[:status])
|
|
138
|
+
|
|
139
|
+
location = env[:response_headers]&.[]('location')
|
|
140
|
+
return if location.nil? || location.empty?
|
|
141
|
+
|
|
142
|
+
uri = resolve_redirect_uri(location, env[:url])
|
|
143
|
+
validate_redirect_target!(uri)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resolve_redirect_uri(location, original_uri)
|
|
147
|
+
uri = URI.parse(location)
|
|
148
|
+
return uri if uri.host
|
|
149
|
+
|
|
150
|
+
URI.join("#{original_uri.scheme}://#{original_uri.host}:#{original_uri.port}", location)
|
|
151
|
+
rescue URI::InvalidURIError
|
|
152
|
+
raise UnsafeRedirectError, "Invalid redirect location: #{location}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def validate_redirect_target!(uri)
|
|
156
|
+
raise UnsafeRedirectError, "Redirect to disallowed scheme: #{uri.scheme}" unless @schemes.include?(uri.scheme)
|
|
157
|
+
|
|
158
|
+
hostname = uri.hostname || uri.host
|
|
159
|
+
return unless hostname
|
|
160
|
+
|
|
161
|
+
addr = parse_ip(hostname)
|
|
162
|
+
if addr
|
|
163
|
+
raise UnsafeRedirectError, "Redirect to private IP: #{hostname}" unless safe_addr?(addr)
|
|
164
|
+
else
|
|
165
|
+
validate_redirect_hostname!(hostname)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def validate_redirect_hostname!(hostname)
|
|
170
|
+
addresses = Array(@resolver.call(hostname))
|
|
171
|
+
raise UnsafeRedirectError, "Cannot resolve redirect hostname: #{hostname}" if addresses.empty?
|
|
172
|
+
|
|
173
|
+
safe = addresses.any? { |a| safe_ip?(a.to_s) }
|
|
174
|
+
raise UnsafeRedirectError, "Redirect to '#{hostname}' resolves to a private IP" unless safe
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def normalized_host(uri)
|
|
178
|
+
host = uri.hostname
|
|
179
|
+
port = uri.port
|
|
180
|
+
return host if port.nil?
|
|
181
|
+
return host if uri.scheme == 'http' && port == 80
|
|
182
|
+
return host if uri.scheme == 'https' && port == 443
|
|
183
|
+
|
|
184
|
+
"#{host}:#{port}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def parse_ip(hostname)
|
|
188
|
+
IPAddr.new(hostname)
|
|
189
|
+
rescue IPAddr::InvalidAddressError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def safe_ip?(ip_string)
|
|
194
|
+
safe_addr?(IPAddr.new(ip_string))
|
|
195
|
+
rescue IPAddr::InvalidAddressError
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def safe_addr?(addr)
|
|
200
|
+
return true if @allowlist.any? { |range| range.include?(addr) }
|
|
201
|
+
return false if @denylist.any? { |range| range.include?(addr) }
|
|
202
|
+
|
|
203
|
+
!unsafe_ip?(addr)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def unsafe_ip?(addr)
|
|
207
|
+
if addr.ipv4?
|
|
208
|
+
IPV4_DENYLIST.any? { |range| range.include?(addr) }
|
|
209
|
+
elsif addr.ipv6?
|
|
210
|
+
IPV6_DENYLIST.any? { |range| range.include?(addr) }
|
|
211
|
+
else
|
|
212
|
+
true
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require_relative 'ssrf_filter/middleware'
|
|
5
|
+
require_relative 'ssrf_filter/version'
|
|
6
|
+
|
|
7
|
+
module Faraday
|
|
8
|
+
module SsrfFilter
|
|
9
|
+
Faraday::Request.register_middleware(ssrf_filter: Faraday::SsrfFilter::Middleware)
|
|
10
|
+
end
|
|
11
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: faraday-ssrf-filter
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Quentin Rousseau
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: faraday
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '3'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '1.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '3'
|
|
32
|
+
description: A Faraday middleware that prevents Server-Side Request Forgery (SSRF)
|
|
33
|
+
attacks by validating resolved IP addresses against known private and reserved IP
|
|
34
|
+
ranges before allowing the request to proceed.
|
|
35
|
+
email:
|
|
36
|
+
- contact@quent.in
|
|
37
|
+
executables: []
|
|
38
|
+
extensions: []
|
|
39
|
+
extra_rdoc_files: []
|
|
40
|
+
files:
|
|
41
|
+
- CHANGELOG.md
|
|
42
|
+
- LICENSE
|
|
43
|
+
- README.md
|
|
44
|
+
- lib/faraday/ssrf_filter.rb
|
|
45
|
+
- lib/faraday/ssrf_filter/middleware.rb
|
|
46
|
+
- lib/faraday/ssrf_filter/version.rb
|
|
47
|
+
homepage: https://github.com/kwent/faraday-ssrf-filter
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
homepage_uri: https://github.com/kwent/faraday-ssrf-filter
|
|
52
|
+
source_code_uri: https://github.com/kwent/faraday-ssrf-filter
|
|
53
|
+
changelog_uri: https://github.com/kwent/faraday-ssrf-filter/blob/main/CHANGELOG.md
|
|
54
|
+
rubygems_mfa_required: 'true'
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: 3.0.0
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.6.9
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: Faraday middleware to prevent SSRF attacks
|
|
72
|
+
test_files: []
|