Files
chatwoot/lib/safe_fetch/request_options.rb
T
Aakash BakhleandGitHub 9c68eed676 fix(captain): send custom tool API key headers (#15008)
Captain custom tools configured with API key authentication now send the
configured key in the requested HTTP header. Existing tools begin
working without needing to be recreated or reconfigured, while
credentials remain protected across redirects.

## How to reproduce

1. Create a Captain custom tool using API Key authentication.
2. Configure `X-API-Key` as the header name and save the tool.
3. Invoke the tool and inspect the incoming request.
4. Before this change, the API key header is absent; after this change,
the configured endpoint receives it.

## What changed

The UI persists API key authentication as `name` and `key`, but the
request builder also required an unused `location: header` property. The
request builder now treats API key authentication as header-based,
matching the only mode exposed by the UI.

Custom authentication headers are also registered as sensitive with
`SafeFetch`. They are retained for the configured endpoint and
same-origin redirects, but stripped when a redirect crosses origins to
prevent credential leakage. Factory, request, and redirect specs cover
the real UI payload and both public and private-network fetch paths.
2026-07-15 22:43:30 +05:30

123 lines
3.8 KiB
Ruby

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,
sensitive_headers: [],
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, :sensitive_headers, :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])
@sensitive_headers = normalize_sensitive_headers(config[:sensitive_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
def resolver
SsrfFilter::DEFAULT_RESOLVER
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 normalize_sensitive_headers(value)
(SafeFetch::DEFAULT_SENSITIVE_HEADERS + Array(value)).map { |header| header.to_s.downcase }.uniq
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 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