mirror of
https://github.com/chatwoot/chatwoot.git
synced 2026-06-04 21:02:35 +08:00
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.
78 lines
2.0 KiB
Ruby
78 lines
2.0 KiB
Ruby
class SafeFetch::Fetcher
|
|
def initialize(options)
|
|
@options = options
|
|
end
|
|
|
|
def fetch
|
|
with_tempfile do |tempfile|
|
|
response = stream_response(tempfile)
|
|
raise SafeFetch::HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
|
|
|
tempfile.rewind
|
|
yield SafeFetch::Result.new(
|
|
tempfile: tempfile,
|
|
filename: options.filename,
|
|
content_type: normalized_content_type(response['content-type'])
|
|
)
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
attr_reader :options
|
|
|
|
def with_tempfile
|
|
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
|
|
yield tempfile
|
|
ensure
|
|
tempfile&.close!
|
|
end
|
|
|
|
def stream_response(tempfile)
|
|
bytes_written = 0
|
|
|
|
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
|
|
|
|
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)
|
|
return unless options.validate_content_type?
|
|
return if allowed_content_type?(content_type)
|
|
|
|
raise SafeFetch::UnsupportedContentTypeError, "content-type not allowed: #{content_type}"
|
|
end
|
|
|
|
def write_response_body(response, tempfile, bytes_written)
|
|
response.read_body do |chunk|
|
|
bytes_written += chunk.bytesize
|
|
raise SafeFetch::FileTooLargeError, "exceeded #{options.effective_max_bytes} bytes" if bytes_written > options.effective_max_bytes
|
|
|
|
tempfile.write(chunk)
|
|
end
|
|
|
|
bytes_written
|
|
end
|
|
|
|
def allowed_content_type?(value)
|
|
mime = normalized_content_type(value)
|
|
return false if mime.blank?
|
|
|
|
options.allowed_content_type_prefixes.any? { |prefix| mime.start_with?(prefix) } ||
|
|
options.allowed_content_types.include?(mime)
|
|
end
|
|
|
|
def normalized_content_type(value)
|
|
value.to_s.split(';').first&.strip&.downcase
|
|
end
|
|
end
|