Files
chatwoot/spec/lib/safe_fetch_spec.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

569 lines
21 KiB
Ruby

require 'rails_helper'
# `SafeFetch.fetch` is a custom method that requires a block (it yields a Result);
# it is NOT `Hash#fetch`, so RuboCop's autocorrect to `fetch(url, nil)` would break the API.
# rubocop:disable Style/RedundantFetchBlock
RSpec.describe SafeFetch do
let(:url) { 'http://example.com/image.png' }
before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
end
describe '.fetch' do
context 'with a valid public URL serving an image' do
before do
stub_request(:get, url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
end
it 'yields a Result with tempfile, filename, and content_type' do
described_class.fetch(url) do |result|
expect(result.tempfile).to be_a(Tempfile)
expect(result.filename).to eq('image.png')
expect(result.content_type).to eq('image/png')
expect(result.tempfile.size).to be > 0
end
end
it 'closes the tempfile after the block returns' do
captured = nil
described_class.fetch(url) { |result| captured = result.tempfile }
expect(captured.closed?).to be true
end
it 'closes the tempfile even when the block raises' do
captured = nil
expect do
described_class.fetch(url) do |result|
captured = result.tempfile
raise 'boom'
end
end.to raise_error('boom')
expect(captured.closed?).to be true
end
it 'defaults the filename to a unique "download-<timestamp>-<hex>" when the URL has no path' do
bare_url = 'http://example.com'
stub_request(:get, bare_url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.fetch(bare_url) do |result|
expect(result.filename).to match(/\Adownload-\d+-[a-f0-9]{8}\z/)
end
end
it 'requires a block' do
expect { described_class.fetch(url) }.to raise_error(ArgumentError, /block required/)
end
end
context 'with embedded basic auth credentials' do
it 'passes decoded credentials to the request' do
authenticated_url = 'http://user+avatar%40example.com:p%40ss+word%3A1@example.com/image.png'
stub_request(:get, url)
.with(headers: { 'Authorization' => 'Basic dXNlcithdmF0YXJAZXhhbXBsZS5jb206cEBzcyt3b3JkOjE=' })
.to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.fetch(authenticated_url) do |result|
expect(result.content_type).to eq('image/png')
end
end
it 'preserves embedded credentials after a same-origin redirect removes userinfo' do
authenticated_url = 'http://user:pass@example.com/protected.png'
initial_url = 'http://example.com/protected.png'
redirect_url = 'http://example.com/public.png'
redirected_headers = nil
stub_request(:get, initial_url)
.with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNz' })
.to_return(
status: 302,
headers: { 'Location' => '/public.png' }
)
stub_request(:get, redirect_url)
.with do |request|
redirected_headers = request.headers.transform_keys(&:downcase)
true
end
.to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.fetch(authenticated_url) do |result|
expect(result.content_type).to eq('image/png')
end
expect(redirected_headers).to include('authorization' => 'Basic dXNlcjpwYXNz')
end
it 'strips embedded credentials on cross-origin redirects' do
authenticated_url = 'http://user:pass@example.com/protected.png'
initial_url = 'http://example.com/protected.png'
redirect_url = 'https://example.com/public.png'
redirected_headers = nil
stub_request(:get, initial_url)
.with(headers: { 'Authorization' => 'Basic dXNlcjpwYXNz' })
.to_return(
status: 302,
headers: { 'Location' => redirect_url }
)
stub_request(:get, redirect_url)
.with do |request|
redirected_headers = request.headers.transform_keys(&:downcase)
true
end
.to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.fetch(authenticated_url) do |result|
expect(result.content_type).to eq('image/png')
end
expect(redirected_headers).not_to include('authorization')
end
end
context 'with URL validation' do
it 'raises InvalidUrlError for javascript: URLs' do
expect { described_class.fetch('javascript:alert(1)') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError for mailto: URLs' do
expect { described_class.fetch('mailto:test@example.com') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError for data: URLs' do
expect { described_class.fetch('data:text/html,<x>') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError for ftp: URLs' do
expect { described_class.fetch('ftp://example.com/file') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError for malformed URLs' do
expect { described_class.fetch('not_a_url') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
it 'raises InvalidUrlError when host is missing' do
expect { described_class.fetch('http:///path') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::InvalidUrlError')
end
end
end
context 'with SSRF protection (integration with ssrf_filter)' do
it 'raises UnsafeUrlError for private IP literals (10.x.x.x)' do
expect { described_class.fetch('http://10.0.0.1/secret') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
it 'raises UnsafeUrlError for loopback addresses' do
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
it 'raises UnsafeUrlError for AWS metadata IP (169.254.169.254)' do
expect { described_class.fetch('http://169.254.169.254/latest/meta-data/') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
it 'raises UnsafeUrlError when hostname resolves to a private IP (DNS rebinding)' do
allow(Resolv).to receive(:getaddresses).with('evil.example.com').and_return(['10.0.0.1'])
expect { described_class.fetch('http://evil.example.com/secret') { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsafeUrlError')
end
end
it 'allows private IP literals when private network access is enabled' do
private_url = 'http://192.168.3.21/image.png'
allow(Resolv).to receive(:getaddresses).with('192.168.3.21').and_return(['192.168.3.21'])
stub_request(:get, private_url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
expect { described_class.fetch(private_url) { nil } }.not_to raise_error
end
end
it 'allows private hostnames when private network access is enabled' do
private_url = 'http://internal-webhook-service/image.png'
allow(Resolv).to receive(:getaddresses).with('internal-webhook-service').and_return(['10.0.0.5'])
stub_request(:get, private_url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
expect { described_class.fetch(private_url) { nil } }.not_to raise_error
end
end
it 'allows redirects to private hostnames when private network access is enabled' do
redirect_url = 'http://example.com/redirect.png'
private_url = 'http://private.example.com/image.png'
allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5'])
stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url })
stub_request(:get, private_url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
expect { described_class.fetch(redirect_url) { nil } }.not_to raise_error
end
end
it 'strips caller-provided sensitive headers on private network cross-origin redirects' do
redirect_url = 'http://example.com/redirect.png'
private_url = 'http://private.example.com/image.png'
redirected_headers = nil
allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5'])
stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url })
stub_request(:get, private_url)
.with do |request|
redirected_headers = request.headers.transform_keys(&:downcase)
true
end
.to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do
described_class.fetch(
redirect_url,
headers: { 'X-API-Key' => 'secret-key' },
sensitive_headers: ['X-API-Key']
) { nil }
end
expect(redirected_headers).not_to include('x-api-key')
end
end
context 'with content-type allowlist' do
it 'rejects text/html responses' do
stub_request(:get, url).to_return(
status: 200,
body: '<html></html>',
headers: { 'Content-Type' => 'text/html' }
)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
end
end
it 'rejects application/octet-stream responses' do
stub_request(:get, url).to_return(
status: 200,
body: 'x',
headers: { 'Content-Type' => 'application/octet-stream' }
)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
end
end
it 'allows video/mp4 responses' do
stub_request(:get, url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'video/mp4' }
)
expect { described_class.fetch(url) { nil } }.not_to raise_error
end
it 'normalizes parameters and casing before yielding content_type' do
stub_request(:get, url).to_return(
status: 200,
body: 'x',
headers: { 'Content-Type' => 'IMAGE/PNG; charset=binary' }
)
described_class.fetch(url) do |result|
expect(result.content_type).to eq('image/png')
end
end
it 'allows exact content-type matches when prefixes are empty' do
pdf_url = 'http://example.com/file.pdf'
stub_request(:get, pdf_url).to_return(
status: 200,
body: 'pdf-data',
headers: { 'Content-Type' => 'application/pdf' }
)
expect do
described_class.fetch(
pdf_url,
allowed_content_type_prefixes: [],
allowed_content_types: ['application/pdf']
) { nil }
end.not_to raise_error
end
it 'rejects exact content-type mismatches when prefixes are empty' do
stub_request(:get, url).to_return(
status: 200,
body: 'x',
headers: { 'Content-Type' => 'image/webp' }
)
expect { described_class.fetch(url, allowed_content_type_prefixes: [], allowed_content_types: ['image/png']) { nil } }
.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
end
end
it 'rejects when the content-type header is missing' do
stub_request(:get, url).to_return(status: 200, body: 'x', headers: {})
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedContentTypeError')
end
end
end
context 'with custom request options' do
let(:post_body) { { hello: 'world' }.to_json }
let(:headers) do
{
'Authorization' => 'Bearer test-token',
'Content-Type' => 'application/json'
}
end
it 'supports POST requests with custom headers when content-type validation is disabled' do
stub_request(:post, url)
.with(body: post_body, headers: headers)
.to_return(status: 200, body: '', headers: {})
expect do
described_class.fetch(
url,
method: :post,
body: post_body,
headers: headers,
validate_content_type: false
) { nil }
end.not_to raise_error
end
it 'preserves non-credential headers on cross-origin redirects' do
redirect_url = 'https://example.com/image.png'
redirected_headers = nil
headers = {
'Authorization' => 'Bearer test-token',
'Cookie' => 'session=test',
'Content-Type' => 'application/json',
'X-Chatwoot-Delivery' => 'test-uuid',
'X-Chatwoot-Signature' => 'sha256=test-signature'
}
stub_request(:post, url).to_return(
status: 307,
headers: { 'Location' => redirect_url }
)
stub_request(:post, redirect_url)
.with do |request|
redirected_headers = request.headers.transform_keys(&:downcase)
true
end
.to_return(status: 200, body: '', headers: {})
described_class.fetch(
url,
method: :post,
body: post_body,
headers: headers,
validate_content_type: false
) { nil }
expect(redirected_headers).to include(
'content-type' => 'application/json',
'x-chatwoot-delivery' => 'test-uuid',
'x-chatwoot-signature' => 'sha256=test-signature'
)
expect(redirected_headers).not_to include('authorization', 'cookie')
end
it 'strips caller-provided sensitive headers on cross-origin redirects' do
redirect_url = 'https://example.com/image.png'
redirected_headers = nil
headers = { 'X-API-Key' => 'secret-key' }
stub_request(:get, url).to_return(status: 302, headers: { 'Location' => redirect_url })
stub_request(:get, redirect_url)
.with do |request|
redirected_headers = request.headers.transform_keys(&:downcase)
true
end
.to_return(status: 200, body: '', headers: {})
described_class.fetch(
url,
headers: headers,
sensitive_headers: ['X-API-Key'],
validate_content_type: false
) { nil }
expect(redirected_headers).not_to include('x-api-key')
end
it 'preserves caller-provided sensitive headers on same-origin redirects' do
redirect_url = 'http://example.com/redirected.png'
stub_request(:get, url).to_return(status: 302, headers: { 'Location' => '/redirected.png' })
stub_request(:get, redirect_url)
.with(headers: { 'X-API-Key' => 'secret-key' })
.to_return(status: 200, body: '', headers: {})
described_class.fetch(
url,
headers: { 'X-API-Key' => 'secret-key' },
sensitive_headers: ['X-API-Key'],
validate_content_type: false
) { nil }
expect(WebMock).to have_requested(:get, redirect_url).with(headers: { 'X-API-Key' => 'secret-key' })
end
it 'raises UnsupportedMethodError for unsupported HTTP methods' do
expect { described_class.fetch(url, method: :options) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::UnsupportedMethodError')
end
end
end
context 'with body size cap' do
it 'honours a custom max_bytes argument' do
stub_request(:get, url).to_return(
status: 200,
body: 'xxxxx',
headers: { 'Content-Type' => 'image/png' }
)
expect { described_class.fetch(url, max_bytes: 2) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::FileTooLargeError')
end
end
it 'reads the default cap from GlobalConfigService MAXIMUM_FILE_UPLOAD_SIZE (matching Attachment#validate_file_size)' do
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('1')
oversize = 'x' * (1.megabyte + 1)
stub_request(:get, url).to_return(
status: 200,
body: oversize,
headers: { 'Content-Type' => 'image/png' }
)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::FileTooLargeError')
end
end
it 'falls back to 40 MB when GlobalConfigService returns a non-positive value' do
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('-10')
# 1 MB body should pass under the 40 MB fallback
stub_request(:get, url).to_return(
status: 200,
body: 'x' * 1.megabyte,
headers: { 'Content-Type' => 'image/png' }
)
expect { described_class.fetch(url) { nil } }.not_to raise_error
end
it 'allows uploads between the old hardcoded 10 MB and the configured limit (regression check)' do
# Default config is 40 MB; a 15 MB upload must succeed.
# This is the exact regression scenario: with the old hardcoded 10 MB cap,
# this would have failed even though direct file uploads of the same size succeed.
allow(GlobalConfigService).to receive(:load).and_call_original
allow(GlobalConfigService).to receive(:load).with('MAXIMUM_FILE_UPLOAD_SIZE', 40).and_return('40')
stub_request(:get, url).to_return(
status: 200,
body: 'x' * (15 * 1024 * 1024),
headers: { 'Content-Type' => 'image/png' }
)
expect { described_class.fetch(url) { nil } }.not_to raise_error
end
end
context 'with network failures' do
it 'maps Net::ReadTimeout to FetchError' do
stub_request(:get, url).to_raise(Net::ReadTimeout)
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::FetchError')
end
end
it 'maps SocketError to FetchError' do
stub_request(:get, url).to_raise(SocketError.new('connection refused'))
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::FetchError')
end
end
end
context 'with non-2xx upstream responses' do
it 'raises HttpError on non-2xx responses' do
stub_request(:get, url).to_return(status: 404, body: '', headers: {})
expect { described_class.fetch(url) { nil } }.to raise_error do |error|
expect(error.class.name).to eq('SafeFetch::HttpError')
end
end
end
end
end
# rubocop:enable Style/RedundantFetchBlock