discourse/spec/multisite/s3_store_spec.rb
Alan Guo Xiang Tan 95ac3a984d
FIX: Enforce content disposition on S3 presigned URLs (#38850)
Presigned S3 URLs only set `Content-Disposition` when `force_download`
was true. Secure uploads and signed paths for dangerous file types
(HTML, SVG, XML) could be served inline.

This commit makes `S3Store#signed_url_for_path` always set
`Content-Disposition` on browser-facing presigned URLs, using
`FileHelper.is_inline_safe?` to choose between `inline` and `attachment`
disposition.

A required `include_content_disposition:` kwarg is added to
`S3Store#signed_url_for_path` so every caller explicitly opts in or out.
Server-side downloads pass `false` since they don't need the header and
the extra query parameter can push presigned URLs over the 2000
character `UrlHelper.normalized_encode` limit.

The S3 multipart upload system spec is also re-enabled as the
intermittent failures are no longer reproducible.
2026-03-27 10:04:00 +08:00

482 lines
18 KiB
Ruby

# frozen_string_literal: true
require "file_store/s3_store"
RSpec.describe "Multisite s3 uploads", type: :multisite do
let(:original_filename) { "smallest.png" }
let(:uploaded_file) { file_from_fixtures(original_filename) }
let(:upload_sha1) { Digest::SHA1.hexdigest(File.read(uploaded_file)) }
let(:upload_path) { Discourse.store.upload_path }
def build_upload(secure: false)
Fabricate.build(
:upload,
sha1: upload_sha1,
original_filename: original_filename,
id: Upload.maximum(:id) + 1,
secure: secure,
)
end
describe "uploading to s3" do
before(:each) { setup_s3 }
describe "#store_upload" do
let(:s3_client) { Aws::S3::Client.new(stub_responses: true) }
let(:s3_helper) { S3Helper.new(SiteSetting.s3_upload_bucket, "", client: s3_client) }
let(:store) { FileStore::S3Store.new(s3_helper) }
let(:upload_opts) do
{
acl: FileStore::S3Store::CANNED_ACL_PUBLIC_READ,
cache_control: "max-age=31556952, public, immutable",
content_type: "image/png",
}
end
context "when the file is a SVG" do
let(:original_filename) { "small.svg" }
let(:uploaded_file) { file_from_fixtures("small.svg", "svg") }
it "adds an attachment content-disposition with the original filename" do
disp_opts = {
content_disposition:
"attachment; filename=\"#{original_filename}\"; filename*=UTF-8''#{original_filename}",
content_type: "image/svg+xml",
}
s3_helper
.expects(:upload)
.with(uploaded_file, kind_of(String), upload_opts.merge(disp_opts))
.returns(%w[path etag])
upload = build_upload
store.store_upload(uploaded_file, upload)
end
end
context "when the file is a video" do
let(:original_filename) { "small.mp4" }
let(:uploaded_file) { file_from_fixtures("small.mp4", "media") }
it "adds inline content-disposition header with original filename" do
disp_opts = {
content_disposition:
"inline; filename=\"#{original_filename}\"; filename*=UTF-8''#{original_filename}",
content_type: "application/mp4",
}
s3_helper
.expects(:upload)
.with(uploaded_file, kind_of(String), upload_opts.merge(disp_opts))
.returns(%w[path etag])
upload = build_upload
store.store_upload(uploaded_file, upload)
end
end
context "when the file is audio" do
let(:original_filename) { "small.mp3" }
let(:uploaded_file) { file_from_fixtures("small.mp3", "media") }
it "adds inline content-disposition header with filename" do
disp_opts = {
content_disposition:
"inline; filename=\"#{original_filename}\"; filename*=UTF-8''#{original_filename}",
content_type: "audio/mpeg",
}
s3_helper
.expects(:upload)
.with(uploaded_file, kind_of(String), upload_opts.merge(disp_opts))
.returns(%w[path etag])
upload = build_upload
store.store_upload(uploaded_file, upload)
end
end
it "returns the correct url for default and second multisite db" do
test_multisite_connection("default") do
upload = build_upload
expect(store.store_upload(uploaded_file, upload)).to eq(
"//#{SiteSetting.s3_upload_bucket}.s3.dualstack.us-west-1.amazonaws.com/#{upload_path}/original/1X/c530c06cf89c410c0355d7852644a73fc3ec8c04.png",
)
expect(upload.etag).to eq("ETag")
end
test_multisite_connection("second") do
upload_path = Discourse.store.upload_path
upload = build_upload
expect(store.store_upload(uploaded_file, upload)).to eq(
"//#{SiteSetting.s3_upload_bucket}.s3.dualstack.us-west-1.amazonaws.com/#{upload_path}/original/1X/c530c06cf89c410c0355d7852644a73fc3ec8c04.png",
)
expect(upload.etag).to eq("ETag")
end
end
end
end
describe "removal from s3" do
before { setup_s3 }
describe "#remove_upload" do
let(:store) { FileStore::S3Store.new }
let(:upload) { build_upload }
let(:upload_key) { "#{upload_path}/original/1X/#{upload.sha1}.png" }
def prepare_fake_s3
@fake_s3 = FakeS3.create
bucket = @fake_s3.bucket(SiteSetting.s3_upload_bucket)
bucket.put_object(key: upload_key, size: upload.filesize, last_modified: upload.created_at)
bucket
end
it "removes the file from s3 on multisite" do
test_multisite_connection("default") do
upload.update!(
url:
"//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/#{upload_path}/original/1X/#{upload.sha1}.png",
)
tombstone_key = "uploads/tombstone/default/original/1X/#{upload.sha1}.png"
bucket = prepare_fake_s3
expect(bucket.find_object(upload_key)).to be_present
expect(bucket.find_object(tombstone_key)).to be_nil
store.remove_upload(upload)
expect(bucket.find_object(upload_key)).to be_nil
expect(bucket.find_object(tombstone_key)).to be_present
end
end
it "removes the file from s3 on another multisite db" do
test_multisite_connection("second") do
upload.update!(
url:
"//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/#{upload_path}/original/1X/#{upload.sha1}.png",
)
tombstone_key = "uploads/tombstone/second/original/1X/#{upload.sha1}.png"
bucket = prepare_fake_s3
expect(bucket.find_object(upload_key)).to be_present
expect(bucket.find_object(tombstone_key)).to be_nil
store.remove_upload(upload)
expect(bucket.find_object(upload_key)).to be_nil
expect(bucket.find_object(tombstone_key)).to be_present
end
end
describe "when s3_upload_bucket includes folders path" do
let(:upload_key) { "discourse-uploads/#{upload_path}/original/1X/#{upload.sha1}.png" }
before { SiteSetting.s3_upload_bucket = "s3-upload-bucket/discourse-uploads" }
it "removes the file from s3 on multisite" do
test_multisite_connection("default") do
upload.update!(
url:
"//s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/discourse-uploads/#{upload_path}/original/1X/#{upload.sha1}.png",
)
tombstone_key =
"discourse-uploads/uploads/tombstone/default/original/1X/#{upload.sha1}.png"
bucket = prepare_fake_s3
expect(bucket.find_object(upload_key)).to be_present
expect(bucket.find_object(tombstone_key)).to be_nil
store.remove_upload(upload)
expect(bucket.find_object(upload_key)).to be_nil
expect(bucket.find_object(tombstone_key)).to be_present
end
end
end
end
end
describe "secure uploads" do
let(:store) { FileStore::S3Store.new }
let(:client) { Aws::S3::Client.new(stub_responses: true) }
let(:resource) { Aws::S3::Resource.new(client: client) }
let(:s3_bucket) { resource.bucket("some-really-cool-bucket") }
let(:s3_helper) { store.s3_helper }
let(:s3_object) { stub }
before(:each) do
setup_s3
SiteSetting.s3_upload_bucket = "some-really-cool-bucket"
SiteSetting.authorized_extensions = "pdf|png|jpg|gif"
end
before { s3_object.stubs(:put).returns(Aws::S3::Types::PutObjectOutput.new(etag: "etag")) }
describe "when secure attachments are enabled" do
it "returns signed URL with correct path" do
test_multisite_connection("default") do
upload =
Fabricate(:upload, original_filename: "small.pdf", extension: "pdf", secure: true)
path = Discourse.store.get_path_for_upload(upload)
s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once
s3_bucket.expects(:object).with("#{upload_path}/#{path}").returns(s3_object).at_least_once
s3_object.expects(:presigned_url).with(
:get,
{
expires_in: SiteSetting.s3_presigned_get_url_expires_after_seconds,
response_content_disposition:
ActionDispatch::Http::ContentDisposition.format(
disposition: "inline",
filename: upload.original_filename,
),
},
)
upload.url = store.store_upload(uploaded_file, upload)
expect(upload.url).to eq(
"//some-really-cool-bucket.s3.dualstack.us-west-1.amazonaws.com/#{upload_path}/#{path}",
)
expect(store.url_for(upload)).not_to eq(upload.url)
end
end
end
describe "when secure uploads are enabled" do
before do
SiteSetting.login_required = true
SiteSetting.secure_uploads = true
s3_helper.stubs(:s3_client).returns(client)
Discourse.stubs(:store).returns(store)
end
it "returns signed URL with correct path" do
test_multisite_connection("default") do
upload = Fabricate.build(:upload_s3, sha1: upload_sha1, id: 1)
signed_url =
Discourse.store.signed_url_for_path(upload.url, include_content_disposition: true)
expect(signed_url).to match(/Amz-Expires/)
expect(signed_url).to match("#{upload_path}")
end
test_multisite_connection("second") do
upload_path = Discourse.store.upload_path
upload = Fabricate.build(:upload_s3, sha1: upload_sha1, id: 1)
signed_url =
Discourse.store.signed_url_for_path(upload.url, include_content_disposition: true)
expect(signed_url).to match(/Amz-Expires/)
expect(signed_url).to match("#{upload_path}")
end
end
end
describe "#update_upload_access_control" do
it "updates correct file for default and second multisite db" do
test_multisite_connection("default") do
upload = build_upload(secure: true)
upload.update!(original_filename: "small.pdf", extension: "pdf")
s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once
expect_upload_acl_update(upload, upload_path)
expect(store.update_upload_access_control(upload)).to be_truthy
end
test_multisite_connection("second") do
upload_path = Discourse.store.upload_path
upload = build_upload(secure: true)
upload.update!(original_filename: "small.pdf", extension: "pdf")
s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once
expect_upload_acl_update(upload, upload_path)
expect(store.update_upload_access_control(upload)).to be_truthy
end
end
describe "optimized images" do
it "updates correct file for default and second multisite DB" do
test_multisite_connection("default") do
upload = build_upload(secure: true)
upload_path = Discourse.store.upload_path
optimized_image = Fabricate(:optimized_image, upload: upload)
s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once
expect_upload_acl_update(upload, upload_path)
expect_optimized_image_acl_update(optimized_image, upload_path)
expect(store.update_upload_access_control(upload)).to be_truthy
end
test_multisite_connection("second") do
upload = build_upload(secure: true)
upload_path = Discourse.store.upload_path
optimized_image = Fabricate(:optimized_image, upload: upload)
s3_helper.expects(:s3_bucket).returns(s3_bucket).at_least_once
expect_upload_acl_update(upload, upload_path)
expect_optimized_image_acl_update(optimized_image, upload_path)
expect(store.update_upload_access_control(upload)).to be_truthy
end
end
end
def expect_upload_acl_update(upload, upload_path)
s3_bucket
.expects(:object)
.with("#{upload_path}/original/1X/#{upload.sha1}.#{upload.extension}")
.returns(s3_object)
s3_object.expects(:acl).returns(s3_object)
s3_object.expects(:put).with(acl: FileStore::S3Store::CANNED_ACL_PRIVATE).returns(s3_object)
end
def expect_optimized_image_acl_update(optimized_image, upload_path)
path = Discourse.store.get_path_for_optimized_image(optimized_image)
s3_bucket.expects(:object).with("#{upload_path}/#{path}").returns(s3_object)
s3_object.expects(:acl).returns(s3_object)
s3_object.expects(:put).with(acl: FileStore::S3Store::CANNED_ACL_PRIVATE).returns(s3_object)
end
end
end
describe "#has_been_uploaded?" do
before do
setup_s3
SiteSetting.s3_upload_bucket = "s3-upload-bucket/test"
end
let(:store) { FileStore::S3Store.new }
it "returns false for blank urls and bad urls" do
expect(store.has_been_uploaded?("")).to eq(false)
expect(store.has_been_uploaded?("http://test@test.com:test/test.git")).to eq(false)
expect(store.has_been_uploaded?("http:///+test@test.com/test.git")).to eq(false)
end
it "returns true if the base hostname is the same for both urls" do
url =
"https://s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/test/original/2X/d/dd7964f5fd13e1103c5244ca30abe1936c0a4b88.png"
expect(store.has_been_uploaded?(url)).to eq(true)
end
it "returns false if the base hostname is the same for both urls BUT the bucket name is different in the path" do
bucket = "someotherbucket"
url =
"https://s3-upload-bucket.s3.dualstack.us-west-1.amazonaws.com/#{bucket}/original/2X/d/dd7964f5fd13e1103c5244ca30abe1936c0a4b88.png"
expect(store.has_been_uploaded?(url)).to eq(false)
end
it "returns false if the hostnames do not match and the s3_cdn_url is blank" do
url =
"https://www.someotherhostname.com/test/original/2X/d/dd7964f5fd13e1103c5244ca30abe1936c0a4b88.png"
expect(store.has_been_uploaded?(url)).to eq(false)
end
it "returns true if the s3_cdn_url is present and matches the url hostname" do
SiteSetting.s3_cdn_url = "https://www.someotherhostname.com"
url =
"https://www.someotherhostname.com/test/original/2X/d/dd7964f5fd13e1103c5244ca30abe1936c0a4b88.png"
expect(store.has_been_uploaded?(url)).to eq(true)
end
it "returns false if the URI is an invalid mailto link" do
link = "mailto: roman;@test.com"
expect(store.has_been_uploaded?(link)).to eq(false)
end
end
describe "#signed_request_for_temporary_upload" do
before { setup_s3 }
let(:store) { FileStore::S3Store.new }
context "for a bucket with no folder path" do
before { SiteSetting.s3_upload_bucket = "s3-upload-bucket" }
it "returns a presigned url and headers with the correct ACL param and the key for the temporary file" do
url, signed_headers = store.signed_request_for_temporary_upload("test.png")
key = store.s3_helper.path_from_url(url)
expect(signed_headers).to eq("x-amz-acl" => FileStore::S3Store::CANNED_ACL_PRIVATE)
expect(url).to match(/Amz-Expires/)
expect(key).to match(
/temp\/uploads\/default\/test_[0-9]\/[a-zA-z0-9]{0,32}\/[a-zA-z0-9]{0,32}.png/,
)
end
it "returns a presigned url and headers with the correct ACL param and the key for the temporary file when `s3_use_acls` is disabled" do
SiteSetting.s3_use_acls = false
url, signed_headers = store.signed_request_for_temporary_upload("test.png")
key = store.s3_helper.path_from_url(url)
expect(signed_headers).to eq({})
expect(url).to match(/Amz-Expires/)
expect(key).to match(
/temp\/uploads\/default\/test_[0-9]\/[a-zA-z0-9]{0,32}\/[a-zA-z0-9]{0,32}.png/,
)
end
it "returns a presigned url and headers with the correct tagging params when ``s3_enable_access_control_tags` is enabled" do
SiteSetting.s3_enable_access_control_tags = true
url, signed_headers = store.signed_request_for_temporary_upload("test.png")
key = store.s3_helper.path_from_url(url)
expect(signed_headers).to eq(
"x-amz-acl" => FileStore::S3Store::CANNED_ACL_PRIVATE,
"x-amz-tagging" => FileStore::S3Store.visibility_tagging_option_value(secure: true),
)
expect(url).to match(/Amz-Expires/)
expect(key).to match(
/temp\/uploads\/default\/test_[0-9]\/[a-zA-z0-9]{0,32}\/[a-zA-z0-9]{0,32}.png/,
)
end
it "presigned url headers contains the metadata when provided" do
url, signed_headers =
store.signed_request_for_temporary_upload(
"test.png",
metadata: {
"test-meta": "testing",
},
)
expect(signed_headers).to eq(
"x-amz-acl" => FileStore::S3Store::CANNED_ACL_PRIVATE,
"x-amz-meta-test-meta" => "testing",
)
expect(url).not_to include("&x-amz-meta-test-meta=testing")
end
end
context "for a bucket with a folder path" do
before { SiteSetting.s3_upload_bucket = "s3-upload-bucket/site" }
it "returns a presigned url with the correct params and the key for the temporary file" do
url, _signed_headers = store.signed_request_for_temporary_upload("test.png")
key = store.s3_helper.path_from_url(url)
expect(url).to match(/Amz-Expires/)
expect(key).to match(
/temp\/site\/uploads\/default\/test_[0-9]\/[a-zA-z0-9]{0,32}\/[a-zA-z0-9]{0,32}.png/,
)
end
end
context "for a multisite site" do
before { SiteSetting.s3_upload_bucket = "s3-upload-bucket/standard99" }
it "returns a presigned url with the correct params and the key for the temporary file" do
test_multisite_connection("second") do
url, _signed_headers = store.signed_request_for_temporary_upload("test.png")
key = store.s3_helper.path_from_url(url)
expect(url).to match(/Amz-Expires/)
expect(key).to match(
/temp\/standard99\/uploads\/second\/test_[0-9]\/[a-zA-z0-9]{0,32}\/[a-zA-z0-9]{0,32}.png/,
)
end
end
end
end
end