Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7d4fbd31d | ||
|
|
ef7b31d202 | ||
|
|
61483c94f0 | ||
|
|
6e60e3b946 |
@@ -29,13 +29,15 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
uri = URI.parse(url)
|
||||
validate_uri(uri)
|
||||
uri
|
||||
rescue URI::InvalidURIError, SocketError
|
||||
rescue URI::InvalidURIError, SocketError, UrlSafetyValidator::UnsafeUrlError, Resolv::ResolvError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_uri(uri)
|
||||
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
UrlSafetyValidator.validate!(uri.to_s)
|
||||
end
|
||||
|
||||
def fetch_and_process_file_from_uri(uri)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
require 'resolv'
|
||||
require 'ipaddr'
|
||||
|
||||
module UrlSafetyValidator
|
||||
# Blocked IP ranges for SSRF protection
|
||||
BLOCKED_IP_RANGES = [
|
||||
IPAddr.new('10.0.0.0/8'), # Private Class A
|
||||
IPAddr.new('172.16.0.0/12'), # Private Class B
|
||||
IPAddr.new('192.168.0.0/16'), # Private Class C
|
||||
IPAddr.new('127.0.0.0/8'), # Loopback
|
||||
IPAddr.new('169.254.0.0/16'), # Link-local (AWS metadata service)
|
||||
IPAddr.new('0.0.0.0/8'), # Current network
|
||||
IPAddr.new('100.64.0.0/10'), # Carrier-grade NAT
|
||||
IPAddr.new('192.0.0.0/24'), # IETF Protocol Assignments
|
||||
IPAddr.new('192.0.2.0/24'), # TEST-NET-1
|
||||
IPAddr.new('198.51.100.0/24'), # TEST-NET-2
|
||||
IPAddr.new('203.0.113.0/24'), # TEST-NET-3
|
||||
IPAddr.new('224.0.0.0/4'), # Multicast
|
||||
IPAddr.new('240.0.0.0/4'), # Reserved
|
||||
IPAddr.new('255.255.255.255/32'), # Broadcast
|
||||
# IPv6 ranges
|
||||
IPAddr.new('::1/128'), # IPv6 loopback
|
||||
IPAddr.new('fc00::/7'), # IPv6 unique local
|
||||
IPAddr.new('fe80::/10'), # IPv6 link-local
|
||||
IPAddr.new('ff00::/8') # IPv6 multicast
|
||||
].freeze
|
||||
|
||||
BLOCKED_HOSTNAMES = [
|
||||
'localhost',
|
||||
'metadata.google.internal',
|
||||
'169.254.169.254',
|
||||
'metadata',
|
||||
'kubernetes.default.svc',
|
||||
'kubernetes.default'
|
||||
].freeze
|
||||
|
||||
class UnsafeUrlError < StandardError; end
|
||||
|
||||
class << self
|
||||
def safe?(url)
|
||||
validate!(url)
|
||||
true
|
||||
rescue UnsafeUrlError, URI::InvalidURIError, SocketError, Resolv::ResolvError
|
||||
false
|
||||
end
|
||||
|
||||
def validate!(url)
|
||||
uri = parse_and_validate_scheme(url)
|
||||
validate_host!(uri)
|
||||
resolve_and_validate_ip!(uri)
|
||||
true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_and_validate_scheme(url)
|
||||
uri = URI.parse(url)
|
||||
raise UnsafeUrlError, 'URL must use HTTP or HTTPS' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
|
||||
uri
|
||||
end
|
||||
|
||||
def validate_host!(uri)
|
||||
host = uri.host&.downcase
|
||||
raise UnsafeUrlError, 'URL must have a valid host' if host.blank?
|
||||
raise UnsafeUrlError, 'Blocked hostname' if BLOCKED_HOSTNAMES.include?(host)
|
||||
raise UnsafeUrlError, 'Hostname ending with .local is not allowed' if host.end_with?('.local')
|
||||
raise UnsafeUrlError, 'Hostname ending with .internal is not allowed' if host.end_with?('.internal')
|
||||
|
||||
# Check if host is an IP address directly
|
||||
validate_ip_not_blocked!(host) if ip_address?(host)
|
||||
end
|
||||
|
||||
def resolve_and_validate_ip!(uri)
|
||||
host = uri.host
|
||||
|
||||
# Resolve DNS to get actual IP addresses
|
||||
addresses = Resolv.getaddresses(host)
|
||||
raise UnsafeUrlError, 'Could not resolve hostname' if addresses.empty?
|
||||
|
||||
# Validate all resolved IPs
|
||||
addresses.each do |addr|
|
||||
validate_ip_not_blocked!(addr)
|
||||
end
|
||||
end
|
||||
|
||||
def validate_ip_not_blocked!(ip_string)
|
||||
ip = IPAddr.new(ip_string)
|
||||
|
||||
BLOCKED_IP_RANGES.each do |range|
|
||||
raise UnsafeUrlError, "IP address #{ip_string} is in a blocked range" if range.include?(ip)
|
||||
end
|
||||
rescue IPAddr::InvalidAddressError
|
||||
raise UnsafeUrlError, "Invalid IP address: #{ip_string}"
|
||||
end
|
||||
|
||||
def ip_address?(host)
|
||||
IPAddr.new(host)
|
||||
true
|
||||
rescue IPAddr::InvalidAddressError
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -96,6 +96,84 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
|
||||
end
|
||||
|
||||
context 'with SSRF attack vectors' do
|
||||
it 'blocks requests to private IP ranges (10.x.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://10.0.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to private IP ranges (172.16.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://172.16.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to private IP ranges (192.168.x.x)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://192.168.1.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to loopback addresses' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://127.0.0.1/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to AWS metadata service (169.254.169.254)' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to localhost' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://localhost/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks requests to .local domains' do
|
||||
allow(Resolv).to receive(:getaddresses).with('server.local').and_return(['192.168.1.100'])
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://server.local/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'blocks DNS rebinding attacks (hostname resolving to private IP)' do
|
||||
allow(Resolv).to receive(:getaddresses).with('evil.attacker.com').and_return(['10.0.0.1'])
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://evil.attacker.com/secret' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns an error when no file or URL is provided' do
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe UrlSafetyValidator do
|
||||
describe '.safe?' do
|
||||
context 'with safe URLs' do
|
||||
it 'returns true for valid public URLs' do
|
||||
expect(described_class.safe?('https://example.com/image.png')).to be true
|
||||
expect(described_class.safe?('http://example.org/file.pdf')).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unsafe URLs' do
|
||||
it 'returns false for private IP ranges' do
|
||||
expect(described_class.safe?('http://10.0.0.1/secret')).to be false
|
||||
expect(described_class.safe?('http://172.16.0.1/secret')).to be false
|
||||
expect(described_class.safe?('http://192.168.1.1/secret')).to be false
|
||||
end
|
||||
|
||||
it 'returns false for loopback addresses' do
|
||||
expect(described_class.safe?('http://127.0.0.1/secret')).to be false
|
||||
expect(described_class.safe?('http://127.0.0.254/secret')).to be false
|
||||
end
|
||||
|
||||
it 'returns false for AWS metadata service' do
|
||||
expect(described_class.safe?('http://169.254.169.254/latest/meta-data/')).to be false
|
||||
end
|
||||
|
||||
it 'returns false for localhost' do
|
||||
expect(described_class.safe?('http://localhost/secret')).to be false
|
||||
end
|
||||
|
||||
it 'returns false for invalid schemes' do
|
||||
expect(described_class.safe?('ftp://example.com/file')).to be false
|
||||
expect(described_class.safe?('file:///etc/passwd')).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.validate!' do
|
||||
context 'with safe URLs' do
|
||||
before do
|
||||
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||
end
|
||||
|
||||
it 'returns true for valid public URLs' do
|
||||
expect(described_class.validate!('https://example.com/image.png')).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'with private IP ranges' do
|
||||
it 'raises UnsafeUrlError for Class A private IPs' do
|
||||
expect { described_class.validate!('http://10.0.0.1/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for Class B private IPs' do
|
||||
expect { described_class.validate!('http://172.16.0.1/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
expect { described_class.validate!('http://172.31.255.255/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for Class C private IPs' do
|
||||
expect { described_class.validate!('http://192.168.1.1/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with loopback addresses' do
|
||||
it 'raises UnsafeUrlError for localhost IP' do
|
||||
expect { described_class.validate!('http://127.0.0.1/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for any loopback IP' do
|
||||
expect { described_class.validate!('http://127.0.0.254/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with AWS/cloud metadata service' do
|
||||
it 'raises UnsafeUrlError for link-local IPs (169.254.x.x)' do
|
||||
expect { described_class.validate!('http://169.254.169.254/latest/meta-data/') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for AWS metadata IP as hostname' do
|
||||
expect { described_class.validate!('http://169.254.169.254/latest/meta-data/iam/security-credentials/') }
|
||||
.to raise_error(described_class::UnsafeUrlError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for GCP metadata hostname' do
|
||||
expect { described_class.validate!('http://metadata.google.internal/computeMetadata/v1/') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /Blocked hostname/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blocked hostnames' do
|
||||
it 'raises UnsafeUrlError for localhost' do
|
||||
expect { described_class.validate!('http://localhost/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /Blocked hostname/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for .local domains' do
|
||||
allow(Resolv).to receive(:getaddresses).with('server.local').and_return(['192.168.1.100'])
|
||||
expect { described_class.validate!('http://server.local/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /\.local is not allowed/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for .internal domains' do
|
||||
allow(Resolv).to receive(:getaddresses).with('server.internal').and_return(['10.0.0.1'])
|
||||
expect { described_class.validate!('http://server.internal/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /\.internal is not allowed/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for kubernetes.default.svc' do
|
||||
expect { described_class.validate!('http://kubernetes.default.svc/api') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /Blocked hostname/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with DNS rebinding protection' do
|
||||
it 'raises UnsafeUrlError when hostname resolves to private IP' do
|
||||
allow(Resolv).to receive(:getaddresses).with('evil.com').and_return(['10.0.0.1'])
|
||||
expect { described_class.validate!('http://evil.com/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError when any resolved IP is blocked' do
|
||||
allow(Resolv).to receive(:getaddresses).with('multi.com').and_return(['93.184.216.34', '127.0.0.1'])
|
||||
expect { described_class.validate!('http://multi.com/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid URLs' do
|
||||
it 'raises UnsafeUrlError for non-HTTP schemes' do
|
||||
expect { described_class.validate!('ftp://example.com/file') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /HTTP or HTTPS/)
|
||||
end
|
||||
|
||||
it 'raises URI::InvalidURIError for malformed URLs' do
|
||||
expect { described_class.validate!('not_a_url') }
|
||||
.to raise_error(URI::InvalidURIError)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for URLs without host' do
|
||||
expect { described_class.validate!('http:///path') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /valid host/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with IPv6 addresses' do
|
||||
it 'raises UnsafeUrlError for IPv6 loopback' do
|
||||
expect { described_class.validate!('http://[::1]/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for IPv6 unique local addresses' do
|
||||
expect { described_class.validate!('http://[fc00::1]/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for IPv6 link-local addresses' do
|
||||
expect { described_class.validate!('http://[fe80::1]/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with special IP ranges' do
|
||||
it 'raises UnsafeUrlError for current network (0.0.0.0/8)' do
|
||||
expect { described_class.validate!('http://0.0.0.1/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for carrier-grade NAT' do
|
||||
expect { described_class.validate!('http://100.64.0.1/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for multicast addresses' do
|
||||
expect { described_class.validate!('http://224.0.0.1/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
|
||||
it 'raises UnsafeUrlError for broadcast address' do
|
||||
expect { described_class.validate!('http://255.255.255.255/secret') }
|
||||
.to raise_error(described_class::UnsafeUrlError, /blocked range/)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user