fix: [CW-6940] Fix SSRF issue for webhook trigger used by macros and automations (#14155)

This routes external downloads used by webhook fetch used by macros and
acutomations 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-6940](https://linear.app/chatwoot/issue/CW-6940/ssrf-via-webhooksautomationmacros-non-upload-non-avatar)
This commit is contained in:
Sony Mathew
2026-04-27 20:30:59 +05:30
committed by GitHub
parent 035d2858f5
commit c8e551820b
8 changed files with 461 additions and 238 deletions

View File

@@ -2,6 +2,8 @@ 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 proxy-authorization].freeze
DEFAULT_OPEN_TIMEOUT = 2
DEFAULT_READ_TIMEOUT = 20
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
@@ -19,106 +21,22 @@ module SafeFetch
class HttpError < Error; end
class FileTooLargeError < Error; end
class UnsupportedContentTypeError < Error; end
class UnsupportedMethodError < Error; end
end
def self.fetch(url,
max_bytes: nil,
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
allowed_content_types: [])
require_relative 'safe_fetch/request_options'
require_relative 'safe_fetch/fetcher'
module SafeFetch
def self.fetch(url, **, &)
raise ArgumentError, 'block required' unless block_given?
effective_max_bytes = max_bytes || default_max_bytes
filename = filename_for(parse_and_validate_url!(url))
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
response = fetch_response(url, tempfile, effective_max_bytes, allowed_content_type_prefixes, allowed_content_types)
yield build_result(tempfile, filename, response)
Fetcher.new(RequestOptions.new(url: url, **)).fetch(&)
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
raise InvalidUrlError, e.message
rescue SsrfFilter::Error, Resolv::ResolvError => e
raise UnsafeUrlError, e.message
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
raise FetchError, e.message
ensure
tempfile&.close!
end
class << self
private
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, allowed_content_types)
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
end
res.read_body do |chunk|
bytes_written += chunk.bytesize
raise FileTooLargeError, "exceeded #{max_bytes} bytes" if bytes_written > max_bytes
tempfile.write(chunk)
end
end
response
end
def filename_for(uri)
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
limit_mb.megabytes
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)
raise InvalidUrlError, 'missing host' if uri.host.blank?
uri
end
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) } || 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

75
lib/safe_fetch/fetcher.rb Normal file
View File

@@ -0,0 +1,75 @@
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)
response = nil
bytes_written = 0
SsrfFilter.public_send(options.method, options.url, **options.request_options) do |res|
response = res
next unless res.is_a?(Net::HTTPSuccess)
validate_content_type!(res['content-type'])
bytes_written = write_response_body(res, tempfile, bytes_written)
end
response
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

View File

@@ -0,0 +1,116 @@
class SafeFetch::RequestOptions
DEFAULTS = {
method: :get,
body: nil,
max_bytes: nil,
open_timeout: SafeFetch::DEFAULT_OPEN_TIMEOUT,
read_timeout: SafeFetch::DEFAULT_READ_TIMEOUT,
headers: nil,
http_basic_authentication: nil,
allowed_content_type_prefixes: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
allowed_content_types: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPES,
validate_content_type: true
}.freeze
attr_reader :allowed_content_type_prefixes, :allowed_content_types, :body, :headers,
:http_basic_authentication, :method, :open_timeout, :read_timeout, :uri, :url
def initialize(url:, **options)
config = DEFAULTS.merge(options)
@url = url
@uri = parse_and_validate_url!(url)
@method = normalize_method(config[:method])
@body = config[:body]
@max_bytes = config[:max_bytes]
@open_timeout = config[:open_timeout]
@read_timeout = config[:read_timeout]
@headers = normalize_headers(config[:headers])
@http_basic_authentication = config[:http_basic_authentication]
@allowed_content_type_prefixes = Array(config[:allowed_content_type_prefixes])
@allowed_content_types = Array(config[:allowed_content_types])
@validate_content_type = config[:validate_content_type]
end
def effective_max_bytes
@effective_max_bytes ||= @max_bytes || default_max_bytes
end
def filename
@filename ||= File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end
def request_options
{
headers: headers,
body: body,
request_proc: request_proc,
sensitive_headers: sensitive_headers,
http_options: { open_timeout: open_timeout, read_timeout: read_timeout }
}
end
def validate_content_type?
@validate_content_type
end
private
def default_max_bytes
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
limit_mb = SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
limit_mb.megabytes
end
def parse_and_validate_url!(value)
parsed_uri = URI.parse(value)
raise SafeFetch::InvalidUrlError, 'scheme must be http or https' unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
raise SafeFetch::InvalidUrlError, 'missing host' if parsed_uri.host.blank?
parsed_uri
end
def normalize_method(value)
http_method = value.to_s.downcase.to_sym
return http_method if SsrfFilter::VERB_MAP.key?(http_method)
raise SafeFetch::UnsupportedMethodError, "unsupported method: #{value}"
end
def normalize_headers(value)
value&.to_h
end
def request_proc
proc do |request|
credentials = http_basic_authentication.presence || basic_authentication_for(request.uri)
request.basic_auth(*credentials) if credentials.present?
end
end
def sensitive_headers
SafeFetch::DEFAULT_SENSITIVE_HEADERS
end
def basic_authentication_for(request_uri)
uri_basic_authentication(request_uri) || original_uri_basic_authentication(request_uri)
end
def original_uri_basic_authentication(request_uri)
return unless same_origin?(request_uri, uri)
uri_basic_authentication(uri)
end
def same_origin?(request_uri, other_uri)
request_uri.scheme == other_uri.scheme && request_uri.hostname == other_uri.hostname && request_uri.port == other_uri.port
end
def uri_basic_authentication(value)
return if value.user.blank?
[
URI.decode_uri_component(value.user),
URI.decode_uri_component(value.password.to_s)
]
end
end

View File

@@ -1,5 +1,15 @@
class Webhooks::Trigger
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
RETRYABLE_AGENT_BOT_STATUSES = [429, 500].freeze
class RetryableError < StandardError
attr_reader :status
def initialize(status:, message:)
@status = status
super(message)
end
end
def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil)
@url = url
@@ -15,11 +25,9 @@ class Webhooks::Trigger
def execute
perform_request
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
raise if @webhook_type == :agent_bot_webhook
handle_failure(e)
rescue StandardError => e
raise RetryableError.new(status: http_status(e), message: e.message) if retryable_agent_bot_error?(e)
handle_failure(e)
end
@@ -32,17 +40,19 @@ class Webhooks::Trigger
def perform_request
body = @payload.to_json
RestClient::Request.execute(
SafeFetch.fetch(
@url,
method: :post,
url: @url,
payload: body,
body: body,
headers: request_headers(body),
timeout: webhook_timeout
)
open_timeout: webhook_timeout,
read_timeout: webhook_timeout,
validate_content_type: false
) { |_response| nil }
end
def request_headers(body)
headers = { content_type: :json, accept: :json }
headers = { 'Content-Type' => 'application/json', 'Accept' => 'application/json' }
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
if @secret.present?
ts = Time.now.to_i.to_s
@@ -111,4 +121,14 @@ class Webhooks::Trigger
timeout&.positive? ? timeout : 5
end
def retryable_agent_bot_error?(error)
@webhook_type == :agent_bot_webhook && RETRYABLE_AGENT_BOT_STATUSES.include?(http_status(error))
end
def http_status(error)
return unless error.is_a?(SafeFetch::HttpError)
error.message.to_s[/\A(\d{3})\b/, 1]&.to_i
end
end