fix(security): harden macro webhook delivery

This commit is contained in:
Sony Mathew
2026-04-23 01:48:45 +05:30
parent aba27c11f8
commit 0506c477eb
10 changed files with 179 additions and 8 deletions
+43
View File
@@ -0,0 +1,43 @@
require 'ipaddr'
require 'resolv'
require 'ssrf_filter'
require 'uri'
module SafeOutboundUrl
class Error < StandardError; end
class InvalidUrlError < Error; end
class UnsafeUrlError < Error; end
def self.validate!(url, resolver: SsrfFilter::DEFAULT_RESOLVER)
uri = parse_http_url!(url)
ip_addresses = resolve_addresses(uri.hostname, resolver)
raise UnsafeUrlError, "Could not resolve hostname '#{uri.hostname}'" if ip_addresses.empty?
raise UnsafeUrlError, "Hostname '#{uri.hostname}' has no public ip addresses" if ip_addresses.all? { |ip| unsafe_ip_address?(ip) }
uri
rescue URI::InvalidURIError => e
raise InvalidUrlError, e.message
rescue IPAddr::InvalidAddressError, Resolv::ResolvError => e
raise UnsafeUrlError, e.message
end
def self.parse_http_url!(url)
uri = URI.parse(url.to_s)
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.hostname.blank?
uri
end
private_class_method :parse_http_url!
def self.resolve_addresses(hostname, resolver)
Array(resolver.call(hostname)).map { |ip| ip.is_a?(IPAddr) ? ip : IPAddr.new(ip) }
end
private_class_method :resolve_addresses
def self.unsafe_ip_address?(ip_address)
SsrfFilter.send(:unsafe_ip_address?, ip_address)
end
private_class_method :unsafe_ip_address?
end
+28
View File
@@ -1,3 +1,5 @@
require 'ssrf_filter'
class Webhooks::Trigger
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
@@ -32,6 +34,8 @@ class Webhooks::Trigger
def perform_request
body = @payload.to_json
return perform_macro_request(body) if @webhook_type == :macro_webhook
RestClient::Request.execute(
method: :post,
url: @url,
@@ -41,6 +45,19 @@ class Webhooks::Trigger
)
end
def perform_macro_request(body)
headers = safe_request_headers(body)
response = SsrfFilter.post(
@url,
body: body,
headers: headers,
sensitive_headers: headers.keys,
http_options: { open_timeout: webhook_timeout, read_timeout: webhook_timeout }
)
raise StandardError, "Webhook request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
end
def request_headers(body)
headers = { content_type: :json, accept: :json }
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
@@ -52,6 +69,17 @@ class Webhooks::Trigger
headers
end
def safe_request_headers(body)
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
headers['X-Chatwoot-Timestamp'] = ts
headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}"
end
headers
end
def handle_error(error)
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless message