chatwoot/lib/safe_fetch/request_options.rb
Vishnu Narayanan 7c16071fc7
fix: Support allowlisted private API inbox webhooks (#14548)
Self-hosted installations can now opt SafeFetch into private-network
access after SSRF hardening. The default remains unchanged: private IP
destinations are blocked unless the instance owner explicitly enables
private-network requests with `SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true`.

Fixes https://linear.app/chatwoot/issue/CW-7131
Fixes https://github.com/chatwoot/chatwoot/issues/14489
Fixes https://github.com/chatwoot/chatwoot/issues/14494

## How to use

For self-hosted installations that need API inbox webhooks, or other
SafeFetch-backed requests, to call trusted private services, enable
private-network access with a single environment variable:

```bash
SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true
```

This is disabled by default. Enable it only when the instance owner
controls the deployment network and trusts the configured URLs.
2026-05-26 17:03:19 +05:30

121 lines
3.6 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,
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
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 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