FEATURE: Secure uploads in PMs only (#23398)

This adds a new secure_uploads_pm_only site setting. When secure_uploads
is true with this setting, only uploads created in PMs will be marked
secure; no uploads in secure categories will be marked as secure, and
the login_required site setting has no bearing on upload security
either.

This is meant to be a stopgap solution to prevent secure uploads
in a single place (private messages) for sensitive admin data exports.
Ideally we would want a more comprehensive way of saying that certain
upload types get secured which is a hybrid/mixed mode secure uploads,
but for now this will do the trick.
This commit is contained in:
Martin Brennan 2023-09-06 09:39:09 +10:00 committed by GitHub
parent de9b567c19
commit c532f6eb3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 283 additions and 43 deletions

View File

@ -567,8 +567,15 @@ class Post < ActiveRecord::Base
def with_secure_uploads? def with_secure_uploads?
return false if !SiteSetting.secure_uploads? return false if !SiteSetting.secure_uploads?
SiteSetting.login_required? ||
(topic.present? && (topic.private_message? || topic.category&.read_restricted)) # NOTE: This is meant to be a stopgap solution to prevent secure uploads
# in a single place (private messages) for sensitive admin data exports.
# Ideally we would want a more comprehensive way of saying that certain
# upload types get secured which is a hybrid/mixed mode secure uploads,
# but for now this will do the trick.
return topic&.private_message? if SiteSetting.secure_uploads_pm_only?
SiteSetting.login_required? || topic&.private_message? || topic&.category&.read_restricted?
end end
def hide!(post_action_type_id, reason = nil, custom_message: nil) def hide!(post_action_type_id, reason = nil, custom_message: nil)

View File

@ -1439,6 +1439,10 @@ files:
default: 1024 default: 1024
min: 1 min: 1
max: 10240 max: 10240
secure_uploads_pm_only:
default: false
hidden: true
client: true
enable_s3_uploads: enable_s3_uploads:
default: false default: false
client: true client: true

View File

@ -609,11 +609,15 @@ task "uploads:secure_upload_analyse_and_update" => :environment do
update_uploads_access_control_post if SiteSetting.secure_uploads? update_uploads_access_control_post if SiteSetting.secure_uploads?
puts "", "Analysing which uploads need to be marked secure and be rebaked.", "" puts "", "Analysing which uploads need to be marked secure and be rebaked.", ""
if SiteSetting.login_required? if SiteSetting.login_required? && !SiteSetting.secure_uploads_pm_only?
# Simply mark all uploads linked to posts secure if login_required because no anons will be able to access them. # Simply mark all uploads linked to posts secure if login_required because
# no anons will be able to access them; however if secure_uploads_pm_only is
# true then login_required will not mark other uploads secure.
post_ids_to_rebake, all_upload_ids_changed = mark_all_as_secure_login_required post_ids_to_rebake, all_upload_ids_changed = mark_all_as_secure_login_required
else else
# Otherwise only mark uploads linked to posts in secure categories or PMs as secure. # Otherwise only mark uploads linked to posts either:
# * In secure categories or PMs if !SiteSetting.secure_uploads_pm_only
# * In PMs if SiteSetting.secure_uploads_pm_only
post_ids_to_rebake, all_upload_ids_changed = post_ids_to_rebake, all_upload_ids_changed =
update_specific_upload_security_no_login_required update_specific_upload_security_no_login_required
end end
@ -693,15 +697,24 @@ end
def update_specific_upload_security_no_login_required def update_specific_upload_security_no_login_required
# A simplification of the rules found in UploadSecurity which is a lot faster than # A simplification of the rules found in UploadSecurity which is a lot faster than
# having to loop through records and use that class to check security. # having to loop through records and use that class to check security.
filter_clause =
if SiteSetting.secure_uploads_pm_only?
"WHERE topics.archetype = 'private_message'"
else
<<~SQL
LEFT JOIN categories ON categories.id = topics.category_id
WHERE (topics.category_id IS NOT NULL AND categories.read_restricted) OR
(topics.archetype = 'private_message')
SQL
end
post_upload_ids_marked_secure = DB.query_single(<<~SQL) post_upload_ids_marked_secure = DB.query_single(<<~SQL)
WITH upl AS ( WITH upl AS (
SELECT DISTINCT ON (upload_id) upload_id SELECT DISTINCT ON (upload_id) upload_id
FROM upload_references FROM upload_references
INNER JOIN posts ON posts.id = upload_references.target_id AND upload_references.target_type = 'Post' INNER JOIN posts ON posts.id = upload_references.target_id AND upload_references.target_type = 'Post'
INNER JOIN topics ON topics.id = posts.topic_id INNER JOIN topics ON topics.id = posts.topic_id
LEFT JOIN categories ON categories.id = topics.category_id #{filter_clause}
WHERE (topics.category_id IS NOT NULL AND categories.read_restricted) OR
(topics.archetype = 'private_message')
) )
UPDATE uploads UPDATE uploads
SET secure = true, SET secure = true,

View File

@ -29,8 +29,9 @@ class TopicUploadSecurityManager
secure_status_did_change = secure_status_did_change =
post.owned_uploads_via_access_control.any? do |upload| post.owned_uploads_via_access_control.any? do |upload|
# we have already got the post preloaded so we may as well # We already have the post preloaded so we may as well
# attach it here to avoid another load in UploadSecurity # attach it here to avoid another load in UploadSecurity
# (which is called via update_secure_status)
upload.access_control_post = post upload.access_control_post = post
upload.update_secure_status(source: "topic upload security") upload.update_secure_status(source: "topic upload security")
end end
@ -43,14 +44,14 @@ class TopicUploadSecurityManager
return if !SiteSetting.secure_uploads return if !SiteSetting.secure_uploads
# we only want to do this if secure uploads is enabled. if # We only want to do this if secure uploads is enabled. If
# the setting is turned on after a site has been running # the setting is turned on after a site has been running
# already, we want to make sure that any post moves after # already, we want to make sure that any post moves after
# this are handled and upload secure statuses and ACLs # this are handled and upload secure statuses and ACLs
# are updated appropriately, as well as setting the access control # are updated appropriately, as well as setting the access control
# post for secure uploads missing it. # post for secure uploads missing it.
# #
# examples (all after secure uploads is enabled): # Examples (all after secure uploads is enabled):
# #
# -> a public topic is moved to a private category after # -> a public topic is moved to a private category after
# -> a PM is converted to a public topic # -> a PM is converted to a public topic

View File

@ -163,7 +163,7 @@ class UploadSecurity
#### START PRIVATE CHECKS #### #### START PRIVATE CHECKS ####
def login_required_check def login_required_check
SiteSetting.login_required? SiteSetting.login_required? && !SiteSetting.secure_uploads_pm_only?
end 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,

View File

@ -28,6 +28,14 @@ RSpec.describe UploadSecurity do
expect(security.should_be_secure?).to eq(true) expect(security.should_be_secure?).to eq(true)
end end
context "if secure_uploads_pm_only" do
before { SiteSetting.secure_uploads_pm_only = true }
it "returns false" do
expect(security.should_be_secure?).to eq(false)
end
end
context "when uploading in public context" do context "when uploading in public context" do
describe "for a public type badge_image" do describe "for a public type badge_image" do
let(:type) { "badge_image" } let(:type) { "badge_image" }

View File

@ -150,6 +150,7 @@ RSpec.describe Post do
describe "with_secure_uploads?" do describe "with_secure_uploads?" do
let(:topic) { Fabricate(:topic) } let(:topic) { Fabricate(:topic) }
let!(:post) { Fabricate(:post, topic: topic) } let!(:post) { Fabricate(:post, topic: topic) }
it "returns false if secure uploads is not enabled" do it "returns false if secure uploads is not enabled" do
expect(post.with_secure_uploads?).to eq(false) expect(post.with_secure_uploads?).to eq(false)
end end
@ -167,6 +168,14 @@ RSpec.describe Post do
it "returns true" do it "returns true" do
expect(post.with_secure_uploads?).to eq(true) expect(post.with_secure_uploads?).to eq(true)
end end
context "if secure_uploads_pm_only" do
before { SiteSetting.secure_uploads_pm_only = true }
it "returns false" do
expect(post.with_secure_uploads?).to eq(false)
end
end
end end
context "if the topic category is read_restricted" do context "if the topic category is read_restricted" do
@ -176,6 +185,14 @@ RSpec.describe Post do
it "returns true" do it "returns true" do
expect(post.with_secure_uploads?).to eq(true) expect(post.with_secure_uploads?).to eq(true)
end end
context "if secure_uploads_pm_only" do
before { SiteSetting.secure_uploads_pm_only = true }
it "returns false" do
expect(post.with_secure_uploads?).to eq(false)
end
end
end end
context "if the post is in a PM topic" do context "if the post is in a PM topic" do
@ -184,6 +201,14 @@ RSpec.describe Post do
it "returns true" do it "returns true" do
expect(post.with_secure_uploads?).to eq(true) expect(post.with_secure_uploads?).to eq(true)
end end
context "if secure_uploads_pm_only" do
before { SiteSetting.secure_uploads_pm_only = true }
it "returns true" do
expect(post.with_secure_uploads?).to eq(true)
end
end
end end
end end
end end

View File

@ -491,16 +491,38 @@ RSpec.describe Upload do
SiteSetting.login_required = true SiteSetting.login_required = true
SiteSetting.authorized_extensions = "" SiteSetting.authorized_extensions = ""
expect do expect do
upl = Fabricate(
Fabricate( :upload,
:upload, access_control_post: Fabricate(:private_message_post),
access_control_post: Fabricate(:private_message_post), security_last_changed_at: Time.zone.now,
security_last_changed_at: Time.zone.now, security_last_changed_reason: "test",
security_last_changed_reason: "test", secure: true,
secure: true, )
)
end.to raise_error(ActiveRecord::RecordInvalid) end.to raise_error(ActiveRecord::RecordInvalid)
end end
context "when secure_uploads_pm_only is true" do
before { SiteSetting.secure_uploads_pm_only = true }
it "does not mark an image upload as secure if login_required is enabled" do
SiteSetting.login_required = true
upload.update!(secure: false)
expect { upload.update_secure_status }.not_to change { upload.secure }
expect(upload.reload.secure).to eq(false)
end
it "marks the upload as not secure if its access control post is a public post" do
upload.update!(secure: true, access_control_post: Fabricate(:post))
upload.update_secure_status
expect(upload.secure).to eq(false)
end
it "leaves the upload as secure if its access control post is a PM post" do
upload.update!(secure: true, access_control_post: Fabricate(:private_message_post))
upload.update_secure_status
expect(upload.secure).to eq(true)
end
end
end end
end end

View File

@ -141,7 +141,7 @@ module SystemHelpers
page.execute_script(js, selector, start, offset) page.execute_script(js, selector, start, offset)
end end
def setup_s3_system_test def setup_s3_system_test(enable_secure_uploads: false, enable_direct_s3_uploads: true)
SiteSetting.enable_s3_uploads = true SiteSetting.enable_s3_uploads = true
SiteSetting.s3_upload_bucket = "discoursetest" SiteSetting.s3_upload_bucket = "discoursetest"
@ -151,6 +151,9 @@ module SystemHelpers
SiteSetting.s3_secret_access_key = MinioRunner.config.minio_root_password SiteSetting.s3_secret_access_key = MinioRunner.config.minio_root_password
SiteSetting.s3_endpoint = MinioRunner.config.minio_server_url SiteSetting.s3_endpoint = MinioRunner.config.minio_server_url
SiteSetting.enable_direct_s3_uploads = enable_direct_s3_uploads
SiteSetting.secure_uploads = enable_secure_uploads
MinioRunner.start MinioRunner.start
end end

View File

@ -82,8 +82,8 @@ module PageObjects
end end
def switch_category(category_name) def switch_category(category_name)
find(".category-chooser").click category_chooser.expand
find(".category-row[data-name='#{category_name}']").click category_chooser.select_row_by_name(category_name)
end end
def preview def preview
@ -216,6 +216,14 @@ module PageObjects
find("#{COMPOSER_ID}").has_css?("#file-uploading") find("#{COMPOSER_ID}").has_css?("#file-uploading")
end end
def select_pm_user(username)
select_kit = PageObjects::Components::SelectKit.new("#private-message-users")
select_kit.expand
select_kit.search(username)
select_kit.select_row_by_value(username)
select_kit.collapse
end
private private
def emoji_preview_selector(emoji) def emoji_preview_selector(emoji)

View File

@ -16,8 +16,7 @@ module PageObjects
end end
def open_new_topic def open_new_topic
page.visit "/" page.visit "/new-topic"
find("button#create-topic").click
self self
end end
@ -32,6 +31,14 @@ module PageObjects
self self
end end
def current_topic_id
find("h1[data-topic-id]")["data-topic-id"]
end
def current_topic
::Topic.find(current_topic_id)
end
def has_topic_title?(text) def has_topic_title?(text)
has_css?("h1 .fancy-title", text: text) has_css?("h1 .fancy-title", text: text)
end end
@ -44,9 +51,9 @@ module PageObjects
has_css?("#post_#{number}") has_css?("#post_#{number}")
end end
def post_by_number(post_or_number) def post_by_number(post_or_number, wait: Capybara.default_max_wait_time)
post_or_number = post_or_number.is_a?(Post) ? post_or_number.post_number : post_or_number post_or_number = post_or_number.is_a?(Post) ? post_or_number.post_number : post_or_number
find(".topic-post:not(.staged) #post_#{post_or_number}") find(".topic-post:not(.staged) #post_#{post_or_number}", wait: wait)
end end
def post_by_number_selector(post_number) def post_by_number_selector(post_number)

View File

@ -0,0 +1,118 @@
# frozen_string_literal: true
describe "Uploading files in the composer to S3", type: :system do
fab!(:current_user) { Fabricate(:admin) }
fab!(:other_user) { Fabricate(:user, username: "otherguy") }
let(:modal) { PageObjects::Modals::Base.new }
let(:composer) { PageObjects::Components::Composer.new }
let(:topic_page) { PageObjects::Pages::Topic.new }
describe "secure uploads" do
def first_post_img(wait: Capybara.default_max_wait_time)
first_post = topic_page.post_by_number(1, wait: wait)
expect(first_post).to have_css("img[data-base62-sha1]")
first_post.find(".cooked").first("img")
end
def expect_first_post_to_have_secure_upload
img = first_post_img
expect(img["src"]).to include("/secure-uploads")
topic = topic_page.current_topic
expect(topic.first_post.uploads.first.secure).to eq(true)
end
it "marks uploads inside of private message posts as secure" do
skip_unless_s3_system_specs_enabled!
setup_s3_system_test(enable_secure_uploads: true)
sign_in(current_user)
topic_page.open_new_message
composer.fill_title("This is a test PM for secure uploads")
composer.select_pm_user("otherguy")
file_path = file_from_fixtures("logo.png", "images").path
attach_file(file_path) { composer.click_toolbar_button("upload") }
expect(page).to have_no_css("#file-uploading")
expect(composer.preview).to have_css(".image-wrapper")
composer.submit
expect_first_post_to_have_secure_upload
end
it "marks uploads inside of private category posts as secure" do
skip_unless_s3_system_specs_enabled!
private_category = Fabricate(:private_category, group: Fabricate(:group))
setup_s3_system_test(enable_secure_uploads: true)
sign_in(current_user)
topic_page.open_new_topic
composer.fill_title("This is a test PM for secure uploads")
composer.switch_category(private_category.name)
file_path = file_from_fixtures("logo.png", "images").path
attach_file(file_path) { composer.click_toolbar_button("upload") }
expect(page).to have_no_css("#file-uploading")
expect(composer.preview).to have_css(".image-wrapper")
composer.submit
expect_first_post_to_have_secure_upload
end
it "marks uploads for all posts as secure when login_required" do
skip_unless_s3_system_specs_enabled!
SiteSetting.login_required = true
setup_s3_system_test(enable_secure_uploads: true)
sign_in(current_user)
topic_page.open_new_topic
composer.fill_title("This is a test PM for secure uploads")
file_path = file_from_fixtures("logo.png", "images").path
attach_file(file_path) { composer.click_toolbar_button("upload") }
expect(page).to have_no_css("#file-uploading")
expect(composer.preview).to have_css(".image-wrapper")
composer.submit
expect_first_post_to_have_secure_upload
end
it "doesn't mark uploads for public posts as secure" do
skip_unless_s3_system_specs_enabled!
setup_s3_system_test(enable_secure_uploads: true)
sign_in(current_user)
topic_page.open_new_topic
composer.fill_title("This is a test PM for secure uploads")
file_path = file_from_fixtures("logo.png", "images").path
attach_file(file_path) { composer.click_toolbar_button("upload") }
expect(page).to have_no_css("#file-uploading")
expect(composer.preview).to have_css(".image-wrapper")
Jobs.run_immediately!
composer.submit
# Extra wait time is added because the job can slow down the processing of the request.
img = first_post_img(wait: 10)
expect(img["src"]).not_to include("/secure-uploads")
topic = topic_page.current_topic
expect(topic.first_post.uploads.first.secure).to eq(false)
end
end
end

View File

@ -5,10 +5,9 @@ describe "Uploading files in the composer to S3", type: :system do
let(:modal) { PageObjects::Modals::Base.new } let(:modal) { PageObjects::Modals::Base.new }
let(:composer) { PageObjects::Components::Composer.new } let(:composer) { PageObjects::Components::Composer.new }
let(:topic) { PageObjects::Pages::Topic.new }
describe "direct S3 uploads" do describe "direct S3 uploads" do
before { SiteSetting.enable_direct_s3_uploads = true }
describe "single part uploads" do describe "single part uploads" do
it "uploads custom avatars to S3" do it "uploads custom avatars to S3" do
skip_unless_s3_system_specs_enabled! skip_unless_s3_system_specs_enabled!
@ -43,7 +42,7 @@ describe "Uploading files in the composer to S3", type: :system do
setup_s3_system_test setup_s3_system_test
sign_in(current_user) sign_in(current_user)
visit "/new-topic" topic.open_new_topic
file_path = file_from_fixtures("logo.png", "images").path file_path = file_from_fixtures("logo.png", "images").path
attach_file(file_path) { composer.click_toolbar_button("upload") } attach_file(file_path) { composer.click_toolbar_button("upload") }

View File

@ -54,22 +54,47 @@ RSpec.describe "tasks/uploads" do
expect(upload3.reload.access_control_post).to eq(post3) expect(upload3.reload.access_control_post).to eq(post3)
end end
it "sets everything attached to a post as secure and rebakes all those posts if login is required" do context "when login_required" do
SiteSetting.login_required = true before { SiteSetting.login_required = true }
freeze_time
post1.update_columns(baked_at: 1.week.ago) it "sets everything attached to a post as secure and rebakes all those posts" do
post2.update_columns(baked_at: 1.week.ago) freeze_time
post3.update_columns(baked_at: 1.week.ago)
invoke_task post1.update_columns(baked_at: 1.week.ago)
post2.update_columns(baked_at: 1.week.ago)
post3.update_columns(baked_at: 1.week.ago)
expect(post1.reload.baked_at).not_to eq_time(1.week.ago) invoke_task
expect(post2.reload.baked_at).not_to eq_time(1.week.ago)
expect(post3.reload.baked_at).not_to eq_time(1.week.ago) expect(post1.reload.baked_at).not_to eq_time(1.week.ago)
expect(upload2.reload.secure).to eq(true) expect(post2.reload.baked_at).not_to eq_time(1.week.ago)
expect(upload1.reload.secure).to eq(true) expect(post3.reload.baked_at).not_to eq_time(1.week.ago)
expect(upload3.reload.secure).to eq(true) expect(upload2.reload.secure).to eq(true)
expect(upload1.reload.secure).to eq(true)
expect(upload3.reload.secure).to eq(true)
end
context "when secure_uploads_pm_only" do
before { SiteSetting.secure_uploads_pm_only = true }
it "only sets everything attached to a private message post as secure and rebakes all those posts" do
freeze_time
post1.update_columns(baked_at: 1.week.ago)
post2.update_columns(baked_at: 1.week.ago)
post3.update_columns(baked_at: 1.week.ago)
post3.topic.update(archetype: "private_message", category: nil)
invoke_task
expect(post1.reload.baked_at).to eq_time(1.week.ago)
expect(post2.reload.baked_at).to eq_time(1.week.ago)
expect(post3.reload.baked_at).not_to eq_time(1.week.ago)
expect(upload1.reload.secure).to eq(false)
expect(upload2.reload.secure).to eq(true)
expect(upload3.reload.secure).to eq(true)
end
end
end end
it "sets the uploads that are media and attachments in the read restricted topic category to secure" do it "sets the uploads that are media and attachments in the read restricted topic category to secure" do