mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
fix: Support allowlisted private API inbox webhooks (#14548)
Self-hosted installations can now opt SafeFetch into private-network access after SSRF hardening. The default remains unchanged: private IP destinations are blocked unless the instance owner explicitly enables private-network requests with `SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true`. Fixes https://linear.app/chatwoot/issue/CW-7131 Fixes https://github.com/chatwoot/chatwoot/issues/14489 Fixes https://github.com/chatwoot/chatwoot/issues/14494 ## How to use For self-hosted installations that need API inbox webhooks, or other SafeFetch-backed requests, to call trusted private services, enable private-network access with a single environment variable: ```bash SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true ``` This is disabled by default. Enable it only when the instance owner controls the deployment network and trusts the configured URLs.
This commit is contained in:
parent
b981ba766f
commit
7c16071fc7
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
102
lib/safe_fetch/private_network_request.rb
Normal file
102
lib/safe_fetch/private_network_request.rb
Normal file
@ -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
|
||||
@ -53,6 +53,10 @@ class SafeFetch::RequestOptions
|
||||
@validate_content_type
|
||||
end
|
||||
|
||||
def resolver
|
||||
SsrfFilter::DEFAULT_RESOLVER
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_max_bytes
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user