fix(ssrf): harden external downloads

This commit is contained in:
Sony Mathew
2026-04-23 01:00:56 +05:30
parent 2182165201
commit c7da5bd5e6
24 changed files with 664 additions and 247 deletions
+77 -10
View File
@@ -2,11 +2,21 @@ require 'ssrf_filter'
module SafeFetch
DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/].freeze
DEFAULT_ALLOWED_CONTENT_TYPES = [].freeze
DEFAULT_SENSITIVE_HEADERS = %w[authorization cookie].freeze
DEFAULT_OPEN_TIMEOUT = 2
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
def close!
tempfile.close! if tempfile.respond_to?(:close!)
end
end
class Error < StandardError; end
class InvalidUrlError < Error; end
@@ -15,10 +25,17 @@ module SafeFetch
class HttpError < Error; end
class FileTooLargeError < Error; end
class UnsupportedContentTypeError < Error; end
class UnsupportedMethodError < Error; end
def self.fetch(url,
method: :get,
body: nil,
max_bytes: nil,
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES)
headers: nil,
http_basic_authentication: nil,
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
allowed_content_types: DEFAULT_ALLOWED_CONTENT_TYPES,
validate_content_type: true)
raise ArgumentError, 'block required' unless block_given?
effective_max_bytes = max_bytes || default_max_bytes
@@ -26,11 +43,26 @@ module SafeFetch
filename = filename_for(uri)
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
response = stream_to_tempfile(url, tempfile, effective_max_bytes, allowed_content_type_prefixes)
response = stream_to_tempfile(
url,
method,
body,
tempfile,
effective_max_bytes,
headers,
http_basic_authentication,
allowed_content_type_prefixes,
allowed_content_types,
validate_content_type
)
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'])
yield Result.new(
tempfile: duplicate_tempfile(tempfile),
filename: filename,
content_type: normalize_content_type(response['content-type'])
)
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
raise InvalidUrlError, e.message
rescue SsrfFilter::Error, Resolv::ResolvError => e
@@ -44,18 +76,26 @@ module SafeFetch
class << self
private
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes)
def stream_to_tempfile(url, method, body, tempfile, max_bytes, headers, http_basic_authentication,
allowed_content_type_prefixes, allowed_content_types, validate_content_type)
response = nil
bytes_written = 0
SsrfFilter.get(
http_method = normalize_method(method)
SsrfFilter.public_send(
http_method,
url,
headers: headers,
body: body,
request_proc: request_proc(http_basic_authentication),
sensitive_headers: sensitive_headers(headers),
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)
if validate_content_type && !allowed_content_type?(res['content-type'], allowed_content_type_prefixes, allowed_content_types)
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
end
@@ -74,12 +114,28 @@ module SafeFetch
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end
def duplicate_tempfile(tempfile)
duplicated = tempfile.dup
duplicated.rewind
duplicated
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
limit_mb.megabytes
end
def request_proc(http_basic_authentication)
return unless http_basic_authentication.present?
proc { |request| request.basic_auth(*http_basic_authentication) }
end
def sensitive_headers(headers)
DEFAULT_SENSITIVE_HEADERS | Array(headers).map { |header, _| header.to_s }
end
def parse_and_validate_url!(url)
uri = URI.parse(url)
raise InvalidUrlError, 'scheme must be http or https' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
@@ -88,11 +144,22 @@ module SafeFetch
uri
end
def allowed_content_type?(value, prefixes)
mime = value.to_s.split(';').first&.strip&.downcase
def normalize_method(method)
http_method = method.to_s.downcase.to_sym
return http_method if SsrfFilter::VERB_MAP.key?(http_method)
raise UnsupportedMethodError, "unsupported method: #{method}"
end
def normalize_content_type(value)
value.to_s.split(';').first&.strip&.downcase
end
def allowed_content_type?(value, prefixes, content_types)
mime = normalize_content_type(value)
return false if mime.blank?
prefixes.any? { |prefix| mime.start_with?(prefix) }
Array(prefixes).any? { |prefix| mime.start_with?(prefix) } || Array(content_types).include?(mime)
end
end
end