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.
123 lines
3.8 KiB
Ruby
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
|