fix: harden fetching on upload endpoint (#14012)
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -40,6 +40,8 @@ gem 'json_refs'
|
|||||||
gem 'rack-attack', '>= 6.7.0'
|
gem 'rack-attack', '>= 6.7.0'
|
||||||
# a utility tool for streaming, flexible and safe downloading of remote files
|
# a utility tool for streaming, flexible and safe downloading of remote files
|
||||||
gem 'down'
|
gem 'down'
|
||||||
|
# SSRF-safe URL fetching
|
||||||
|
gem 'ssrf_filter', '~> 1.5'
|
||||||
# authentication type to fetch and send mail over oauth2.0
|
# authentication type to fetch and send mail over oauth2.0
|
||||||
gem 'gmail_xoauth'
|
gem 'gmail_xoauth'
|
||||||
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
# Lock net-smtp to 0.3.4 to avoid issues with gmail_xoauth2
|
||||||
|
|||||||
@@ -942,6 +942,7 @@ GEM
|
|||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
squasher (0.7.2)
|
squasher (0.7.2)
|
||||||
|
ssrf_filter (1.5.0)
|
||||||
stackprof (0.2.25)
|
stackprof (0.2.25)
|
||||||
statsd-ruby (1.5.0)
|
statsd-ruby (1.5.0)
|
||||||
stripe (18.0.1)
|
stripe (18.0.1)
|
||||||
@@ -1158,6 +1159,7 @@ DEPENDENCIES
|
|||||||
spring
|
spring
|
||||||
spring-watcher-listen
|
spring-watcher-listen
|
||||||
squasher
|
squasher
|
||||||
|
ssrf_filter (~> 1.5)
|
||||||
stackprof
|
stackprof
|
||||||
stripe (~> 18.0)
|
stripe (~> 18.0)
|
||||||
telephone_number
|
telephone_number
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
elsif params[:external_url].present?
|
elsif params[:external_url].present?
|
||||||
create_from_url
|
create_from_url
|
||||||
else
|
else
|
||||||
render_error('No file or URL provided', :unprocessable_entity)
|
render_error(I18n.t('errors.upload.missing_input'), :unprocessable_entity)
|
||||||
end
|
end
|
||||||
|
|
||||||
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
||||||
@@ -19,35 +19,21 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create_from_url
|
def create_from_url
|
||||||
uri = parse_uri(params[:external_url])
|
SafeFetch.fetch(params[:external_url].to_s) do |result|
|
||||||
return if performed?
|
create_and_save_blob(result.tempfile, result.filename, result.content_type)
|
||||||
|
|
||||||
fetch_and_process_file_from_uri(uri)
|
|
||||||
end
|
|
||||||
|
|
||||||
def parse_uri(url)
|
|
||||||
uri = URI.parse(url)
|
|
||||||
validate_uri(uri)
|
|
||||||
uri
|
|
||||||
rescue URI::InvalidURIError, SocketError
|
|
||||||
render_error('Invalid URL provided', :unprocessable_entity)
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_uri(uri)
|
|
||||||
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_and_process_file_from_uri(uri)
|
|
||||||
uri.open do |file|
|
|
||||||
create_and_save_blob(file, File.basename(uri.path), file.content_type)
|
|
||||||
end
|
end
|
||||||
rescue OpenURI::HTTPError => e
|
rescue SafeFetch::HttpError => e
|
||||||
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
|
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
|
||||||
rescue SocketError
|
rescue SafeFetch::FetchError
|
||||||
render_error('Invalid URL provided', :unprocessable_entity)
|
render_error(I18n.t('errors.upload.fetch_failed'), :unprocessable_entity)
|
||||||
|
rescue SafeFetch::FileTooLargeError
|
||||||
|
render_error(I18n.t('errors.upload.file_too_large'), :unprocessable_entity)
|
||||||
|
rescue SafeFetch::UnsupportedContentTypeError
|
||||||
|
render_error(I18n.t('errors.upload.unsupported_content_type'), :unprocessable_entity)
|
||||||
|
rescue SafeFetch::Error
|
||||||
|
render_error(I18n.t('errors.upload.invalid_url'), :unprocessable_entity)
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
render_error('An unexpected error occurred', :internal_server_error)
|
render_error(I18n.t('errors.upload.unexpected'), :internal_server_error)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_and_save_blob(io, filename, content_type)
|
def create_and_save_blob(io, filename, content_type)
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ en:
|
|||||||
not_found: Assignment policy not found
|
not_found: Assignment policy not found
|
||||||
attachments:
|
attachments:
|
||||||
invalid: Invalid attachment
|
invalid: Invalid attachment
|
||||||
|
upload:
|
||||||
|
missing_input: 'No file or URL provided'
|
||||||
|
invalid_url: 'Invalid URL provided'
|
||||||
|
fetch_failed: 'Failed to fetch file from URL'
|
||||||
|
fetch_failed_with_message: 'Failed to fetch file from URL: %{message}'
|
||||||
|
file_too_large: 'File exceeds the maximum allowed size'
|
||||||
|
unsupported_content_type: 'File type not supported (only images and videos are allowed)'
|
||||||
|
unexpected: 'An unexpected error occurred'
|
||||||
saml:
|
saml:
|
||||||
feature_not_enabled: SAML feature not enabled for this account
|
feature_not_enabled: SAML feature not enabled for this account
|
||||||
sso_not_enabled: SAML SSO is not enabled for this installation
|
sso_not_enabled: SAML SSO is not enabled for this installation
|
||||||
|
|||||||
98
lib/safe_fetch.rb
Normal file
98
lib/safe_fetch.rb
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
require 'ssrf_filter'
|
||||||
|
|
||||||
|
module SafeFetch
|
||||||
|
DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/].freeze
|
||||||
|
DEFAULT_OPEN_TIMEOUT = 2
|
||||||
|
DEFAULT_READ_TIMEOUT = 20
|
||||||
|
DEFAULT_MAX_BYTES_FALLBACK_MB = 40
|
||||||
|
|
||||||
|
Result = Data.define(:tempfile, :filename, :content_type)
|
||||||
|
|
||||||
|
class Error < StandardError; end
|
||||||
|
class InvalidUrlError < Error; end
|
||||||
|
class UnsafeUrlError < Error; end
|
||||||
|
class FetchError < Error; end
|
||||||
|
class HttpError < Error; end
|
||||||
|
class FileTooLargeError < Error; end
|
||||||
|
class UnsupportedContentTypeError < Error; end
|
||||||
|
|
||||||
|
def self.fetch(url,
|
||||||
|
max_bytes: nil,
|
||||||
|
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES)
|
||||||
|
raise ArgumentError, 'block required' unless block_given?
|
||||||
|
|
||||||
|
effective_max_bytes = max_bytes || default_max_bytes
|
||||||
|
uri = parse_and_validate_url!(url)
|
||||||
|
filename = filename_for(uri)
|
||||||
|
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
|
||||||
|
|
||||||
|
response = stream_to_tempfile(url, tempfile, effective_max_bytes, allowed_content_type_prefixes)
|
||||||
|
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
|
||||||
|
|
||||||
|
tempfile.rewind
|
||||||
|
yield Result.new(tempfile: tempfile, filename: filename, content_type: response['content-type'])
|
||||||
|
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
|
||||||
|
raise InvalidUrlError, e.message
|
||||||
|
rescue SsrfFilter::Error, Resolv::ResolvError => e
|
||||||
|
raise UnsafeUrlError, e.message
|
||||||
|
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
|
||||||
|
raise FetchError, e.message
|
||||||
|
ensure
|
||||||
|
tempfile&.close!
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
private
|
||||||
|
|
||||||
|
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes)
|
||||||
|
response = nil
|
||||||
|
bytes_written = 0
|
||||||
|
|
||||||
|
SsrfFilter.get(
|
||||||
|
url,
|
||||||
|
http_options: { open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT }
|
||||||
|
) do |res|
|
||||||
|
response = res
|
||||||
|
next unless res.is_a?(Net::HTTPSuccess)
|
||||||
|
|
||||||
|
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes)
|
||||||
|
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
|
||||||
|
end
|
||||||
|
|
||||||
|
res.read_body do |chunk|
|
||||||
|
bytes_written += chunk.bytesize
|
||||||
|
raise FileTooLargeError, "exceeded #{max_bytes} bytes" if bytes_written > max_bytes
|
||||||
|
|
||||||
|
tempfile.write(chunk)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response
|
||||||
|
end
|
||||||
|
|
||||||
|
def filename_for(uri)
|
||||||
|
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_max_bytes
|
||||||
|
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
|
||||||
|
limit_mb = DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
|
||||||
|
limit_mb.megabytes
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_and_validate_url!(url)
|
||||||
|
uri = URI.parse(url)
|
||||||
|
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.host.blank?
|
||||||
|
|
||||||
|
uri
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_content_type?(value, prefixes)
|
||||||
|
mime = value.to_s.split(';').first&.strip&.downcase
|
||||||
|
return false if mime.blank?
|
||||||
|
|
||||||
|
prefixes.any? { |prefix| mime.start_with?(prefix) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -39,6 +39,11 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
|||||||
let(:valid_external_url) { 'http://example.com/image.jpg' }
|
let(:valid_external_url) { 'http://example.com/image.jpg' }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
|
allow(Resolv).to receive(:getaddresses).and_call_original
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('error.example.com').and_return(['93.184.216.34'])
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('nonexistent.example.com').and_return(['93.184.216.34'])
|
||||||
|
|
||||||
stub_request(:get, valid_external_url)
|
stub_request(:get, valid_external_url)
|
||||||
.to_return(status: 200, body: File.new(Rails.root.join('spec/assets/avatar.png')), headers: { 'Content-Type' => 'image/png' })
|
.to_return(status: 200, body: File.new(Rails.root.join('spec/assets/avatar.png')), headers: { 'Content-Type' => 'image/png' })
|
||||||
end
|
end
|
||||||
@@ -82,7 +87,7 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
|||||||
params: { external_url: 'http://nonexistent.example.com' }
|
params: { external_url: 'http://nonexistent.example.com' }
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
expect(response.parsed_body['error']).to eq('Failed to fetch file from URL')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'handles HTTP errors' do
|
it 'handles HTTP errors' do
|
||||||
@@ -96,6 +101,112 @@ RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
|||||||
expect(response).to have_http_status(:unprocessable_entity)
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
|
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'rejects oversized responses with a file-size message' do
|
||||||
|
stub_request(:get, valid_external_url)
|
||||||
|
.to_return(status: 200,
|
||||||
|
body: 'x' * (41 * 1024 * 1024),
|
||||||
|
headers: { 'Content-Type' => 'image/png' })
|
||||||
|
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: valid_external_url }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('File exceeds the maximum allowed size')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects unsupported content types with a file-type message' do
|
||||||
|
stub_request(:get, valid_external_url)
|
||||||
|
.to_return(status: 200,
|
||||||
|
body: '<html></html>',
|
||||||
|
headers: { 'Content-Type' => 'text/html' })
|
||||||
|
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: valid_external_url }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('File type not supported (only images and videos are allowed)')
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with SSRF attack vectors' do
|
||||||
|
it 'blocks requests to private IP ranges (10.x.x.x)' do
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: 'http://10.0.0.1/secret' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'blocks requests to private IP ranges (172.16.x.x)' do
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: 'http://172.16.0.1/secret' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'blocks requests to private IP ranges (192.168.x.x)' do
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: 'http://192.168.1.1/secret' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'blocks requests to loopback addresses' do
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: 'http://127.0.0.1/secret' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'blocks requests to AWS metadata service (169.254.169.254)' do
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'blocks requests to localhost' do
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: 'http://localhost/secret' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'blocks requests to .local domains' do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('server.local').and_return(['192.168.1.100'])
|
||||||
|
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: 'http://server.local/secret' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'blocks DNS rebinding attacks (hostname resolving to private IP)' do
|
||||||
|
allow(Resolv).to receive(:getaddresses).with('evil.attacker.com').and_return(['10.0.0.1'])
|
||||||
|
|
||||||
|
post upload_url,
|
||||||
|
headers: user.create_new_auth_token,
|
||||||
|
params: { external_url: 'http://evil.attacker.com/secret' }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns an error when no file or URL is provided' do
|
it 'returns an error when no file or URL is provided' do
|
||||||
|
|||||||
258
spec/lib/safe_fetch_spec.rb
Normal file
258
spec/lib/safe_fetch_spec.rb
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
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 URL validation' do
|
||||||
|
it 'raises InvalidUrlError for javascript: URLs' do
|
||||||
|
expect { described_class.fetch('javascript:alert(1)') { nil } }
|
||||||
|
.to raise_error(SafeFetch::InvalidUrlError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises InvalidUrlError for mailto: URLs' do
|
||||||
|
expect { described_class.fetch('mailto:test@example.com') { nil } }
|
||||||
|
.to raise_error(SafeFetch::InvalidUrlError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises InvalidUrlError for data: URLs' do
|
||||||
|
expect { described_class.fetch('data:text/html,<x>') { nil } }
|
||||||
|
.to raise_error(SafeFetch::InvalidUrlError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises InvalidUrlError for ftp: URLs' do
|
||||||
|
expect { described_class.fetch('ftp://example.com/file') { nil } }
|
||||||
|
.to raise_error(SafeFetch::InvalidUrlError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises InvalidUrlError for malformed URLs' do
|
||||||
|
expect { described_class.fetch('not_a_url') { nil } }
|
||||||
|
.to raise_error(SafeFetch::InvalidUrlError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises InvalidUrlError when host is missing' do
|
||||||
|
expect { described_class.fetch('http:///path') { nil } }
|
||||||
|
.to raise_error(SafeFetch::InvalidUrlError, /missing host/)
|
||||||
|
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(SafeFetch::UnsafeUrlError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises UnsafeUrlError for loopback addresses' do
|
||||||
|
expect { described_class.fetch('http://127.0.0.1/secret') { nil } }
|
||||||
|
.to raise_error(SafeFetch::UnsafeUrlError)
|
||||||
|
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(SafeFetch::UnsafeUrlError)
|
||||||
|
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(SafeFetch::UnsafeUrlError)
|
||||||
|
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(SafeFetch::UnsupportedContentTypeError)
|
||||||
|
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(SafeFetch::UnsupportedContentTypeError)
|
||||||
|
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 'strips charset/boundary parameters before comparing' do
|
||||||
|
stub_request(:get, url).to_return(
|
||||||
|
status: 200,
|
||||||
|
body: 'x',
|
||||||
|
headers: { 'Content-Type' => 'image/png; charset=binary' }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { described_class.fetch(url) { nil } }.not_to raise_error
|
||||||
|
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(SafeFetch::UnsupportedContentTypeError)
|
||||||
|
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(SafeFetch::FileTooLargeError)
|
||||||
|
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(SafeFetch::FileTooLargeError)
|
||||||
|
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(SafeFetch::FetchError)
|
||||||
|
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(SafeFetch::FetchError)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with non-2xx upstream responses' do
|
||||||
|
it 'raises HttpError with the status code in the message' do
|
||||||
|
stub_request(:get, url).to_return(status: 404, body: '', headers: {})
|
||||||
|
|
||||||
|
expect { described_class.fetch(url) { nil } }
|
||||||
|
.to raise_error(SafeFetch::HttpError, /404/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
# rubocop:enable Style/RedundantFetchBlock
|
||||||
Reference in New Issue
Block a user