diff --git a/.env.example b/.env.example index 8a4f0bb5d1a..69b1b9cde92 100644 --- a/.env.example +++ b/.env.example @@ -234,6 +234,10 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: # Comma-separated list of trusted IPs that bypass Rack Attack throttling rules # RACK_ATTACK_ALLOWED_IPS=127.0.0.1,::1,192.168.0.10 +## SafeFetch private network access +## Keep disabled by default. Self-hosted installations can enable this to allow SafeFetch requests to private network URLs. +# SAFE_FETCH_ALLOW_PRIVATE_NETWORK=false + ## Running chatwoot as an API only server ## setting this value to true will disable the frontend dashboard endpoints # CW_API_ONLY_SERVER=false diff --git a/lib/safe_fetch.rb b/lib/safe_fetch.rb index 7f89c03c477..f635758af75 100644 --- a/lib/safe_fetch.rb +++ b/lib/safe_fetch.rb @@ -34,4 +34,8 @@ module SafeFetch rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e raise FetchError, e.message end + + def self.allow_private_network? + ActiveModel::Type::Boolean.new.cast(ENV.fetch('SAFE_FETCH_ALLOW_PRIVATE_NETWORK', false)) + end end diff --git a/lib/safe_fetch/fetcher.rb b/lib/safe_fetch/fetcher.rb index fa3c01f5551..ab8a9863c7b 100644 --- a/lib/safe_fetch/fetcher.rb +++ b/lib/safe_fetch/fetcher.rb @@ -29,18 +29,20 @@ class SafeFetch::Fetcher end def stream_response(tempfile) - response = nil bytes_written = 0 - SsrfFilter.public_send(options.method, options.url, **options.request_options) do |res| - response = res + perform_request do |res| next unless res.is_a?(Net::HTTPSuccess) validate_content_type!(res['content-type']) bytes_written = write_response_body(res, tempfile, bytes_written) end + end - response + def perform_request(&) + return SafeFetch::PrivateNetworkRequest.new(options).perform(&) if SafeFetch.allow_private_network? + + SsrfFilter.public_send(options.method, options.url, **options.request_options, &) end def validate_content_type!(content_type) diff --git a/lib/safe_fetch/private_network_request.rb b/lib/safe_fetch/private_network_request.rb new file mode 100644 index 00000000000..9e9740ef4b6 --- /dev/null +++ b/lib/safe_fetch/private_network_request.rb @@ -0,0 +1,102 @@ +class SafeFetch::PrivateNetworkRequest + def initialize(options) + @options = options + end + + def perform(&) + url = options.url + original_url = url + original_uri = URI(url) + + (SsrfFilter::DEFAULT_MAX_REDIRECTS + 1).times do + uri = URI(url) + validate_scheme!(uri) + + response, next_url = fetch_once(uri, resolved_addresses(uri.hostname).sample.to_s, original_uri, &) + return response if next_url.nil? + + url = next_url + end + + raise SsrfFilter::TooManyRedirects, "Got #{SsrfFilter::DEFAULT_MAX_REDIRECTS} redirects fetching #{original_url}" + end + + private + + attr_reader :options + + def validate_scheme!(uri) + return if SsrfFilter::DEFAULT_SCHEME_WHITELIST.include?(uri.scheme) + + raise SsrfFilter::InvalidUriScheme, "URI scheme '#{uri.scheme}' not in whitelist: #{SsrfFilter::DEFAULT_SCHEME_WHITELIST}" + end + + def resolved_addresses(hostname) + ip_addresses = options.resolver.call(hostname) + raise SsrfFilter::UnresolvedHostname, "Could not resolve hostname '#{hostname}'" if ip_addresses.empty? + + ip_addresses + end + + def fetch_once(uri, ip_address, original_uri, &) + request = build_request(uri) + strip_sensitive_headers!(request, original_uri, uri) + validate_request!(request) + + Net::HTTP.start(uri.hostname, uri.port, **http_options(uri, ip_address)) do |http| + response = http.request(request, &) + return response, redirect_location(response, uri) + end + end + + def build_request(uri) + request = SsrfFilter::VERB_MAP[options.method].new(uri) + request['host'] = normalized_hostname(uri) + + Array(options.request_options[:headers]).each { |header, value| request[header] = value } + request.body = options.body if options.body + options.request_options[:request_proc].call(request) if options.request_options[:request_proc].respond_to?(:call) + + request + end + + def http_options(uri, ip_address) + options.request_options[:http_options].merge( + use_ssl: uri.scheme == 'https', + ipaddr: ip_address + ) + end + + def strip_sensitive_headers!(request, original_uri, uri) + return unless different_origin?(original_uri, uri) + + options.request_options[:sensitive_headers].each { |header| request.delete(header) } + end + + def validate_request!(request) + request.each do |header, value| + next if header.count("\r\n").zero? && value.count("\r\n").zero? + + raise SsrfFilter::CRLFInjection, "CRLF injection in header #{header} with value #{value}" + end + end + + def redirect_location(response, uri) + return unless response.is_a?(Net::HTTPRedirection) + + location = response['location'] + return "#{uri.scheme}://#{normalized_hostname(uri)}#{location}" if location&.start_with?('/') + + location + end + + def normalized_hostname(uri) + return uri.hostname if (uri.port == 80 && uri.scheme == 'http') || (uri.port == 443 && uri.scheme == 'https') + + "#{uri.hostname}:#{uri.port}" + end + + def different_origin?(uri, other_uri) + uri.scheme != other_uri.scheme || uri.hostname != other_uri.hostname || uri.port != other_uri.port + end +end diff --git a/lib/safe_fetch/request_options.rb b/lib/safe_fetch/request_options.rb index 72969a76d3d..6d11ebd196d 100644 --- a/lib/safe_fetch/request_options.rb +++ b/lib/safe_fetch/request_options.rb @@ -53,6 +53,10 @@ class SafeFetch::RequestOptions @validate_content_type end + def resolver + SsrfFilter::DEFAULT_RESOLVER + end + private def default_max_bytes diff --git a/spec/lib/safe_fetch_spec.rb b/spec/lib/safe_fetch_spec.rb index 8c54a092c57..a124be7746a 100644 --- a/spec/lib/safe_fetch_spec.rb +++ b/spec/lib/safe_fetch_spec.rb @@ -205,6 +205,50 @@ RSpec.describe SafeFetch do expect(error.class.name).to eq('SafeFetch::UnsafeUrlError') end end + + it 'allows private IP literals when private network access is enabled' do + private_url = 'http://192.168.3.21/image.png' + allow(Resolv).to receive(:getaddresses).with('192.168.3.21').and_return(['192.168.3.21']) + stub_request(:get, private_url).to_return( + status: 200, + body: File.new(Rails.root.join('spec/assets/avatar.png')), + headers: { 'Content-Type' => 'image/png' } + ) + + with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do + expect { described_class.fetch(private_url) { nil } }.not_to raise_error + end + end + + it 'allows private hostnames when private network access is enabled' do + private_url = 'http://internal-webhook-service/image.png' + allow(Resolv).to receive(:getaddresses).with('internal-webhook-service').and_return(['10.0.0.5']) + stub_request(:get, private_url).to_return( + status: 200, + body: File.new(Rails.root.join('spec/assets/avatar.png')), + headers: { 'Content-Type' => 'image/png' } + ) + + with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do + expect { described_class.fetch(private_url) { nil } }.not_to raise_error + end + end + + it 'allows redirects to private hostnames when private network access is enabled' do + redirect_url = 'http://example.com/redirect.png' + private_url = 'http://private.example.com/image.png' + allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5']) + stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url }) + stub_request(:get, private_url).to_return( + status: 200, + body: File.new(Rails.root.join('spec/assets/avatar.png')), + headers: { 'Content-Type' => 'image/png' } + ) + + with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do + expect { described_class.fetch(redirect_url) { nil } }.not_to raise_error + end + end end context 'with content-type allowlist' do