diff --git a/lib/tasks/uploads.rake b/lib/tasks/uploads.rake
index 8410987d8e..6314efff69 100644
--- a/lib/tasks/uploads.rake
+++ b/lib/tasks/uploads.rake
@@ -537,6 +537,10 @@ task "uploads:sync_s3_acls" => :environment do
end
end
+#
+# TODO (martin) Update this rake task to use the _first_ UploadReference
+# record for each upload to determine security, and do not mark things
+# as secure if the first record is something public e.g. a site setting.
task "uploads:disable_secure_uploads" => :environment do
RailsMultisite::ConnectionManagement.each_connection do |db|
unless Discourse.store.external?
@@ -584,6 +588,10 @@ end
# the upload secure flag and S3 upload ACLs. Any uploads that
# have their secure status changed will have all associated posts
# rebaked.
+#
+# TODO (martin) Update this rake task to use the _first_ UploadReference
+# record for each upload to determine security, and do not mark things
+# as secure if the first record is something public e.g. a site setting.
task "uploads:secure_upload_analyse_and_update" => :environment do
RailsMultisite::ConnectionManagement.each_connection do |db|
unless Discourse.store.external?
diff --git a/lib/upload_security.rb b/lib/upload_security.rb
index ccaf86f1ed..c5de6c17bf 100644
--- a/lib/upload_security.rb
+++ b/lib/upload_security.rb
@@ -13,8 +13,12 @@
# original post the upload is linked to has far more bearing on its security context
# post-upload. If the access_control_post_id does not exist then we just rely
# on the current secure? status, otherwise there would be a lot of additional
-# complex queries and joins to perform. Over time more of these specific
-# queries will be implemented.
+# complex queries and joins to perform.
+#
+# These queries will be performed only if the @creating option is false. So if
+# an upload is included in a post, and it's an upload from a different source
+# (e.g. a category logo, site setting upload) then we will determine secure
+# state _based on the first place the upload was referenced_.
#
# NOTE: When updating this to add more cases where uploads will be marked
# secure, consider uploads:secure_upload_analyse_and_update as well, which
@@ -35,6 +39,18 @@ class UploadSecurity
badge_image
]
+ PUBLIC_UPLOAD_REFERENCE_TYPES = %w[
+ Badge
+ Category
+ CustomEmoji
+ Group
+ SiteSetting
+ ThemeField
+ User
+ UserAvatar
+ UserProfile
+ ]
+
def self.register_custom_public_type(type)
@@custom_public_types << type if !@@custom_public_types.include?(type)
end
@@ -65,6 +81,46 @@ class UploadSecurity
[false, "no checks satisfied"]
end
+ private
+
+ def access_control_post
+ @access_control_post ||=
+ @upload.access_control_post_id.present? ? @upload.access_control_post : nil
+ end
+
+ def insecure_context_checks
+ {
+ secure_uploads_disabled: "secure uploads is disabled",
+ insecure_creation_for_modifiers: "one or more creation for_modifiers was satisfied",
+ public_type: "upload is public type",
+ regular_emoji: "upload is used for regular emoji",
+ publicly_referenced_first: "upload was publicly referenced when it was first created",
+ }
+ end
+
+ def secure_context_checks
+ {
+ login_required: "login is required",
+ access_control_post_has_secure_uploads: "access control post dictates security",
+ secure_creation_for_modifiers: "one or more creation for_modifiers was satisfied",
+ uploading_in_composer: "uploading via the composer",
+ already_secure: "upload is already secure",
+ }
+ end
+
+ # The access control check is important because that is the truest indicator
+ # of whether an upload should be secure or not, and thus should be returned
+ # immediately if there is an access control post.
+ def priority_check?(check)
+ check == :access_control_post_has_secure_uploads && access_control_post
+ end
+
+ def perform_check(check)
+ send("#{check}_check")
+ end
+
+ #### START PUBLIC CHECKS ####
+
def secure_uploads_disabled_check
!SiteSetting.secure_uploads?
end
@@ -78,8 +134,11 @@ class UploadSecurity
PUBLIC_TYPES.include?(@upload_type) || @@custom_public_types.include?(@upload_type)
end
- def custom_emoji_check
- @upload.id.present? && CustomEmoji.exists?(upload_id: @upload.id)
+ def publicly_referenced_first_check
+ return false if @creating
+ first_reference = @upload.upload_references.order(created_at: :asc).first
+ return false if first_reference.blank?
+ PUBLIC_UPLOAD_REFERENCE_TYPES.include?(first_reference.target_type)
end
def regular_emoji_check
@@ -89,18 +148,26 @@ class UploadSecurity
uri.path.include?("images/emoji")
end
+ #### END PUBLIC CHECKS ####
+
+ #--------------------------#
+
+ #### START PRIVATE CHECKS ####
+
def login_required_check
SiteSetting.login_required?
end
- # whether the upload should remain secure or not after posting depends on its context,
+ # Whether the upload should remain secure or not after posting depends on its context,
# which is based on the post it is linked to via access_control_post_id.
- # if that post is with_secure_uploads? then the upload should also be secure.
- # this may change to false if the upload was set to secure on upload e.g. in
- # a post composer then it turned out that the post itself was not in a secure context
#
- # a post is with secure uploads if it is a private message or in a read restricted
- # category
+ # If that post is with_secure_uploads? then the upload should also be secure.
+ #
+ # This may change to false if the upload was set to secure on upload e.g. in
+ # a post composer then it turned out that the post itself was not in a secure context.
+ #
+ # A post is with secure uploads if it is a private message or in a read restricted
+ # category. See `Post#with_secure_uploads?` for the full definition.
def access_control_post_has_secure_uploads_check
access_control_post&.with_secure_uploads?
end
@@ -118,41 +185,5 @@ class UploadSecurity
@upload.secure?
end
- private
-
- def access_control_post
- @access_control_post ||=
- @upload.access_control_post_id.present? ? @upload.access_control_post : nil
- end
-
- def insecure_context_checks
- {
- secure_uploads_disabled: "secure uploads is disabled",
- insecure_creation_for_modifiers: "one or more creation for_modifiers was satisfied",
- public_type: "upload is public type",
- custom_emoji: "upload is used for custom emoji",
- regular_emoji: "upload is used for regular emoji",
- }
- end
-
- def secure_context_checks
- {
- login_required: "login is required",
- access_control_post_has_secure_uploads: "access control post dictates security",
- secure_creation_for_modifiers: "one or more creation for_modifiers was satisfied",
- uploading_in_composer: "uploading via the composer",
- already_secure: "upload is already secure",
- }
- end
-
- # the access control check is important because that is the truest indicator
- # of whether an upload should be secure or not, and thus should be returned
- # immediately if there is an access control post
- def priority_check?(check)
- check == :access_control_post_has_secure_uploads && access_control_post
- end
-
- def perform_check(check)
- send("#{check}_check")
- end
+ #### END PRIVATE CHECKS ####
end
diff --git a/spec/integration/secure_uploads_spec.rb b/spec/integration/secure_uploads_spec.rb
new file mode 100644
index 0000000000..b9c3c4d067
--- /dev/null
+++ b/spec/integration/secure_uploads_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+describe "Secure uploads" do
+ fab!(:user) { Fabricate(:user) }
+ fab!(:group) { Fabricate(:group) }
+ fab!(:secure_category) { Fabricate(:private_category, group: group) }
+
+ before do
+ Jobs.run_immediately!
+
+ # this is done so the after_save callbacks for site settings to make
+ # UploadReference records works
+ @original_provider = SiteSetting.provider
+ SiteSetting.provider = SiteSettings::DbProvider.new(SiteSetting)
+ setup_s3
+ stub_s3_store
+ SiteSetting.secure_uploads = true
+ group.add(user)
+ user.reload
+ end
+
+ after { SiteSetting.provider = @original_provider }
+
+ def create_upload
+ filename = "logo.png"
+ file = file_from_fixtures(filename)
+ UploadCreator.new(file, filename).create_for(user.id)
+ end
+
+ def stub_presign_upload_get(upload)
+ # this is necessary because by default any upload inside a secure post is considered "secure"
+ # for the purposes of fetching hotlinked images until proven otherwise, and this is easier
+ # than trying to stub the presigned URL for s3 in a different way
+ stub_request(:get, "https:#{upload.url}").to_return(
+ status: 200,
+ body: file_from_fixtures("logo.png"),
+ )
+ Upload.stubs(:signed_url_from_secure_uploads_url).returns("https:#{upload.url}")
+ end
+
+ it "does not convert an upload to secure when it was first used in a site setting then in a post" do
+ upload = create_upload
+ SiteSetting.favicon = upload
+ expect(upload.reload.upload_references.count).to eq(1)
+ create_post(
+ title: "Secure upload post",
+ raw: "This is a new post
",
+ category: secure_category,
+ user: user,
+ )
+ upload.reload
+ expect(upload.upload_references.count).to eq(2)
+ expect(upload.secure).to eq(false)
+ end
+
+ it "does not convert an upload to insecure when it was first used in a secure post then a site setting" do
+ upload = create_upload
+ create_post(
+ title: "Secure upload post",
+ raw: "This is a new post
",
+ category: secure_category,
+ user: user,
+ )
+ expect(upload.reload.upload_references.count).to eq(1)
+ SiteSetting.favicon = upload
+ upload.reload
+ expect(upload.upload_references.count).to eq(2)
+ expect(upload.secure).to eq(true)
+ end
+
+ it "does not convert an upload to secure when it was first used in a public post then in a secure post" do
+ upload = create_upload
+
+ post =
+ create_post(
+ title: "Public upload post",
+ raw: "This is a new post
",
+ user: user,
+ )
+ upload.reload
+ expect(upload.upload_references.count).to eq(1)
+ expect(upload.secure).to eq(false)
+ expect(upload.access_control_post).to eq(post)
+
+ stub_presign_upload_get(upload)
+ create_post(
+ title: "Secure upload post",
+ raw: "This is a new post
",
+ category: secure_category,
+ user: user,
+ )
+ upload.reload
+ expect(upload.upload_references.count).to eq(2)
+ expect(upload.secure).to eq(false)
+ expect(upload.access_control_post).to eq(post)
+ end
+
+ it "does not convert an upload to insecure when it was first used in a secure post then in a public post" do
+ upload = create_upload
+
+ stub_presign_upload_get(upload)
+ post =
+ create_post(
+ title: "Secure upload post",
+ raw: "This is a new post
",
+ category: secure_category,
+ user: user,
+ )
+ upload.reload
+ expect(upload.upload_references.count).to eq(1)
+ expect(upload.secure).to eq(true)
+ expect(upload.access_control_post).to eq(post)
+
+ create_post(
+ title: "Public upload post",
+ raw: "This is a new post
",
+ user: user,
+ )
+ upload.reload
+ expect(upload.upload_references.count).to eq(2)
+ expect(upload.secure).to eq(true)
+ expect(upload.access_control_post).to eq(post)
+ end
+end
diff --git a/spec/lib/upload_security_spec.rb b/spec/lib/upload_security_spec.rb
index b5a307997f..1a70bfecfa 100644
--- a/spec/lib/upload_security_spec.rb
+++ b/spec/lib/upload_security_spec.rb
@@ -1,13 +1,14 @@
# frozen_string_literal: true
RSpec.describe UploadSecurity do
- let(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) }
- let(:post_in_secure_context) do
+ fab!(:private_category) { Fabricate(:private_category, group: Fabricate(:group)) }
+ fab!(:post_in_secure_context) do
Fabricate(:post, topic: Fabricate(:topic, category: private_category))
end
fab!(:upload) { Fabricate(:upload) }
let(:type) { nil }
- let(:opts) { { type: type, creating: true } }
+ let(:opts) { { type: type, creating: creating } }
+
subject { described_class.new(upload, opts) }
context "when secure uploads is enabled" do
@@ -16,172 +17,293 @@ RSpec.describe UploadSecurity do
SiteSetting.secure_uploads = true
end
- context "when login_required (everything should be secure except public context items)" do
- before { SiteSetting.login_required = true }
- it "returns true" do
- expect(subject.should_be_secure?).to eq(true)
- end
+ context "when creating the upload" do
+ let(:creating) { true }
- context "when uploading in public context" do
- describe "for a public type badge_image" do
- let(:type) { "badge_image" }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for a public type group_flair" do
- let(:type) { "group_flair" }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for a public type avatar" do
- let(:type) { "avatar" }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for a public type custom_emoji" do
- let(:type) { "custom_emoji" }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for a public type profile_background" do
- let(:type) { "profile_background" }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for a public type avatar" do
- let(:type) { "avatar" }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for a public type category_logo" do
- let(:type) { "category_logo" }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for a public type category_background" do
- let(:type) { "category_background" }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for a custom public type" do
- let(:type) { "my_custom_type" }
+ context "when login_required (everything should be secure except public context items)" do
+ before { SiteSetting.login_required = true }
- it "returns true if the custom type has not been added" do
- expect(subject.should_be_secure?).to eq(true)
- end
-
- it "returns false if the custom type has been added" do
- UploadSecurity.register_custom_public_type(type)
- expect(subject.should_be_secure?).to eq(false)
- UploadSecurity.reset_custom_public_types
- end
- end
- describe "for_theme" do
- before { upload.stubs(:for_theme).returns(true) }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for_site_setting" do
- before { upload.stubs(:for_site_setting).returns(true) }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- describe "for_gravatar" do
- before { upload.stubs(:for_gravatar).returns(true) }
- it "returns false" do
- expect(subject.should_be_secure?).to eq(false)
- end
- end
-
- describe "when the upload is used for a custom emoji" do
- it "returns false" do
- CustomEmoji.create(name: "meme", upload: upload)
- expect(subject.should_be_secure?).to eq(false)
- end
- end
-
- describe "when it is based on a regular emoji" do
- it "returns false" do
- falafel =
- Emoji.all.find do |e|
- e.url == "/images/emoji/twitter/falafel.png?v=#{Emoji::EMOJI_VERSION}"
- end
- upload.update!(origin: "http://localhost:3000#{falafel.url}")
- expect(subject.should_be_secure?).to eq(false)
- end
- end
- end
- end
-
- context "when the access control post has_secure_uploads?" do
- before { upload.update(access_control_post_id: post_in_secure_context.id) }
- it "returns true" do
- expect(subject.should_be_secure?).to eq(true)
- end
-
- context "when the post is deleted" do
- before { post_in_secure_context.trash! }
- it "still determines whether the post has secure uploads; returns true" do
- expect(subject.should_be_secure?).to eq(true)
- end
- end
- end
-
- context "when uploading in the composer" do
- let(:type) { "composer" }
- it "returns true" do
- expect(subject.should_be_secure?).to eq(true)
- end
- end
- context "when uploading for a group message" do
- before { upload.stubs(:for_group_message).returns(true) }
- it "returns true" do
- expect(subject.should_be_secure?).to eq(true)
- end
- end
- context "when uploading for a PM" do
- before { upload.stubs(:for_private_message).returns(true) }
- it "returns true" do
- expect(subject.should_be_secure?).to eq(true)
- end
- end
- context "when upload is already secure" do
- before { upload.update(secure: true) }
- it "returns true" do
- expect(subject.should_be_secure?).to eq(true)
- end
- end
-
- context "for attachments" do
- before { upload.update(original_filename: "test.pdf") }
-
- context "when the access control post has_secure_uploads?" do
- before { upload.update(access_control_post: post_in_secure_context) }
it "returns true" do
expect(subject.should_be_secure?).to eq(true)
end
+
+ context "when uploading in public context" do
+ describe "for a public type badge_image" do
+ let(:type) { "badge_image" }
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for a public type group_flair" do
+ let(:type) { "group_flair" }
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for a public type avatar" do
+ let(:type) { "avatar" }
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for a public type custom_emoji" do
+ let(:type) { "custom_emoji" }
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for a public type profile_background" do
+ let(:type) { "profile_background" }
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for a public type avatar" do
+ let(:type) { "avatar" }
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for a public type category_logo" do
+ let(:type) { "category_logo" }
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for a public type category_background" do
+ let(:type) { "category_background" }
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for a custom public type" do
+ let(:type) { "my_custom_type" }
+
+ it "returns true if the custom type has not been added" do
+ expect(subject.should_be_secure?).to eq(true)
+ end
+
+ it "returns false if the custom type has been added" do
+ UploadSecurity.register_custom_public_type(type)
+ expect(subject.should_be_secure?).to eq(false)
+ UploadSecurity.reset_custom_public_types
+ end
+ end
+
+ describe "for_theme" do
+ before { upload.stubs(:for_theme).returns(true) }
+
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for_site_setting" do
+ before { upload.stubs(:for_site_setting).returns(true) }
+
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "for_gravatar" do
+ before { upload.stubs(:for_gravatar).returns(true) }
+
+ it "returns false" do
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when it is based on a regular emoji" do
+ it "returns false" do
+ falafel =
+ Emoji.all.find do |e|
+ e.url == "/images/emoji/twitter/falafel.png?v=#{Emoji::EMOJI_VERSION}"
+ end
+ upload.update!(origin: "http://localhost:3000#{falafel.url}")
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+ end
+ end
+
+ context "when the access control post has_secure_uploads?" do
+ before { upload.update(access_control_post_id: post_in_secure_context.id) }
+
+ it "returns true" do
+ expect(subject.should_be_secure?).to eq(true)
+ end
+
+ context "when the post is deleted" do
+ before { post_in_secure_context.trash! }
+
+ it "still determines whether the post has secure uploads; returns true" do
+ expect(subject.should_be_secure?).to eq(true)
+ end
+ end
+ end
+
+ context "when uploading in the composer" do
+ let(:type) { "composer" }
+ it "returns true" do
+ expect(subject.should_be_secure?).to eq(true)
+ end
+ end
+
+ context "when uploading for a group message" do
+ before { upload.stubs(:for_group_message).returns(true) }
+ it "returns true" do
+ expect(subject.should_be_secure?).to eq(true)
+ end
+ end
+
+ context "when uploading for a PM" do
+ before { upload.stubs(:for_private_message).returns(true) }
+ it "returns true" do
+ expect(subject.should_be_secure?).to eq(true)
+ end
+ end
+
+ context "when upload is already secure" do
+ before { upload.update(secure: true) }
+ it "returns true" do
+ expect(subject.should_be_secure?).to eq(true)
+ end
+ end
+
+ context "for attachments" do
+ before { upload.update(original_filename: "test.pdf") }
+
+ context "when the access control post has_secure_uploads?" do
+ before { upload.update(access_control_post: post_in_secure_context) }
+ it "returns true" do
+ expect(subject.should_be_secure?).to eq(true)
+ end
+ end
+ end
+ end
+
+ context "when checking an existing upload" do
+ let(:creating) { false }
+
+ before do
+ # this is done so the after_save callbacks for site settings to make
+ # UploadReference records works
+ @original_provider = SiteSetting.provider
+ SiteSetting.provider = SiteSettings::DbProvider.new(SiteSetting)
+ setup_s3
+ SiteSetting.secure_uploads = true
+ end
+
+ after { SiteSetting.provider = @original_provider }
+
+ def create_secure_post_reference
+ UploadReference.ensure_exist!(upload_ids: [upload.id], target: post_in_secure_context)
+ upload.update!(access_control_post: post_in_secure_context)
+ end
+
+ describe "when the upload is first used for a post in a secure context" do
+ it "returns true" do
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(true)
+ end
+ end
+
+ describe "when the upload is first used for a site setting" do
+ it "returns false" do
+ SiteSetting.favicon = upload
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when the upload is first used for a theme field" do
+ it "returns false" do
+ Fabricate(:theme_field, type_id: ThemeField.types[:theme_upload_var], upload: upload)
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when the upload is first used for a group flair image" do
+ it "returns false" do
+ Fabricate(:group, flair_upload: upload)
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when the upload is first used for a custom emoji" do
+ it "returns false" do
+ CustomEmoji.create(name: "meme", upload: upload)
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when the upload is first used for a badge" do
+ it "returns false" do
+ Fabricate(:badge, image_upload: upload)
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when the upload is first used for a category image (logo, dark logo, background)" do
+ it "returns false" do
+ Fabricate(:category, uploaded_logo: upload)
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when the upload is first used for a user profile (profile background, card background)" do
+ it "returns false" do
+ user = Fabricate(:user)
+ user.user_profile.update!(card_background_upload: upload)
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when the upload is first used for a user uploaded avatar" do
+ it "returns false" do
+ Fabricate(:user, uploaded_avatar: upload)
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
+ end
+
+ describe "when the upload is first used for a UserAvatar" do
+ it "returns false" do
+ Fabricate(:user_avatar, custom_upload: upload)
+ create_secure_post_reference
+ expect(subject.should_be_secure?).to eq(false)
+ end
end
end
end
context "when secure uploads is disabled" do
+ let(:creating) { true }
+
before { SiteSetting.secure_uploads = false }
+
it "returns false" do
expect(subject.should_be_secure?).to eq(false)
end
context "for attachments" do
before { upload.update(original_filename: "test.pdf") }
+
it "returns false" do
expect(subject.should_be_secure?).to eq(false)
end