fix: [CW-6931] Harden external downloads against SSRF [avatar from url job] (#14153)

This routes external downloads used by avatar sync through SafeFetch. It closes the SSRF exposure from raw Down.download paths, preserves provider-specific auth and header flows, and adds regression coverage
for blocked internal URLs plus authenticated downloads.
Fixes # (issue): [CW-6931](https://linear.app/chatwoot/issue/CW-6931/avatarwidget-url-ssrf-downdownload-unprotected-unauth)
This commit is contained in:
Sony Mathew
2026-04-24 18:59:45 +05:30
committed by GitHub
parent c5fb8d73cc
commit 661608c0b1
6 changed files with 274 additions and 65 deletions

View File

@@ -6,7 +6,11 @@ module SafeFetch
DEFAULT_READ_TIMEOUT = 20
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
Result = Data.define(:tempfile, :filename, :content_type)
Result = Data.define(:tempfile, :filename, :content_type) do
def original_filename
filename
end
end
class Error < StandardError; end
class InvalidUrlError < Error; end
@@ -18,19 +22,15 @@ module SafeFetch
def self.fetch(url,
max_bytes: nil,
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES)
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
allowed_content_types: [])
raise ArgumentError, 'block required' unless block_given?
effective_max_bytes = max_bytes || default_max_bytes
uri = parse_and_validate_url!(url)
filename = filename_for(uri)
filename = filename_for(parse_and_validate_url!(url))
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
response = stream_to_tempfile(url, tempfile, effective_max_bytes, allowed_content_type_prefixes)
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
tempfile.rewind
yield Result.new(tempfile: tempfile, filename: filename, content_type: response['content-type'])
response = fetch_response(url, tempfile, effective_max_bytes, allowed_content_type_prefixes, allowed_content_types)
yield build_result(tempfile, filename, response)
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
raise InvalidUrlError, e.message
rescue SsrfFilter::Error, Resolv::ResolvError => e
@@ -44,18 +44,23 @@ module SafeFetch
class << self
private
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes)
def fetch_response(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
end
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes, allowed_content_types)
response = nil
bytes_written = 0
SsrfFilter.get(
url,
request_proc: ->(request) { apply_url_basic_auth(request) },
http_options: { open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT }
) do |res|
response = res
next unless res.is_a?(Net::HTTPSuccess)
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes)
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes, allowed_content_types)
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
end
@@ -74,6 +79,14 @@ module SafeFetch
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end
def build_result(tempfile, filename, response)
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
tempfile.rewind
content_type = normalized_content_type(response['content-type'])
Result.new(tempfile: tempfile, filename: filename, content_type: content_type)
end
def default_max_bytes
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
limit_mb = DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
@@ -88,11 +101,24 @@ module SafeFetch
uri
end
def allowed_content_type?(value, prefixes)
mime = value.to_s.split(';').first&.strip&.downcase
def allowed_content_type?(value, prefixes, content_types)
mime = normalized_content_type(value)
return false if mime.blank?
prefixes.any? { |prefix| mime.start_with?(prefix) }
prefixes.any? { |prefix| mime.start_with?(prefix) } || content_types.include?(mime)
end
def normalized_content_type(value)
value.to_s.split(';').first&.strip&.downcase
end
def apply_url_basic_auth(request)
uri = request.uri
return if uri.user.blank?
username = URI.decode_uri_component(uri.user)
password = URI.decode_uri_component(uri.password.to_s)
request.basic_auth(username, password)
end
end
end