diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..2a43fb99 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +When running rspec tests, please ensure that the RAILS_ENV environment variable is always set to test. The command should be executed as RAILS_ENV=test bundle exec rspec. diff --git a/app/uploaders/camaleon_cms_aws_uploader.rb b/app/uploaders/camaleon_cms_aws_uploader.rb index 5bf9191e..9cb3dfe8 100644 --- a/app/uploaders/camaleon_cms_aws_uploader.rb +++ b/app/uploaders/camaleon_cms_aws_uploader.rb @@ -37,6 +37,8 @@ def objects(prefix = '/', sort = 'created_at') end def fetch_file(file_name) + return { error: 'Invalid file path' } unless valid_folder_path?(file_name) + return file_name if file_exists?(file_name) return file_name if bucket.object(file_name).download_file(file_name) && file_exists?(file_name) @@ -84,8 +86,9 @@ def file_parse(s3_file) # - same_name: false => avoid to overwrite an existent file with same key and search for an available key # - is_thumb: true => if this file is a thumbnail of an uploaded file def add_file(uploaded_io_or_file_path, key, args = {}) + return { error: 'Invalid file path' } unless valid_folder_path?(key) + args = { same_name: false, is_thumb: false }.merge(args) - res = nil key = "#{@aws_settings['inner_folder']}/#{key}" if @aws_settings['inner_folder'].present? && !args[:is_thumb] key = key.cama_fix_media_key key = search_new_key(key) unless args[:same_name] @@ -117,6 +120,8 @@ def add_folder(key) # delete a folder in AWS with :key def delete_folder(key) + return { error: 'Invalid folder path' } unless valid_folder_path?(key) + key = "#{@aws_settings['inner_folder']}/#{key}" if @aws_settings['inner_folder'].present? key = key.cama_fix_media_key bucket.objects(prefix: key.slice(1..-1) << '/').delete @@ -125,6 +130,8 @@ def delete_folder(key) # delete a file in AWS with :key def delete_file(key) + return { error: 'Invalid file path' } unless valid_folder_path?(key) + key = "#{@aws_settings['inner_folder']}/#{key}" if @aws_settings['inner_folder'].present? key = key.cama_fix_media_key begin diff --git a/spec/uploaders/aws_uploader_spec.rb b/spec/uploaders/aws_uploader_spec.rb new file mode 100644 index 00000000..44380854 --- /dev/null +++ b/spec/uploaders/aws_uploader_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe CamaleonCmsAwsUploader do + init_site + + let(:current_site) { Cama::Site.first.decorate } + let(:hook_instance) { instance_double('UploaderHookInstance', hooks_run: nil) } # rubocop:disable RSpec/VerifiedDoubleReference + let(:uploader) { described_class.new({ current_site: current_site, aws_settings: {} }, hook_instance) } + let(:bucket) { instance_double(Aws::S3::Bucket) } + + before { allow(uploader).to receive(:bucket).and_return(bucket) } + + context 'with an invalid path containing path traversal characters' do + describe '#add_file' do + it 'returns an error' do + expect(bucket).not_to receive(:object) + + expect(uploader.add_file('/tmp/test.png', '../tmp/test.png')).to eql(error: 'Invalid file path') + end + end + + describe '#delete_folder' do + it 'returns an error' do + expect(bucket).not_to receive(:objects) + + expect(uploader.delete_folder('../tmp')).to eql(error: 'Invalid folder path') + end + end + + describe '#delete_file' do + it 'returns an error' do + expect(bucket).not_to receive(:object) + expect(hook_instance).not_to receive(:hooks_run) + + expect(uploader.delete_file('../tmp/test.png')).to eql(error: 'Invalid file path') + end + end + end + + context 'with an invalid URI-like path' do + describe '#add_file' do + it 'returns an error' do + expect(bucket).not_to receive(:object) + + expect(uploader.add_file('/tmp/test.png', 'file:///tmp/test.png')).to eql(error: 'Invalid file path') + end + end + + describe '#delete_folder' do + it 'returns an error' do + expect(bucket).not_to receive(:objects) + + expect(uploader.delete_folder('s3://bucket/folder')).to eql(error: 'Invalid folder path') + end + end + + describe '#delete_file' do + it 'returns an error' do + expect(bucket).not_to receive(:object) + expect(hook_instance).not_to receive(:hooks_run) + + expect(uploader.delete_file('https://example.com/file.txt')).to eql(error: 'Invalid file path') + end + end + end + + context 'with a valid file path' do + describe '#add_file' do + let(:s3_file) { instance_double(Aws::S3::Object) } + let(:parsed_file) do + { + 'name' => 'test.png', + 'folder_path' => '/safe', + 'url' => 'https://cdn.example.com/safe/test.png', + 'is_folder' => false, + 'file_size' => 123.45, + 'thumb' => '/safe/thumb/test-png.png', + 'file_type' => 'image', + 'created_at' => '2026-03-09T00:00:00Z', + 'dimension' => '100x100', + 'key' => '/safe/test.png' + } + end + + before do + allow(bucket).to receive(:object).and_return(s3_file) + allow(s3_file).to receive(:upload_file).and_return(true) + allow(uploader).to receive(:search_new_key).and_return('/safe/test.png') + allow(uploader).to receive(:file_parse).with(s3_file).and_return(parsed_file) + allow(uploader).to receive(:cache_item).with(parsed_file).and_return(parsed_file) + end + + it 'uploads the file and returns cached metadata' do + file_path = "#{CAMALEON_CMS_ROOT}/spec/support/fixtures/rails.png" + expect(hook_instance).to receive(:hooks_run).with( + 'uploader_aws_before_upload', + hash_including( + file: file_path, key: '/safe/test.png', args: hash_including(same_name: false, is_thumb: false) + ) + ) + + expect(bucket).to receive(:object).with('safe/test.png') + expect(s3_file).to receive(:upload_file).with(file_path, { acl: 'public-read' }) + + result = uploader.add_file(file_path, 'safe/test.png') + + expect(result).to eql(parsed_file) + end + end + end +end