diff --git a/app/controllers/api/v1/accounts/upload_controller.rb b/app/controllers/api/v1/accounts/upload_controller.rb index 479d8ae1b..e956349b6 100644 --- a/app/controllers/api/v1/accounts/upload_controller.rb +++ b/app/controllers/api/v1/accounts/upload_controller.rb @@ -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) diff --git a/lib/url_safety_validator.rb b/lib/url_safety_validator.rb new file mode 100644 index 000000000..b91d4907a --- /dev/null +++ b/lib/url_safety_validator.rb @@ -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 diff --git a/spec/controllers/api/v1/upload_controller_spec.rb b/spec/controllers/api/v1/upload_controller_spec.rb index 93ef28dd8..e6416795a 100644 --- a/spec/controllers/api/v1/upload_controller_spec.rb +++ b/spec/controllers/api/v1/upload_controller_spec.rb @@ -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 diff --git a/spec/lib/url_safety_validator_spec.rb b/spec/lib/url_safety_validator_spec.rb new file mode 100644 index 000000000..659f9cf14 --- /dev/null +++ b/spec/lib/url_safety_validator_spec.rb @@ -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