SECURITY: Respect topic permissions when loading draft metadata

Co-authored-by: Sam Saffron <sam.saffron@gmail.com>
This commit is contained in:
David Taylor 2020-03-23 11:02:24 +00:00
parent 3f9b922d20
commit 5ff505cea6
No known key found for this signature in database
GPG Key ID: 46904C18B1D3F434
7 changed files with 188 additions and 83 deletions

View File

@ -23,17 +23,6 @@ class DraftsController < ApplicationController
} }
stream = Draft.stream(opts) stream = Draft.stream(opts)
stream.each do |d|
parsed_data = JSON.parse(d.data)
if parsed_data
if parsed_data['reply']
d.raw = parsed_data['reply']
end
if parsed_data['categoryId'].present? && !d.category_id.present?
d.category_id = parsed_data['categoryId']
end
end
end
render json: { render json: {
drafts: stream ? serialize_data(stream, DraftSerializer) : [], drafts: stream ? serialize_data(stream, DraftSerializer) : [],

View File

@ -5,6 +5,8 @@ class Draft < ActiveRecord::Base
NEW_PRIVATE_MESSAGE ||= 'new_private_message' NEW_PRIVATE_MESSAGE ||= 'new_private_message'
EXISTING_TOPIC ||= 'topic_' EXISTING_TOPIC ||= 'topic_'
belongs_to :user
class OutOfSequence < StandardError; end class OutOfSequence < StandardError; end
def self.set(user, key, sequence, data, owner = nil) def self.set(user, key, sequence, data, owner = nil)
@ -137,6 +139,72 @@ class Draft < ActiveRecord::Base
Draft.where(user_id: user.id, draft_key: key).destroy_all Draft.where(user_id: user.id, draft_key: key).destroy_all
end end
def display_user
post&.user || topic&.user || user
end
def parsed_data
JSON.parse(data)
end
def topic_id
if draft_key.starts_with?(EXISTING_TOPIC)
draft_key.gsub(EXISTING_TOPIC, "").to_i
end
end
def topic_preloaded?
!!defined?(@topic)
end
def topic
topic_preloaded? ? @topic : @topic = Draft.allowed_draft_topics_for_user(user).find_by(id: topic_id)
end
def preload_topic(topic)
@topic = topic
end
def post_id
parsed_data["postId"]
end
def post_preloaded?
!!defined?(@post)
end
def post
post_preloaded? ? @post : @post = Draft.allowed_draft_posts_for_user(user).find_by(id: post_id)
end
def preload_post(post)
@post = post
end
def self.preload_data(drafts, user)
topic_ids = drafts.map(&:topic_id)
post_ids = drafts.map(&:post_id)
topics = self.allowed_draft_topics_for_user(user).where(id: topic_ids)
posts = self.allowed_draft_posts_for_user(user).where(id: post_ids)
drafts.each do |draft|
draft.preload_topic(topics.detect { |t| t.id == draft.topic_id })
draft.preload_post(posts.detect { |p| p.id == draft.post_id })
end
end
def self.allowed_draft_topics_for_user(user)
topics = Topic.listable_topics.secured(Guardian.new(user))
pms = Topic.private_messages_for_user(user)
topics.or(pms)
end
def self.allowed_draft_posts_for_user(user)
# .secured handles whispers, merge handles topic/pm visibility
Post.secured(Guardian.new(user)).joins(:topic).merge(self.allowed_draft_topics_for_user(user))
end
def self.stream(opts = nil) def self.stream(opts = nil)
opts ||= {} opts ||= {}
@ -144,36 +212,15 @@ class Draft < ActiveRecord::Base
offset = (opts[:offset] || 0).to_i offset = (opts[:offset] || 0).to_i
limit = (opts[:limit] || 30).to_i limit = (opts[:limit] || 30).to_i
# JOIN of topics table based on manipulating draft_key seems imperfect stream = Draft.where(user_id: user_id)
builder = DB.build <<~SQL .order(updated_at: :desc)
SELECT
d.*, t.title, t.id topic_id, t.archetype,
t.category_id, t.closed topic_closed, t.archived topic_archived,
pu.username, pu.name, pu.id user_id, pu.uploaded_avatar_id, pu.username_lower,
du.username draft_username, NULL as raw, NULL as cooked, NULL as post_number
FROM drafts d
LEFT JOIN LATERAL json_extract_path_text (d.data::json, 'postId') postId ON TRUE
LEFT JOIN posts p ON postId :: BIGINT = p.id
LEFT JOIN topics t ON
CASE
WHEN d.draft_key LIKE '%' || '#{EXISTING_TOPIC}' || '%'
THEN CAST(replace(d.draft_key, '#{EXISTING_TOPIC}', '') AS INT)
ELSE 0
END = t.id
JOIN users pu on pu.id = COALESCE(p.user_id, t.user_id, d.user_id)
JOIN users du on du.id = #{user_id}
/*where*/
/*order_by*/
/*offset*/
/*limit*/
SQL
builder
.where('d.user_id = :user_id', user_id: user_id.to_i)
.order_by('d.updated_at desc')
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
.query
# Preload posts and topics to avoid N+1 queries
Draft.preload_data(stream, opts[:user])
stream
end end
def self.cleanup! def self.cleanup!

View File

@ -69,15 +69,7 @@ class Post < ActiveRecord::Base
register_custom_field_type(MISSING_UPLOADS_IGNORED, :boolean) register_custom_field_type(MISSING_UPLOADS_IGNORED, :boolean)
scope :private_posts_for_user, ->(user) { scope :private_posts_for_user, ->(user) {
where("posts.topic_id IN (SELECT topic_id where("posts.topic_id IN (#{Topic::PRIVATE_MESSAGES_SQL})", user_id: user.id)
FROM topic_allowed_users
WHERE user_id = :user_id
UNION ALL
SELECT tg.topic_id
FROM topic_allowed_groups tg
JOIN group_users gu ON gu.user_id = :user_id AND
gu.group_id = tg.group_id)",
user_id: user.id)
} }
scope :by_newest, -> { order('created_at DESC, id DESC') } scope :by_newest, -> { order('created_at DESC, id DESC') }

View File

@ -152,6 +152,20 @@ class Topic < ActiveRecord::Base
# Return private message topics # Return private message topics
scope :private_messages, -> { where(archetype: Archetype.private_message) } scope :private_messages, -> { where(archetype: Archetype.private_message) }
PRIVATE_MESSAGES_SQL = <<~SQL
SELECT topic_id
FROM topic_allowed_users
WHERE user_id = :user_id
UNION ALL
SELECT tg.topic_id
FROM topic_allowed_groups tg
JOIN group_users gu ON gu.user_id = :user_id AND gu.group_id = tg.group_id
SQL
scope :private_messages_for_user, ->(user) {
private_messages.where("topics.id IN (#{PRIVATE_MESSAGES_SQL})", user_id: user.id)
}
scope :listable_topics, -> { where('topics.archetype <> ?', Archetype.private_message) } scope :listable_topics, -> { where('topics.archetype <> ?', Archetype.private_message) }
scope :by_newest, -> { order('topics.created_at desc, topics.id desc') } scope :by_newest, -> { order('topics.created_at desc, topics.id desc') }

View File

@ -23,36 +23,68 @@ class DraftSerializer < ApplicationSerializer
:archetype, :archetype,
:archived :archived
def cooked
object.parsed_data['reply'] || ""
end
def draft_username
object.user.username
end
def avatar_template def avatar_template
User.avatar_template(object.username, object.uploaded_avatar_id) object.user.avatar_template
end
def username
object.display_user&.username
end
def username_lower
object.display_user&.username_lower
end
def name
object.display_user&.name
end
def title
object.topic&.title
end end
def slug def slug
Slug.for(object.title) object.topic&.slug
end end
def include_slug? def category_id
object.title.present? object.topic&.category_id
end end
def closed def closed
object.topic_closed object.topic&.closed
end end
def archived def archived
object.topic_archived object.topic&.archived
end
def archetype
object&.topic&.archetype
end
def include_slug?
object.topic&.title&.present?
end end
def include_closed? def include_closed?
object.topic_closed.present? object.topic&.closed&.present?
end end
def include_archived? def include_archived?
object.topic_archived.present? object.topic&.archived&.present?
end end
def include_category_id? def include_category_id?
object.category_id.present? object.topic&.category_id&.present?
end end
end end

View File

@ -8,6 +8,10 @@ describe Draft do
Fabricate(:user) Fabricate(:user)
end end
fab!(:post) do
Fabricate(:post)
end
context 'backup_drafts_to_pm_length' do context 'backup_drafts_to_pm_length' do
it "correctly backs up drafts to a personal message" do it "correctly backs up drafts to a personal message" do
SiteSetting.backup_drafts_to_pm_length = 1 SiteSetting.backup_drafts_to_pm_length = 1
@ -111,7 +115,6 @@ describe Draft do
end end
it 'can cleanup old drafts' do it 'can cleanup old drafts' do
user = Fabricate(:user)
key = Draft::NEW_TOPIC key = Draft::NEW_TOPIC
Draft.set(user, key, 0, 'draft') Draft.set(user, key, 0, 'draft')
@ -164,39 +167,35 @@ describe Draft do
it "should include the right draft username in the stream" do it "should include the right draft username in the stream" do
Draft.set(user, "topic_#{public_topic.id}", 0, '{"reply":"hey"}') Draft.set(user, "topic_#{public_topic.id}", 0, '{"reply":"hey"}')
draft_row = stream.first draft_row = stream.first
expect(draft_row.draft_username).to eq(user.username) expect(draft_row.user.username).to eq(user.username)
end end
end end
context 'key expiry' do context 'key expiry' do
it 'nukes new topic draft after a topic is created' do it 'nukes new topic draft after a topic is created' do
u = Fabricate(:user) Draft.set(user, Draft::NEW_TOPIC, 0, 'my draft')
Draft.set(u, Draft::NEW_TOPIC, 0, 'my draft') _t = Fabricate(:topic, user: user)
_t = Fabricate(:topic, user: u) s = DraftSequence.current(user, Draft::NEW_TOPIC)
s = DraftSequence.current(u, Draft::NEW_TOPIC) expect(Draft.get(user, Draft::NEW_TOPIC, s)).to eq nil
expect(Draft.get(u, Draft::NEW_TOPIC, s)).to eq nil
expect(Draft.count).to eq 0 expect(Draft.count).to eq 0
end end
it 'nukes new pm draft after a pm is created' do it 'nukes new pm draft after a pm is created' do
u = Fabricate(:user) Draft.set(user, Draft::NEW_PRIVATE_MESSAGE, 0, 'my draft')
Draft.set(u, Draft::NEW_PRIVATE_MESSAGE, 0, 'my draft') t = Fabricate(:topic, user: user, archetype: Archetype.private_message, category_id: nil)
t = Fabricate(:topic, user: u, archetype: Archetype.private_message, category_id: nil)
s = DraftSequence.current(t.user, Draft::NEW_PRIVATE_MESSAGE) s = DraftSequence.current(t.user, Draft::NEW_PRIVATE_MESSAGE)
expect(Draft.get(u, Draft::NEW_PRIVATE_MESSAGE, s)).to eq nil expect(Draft.get(user, Draft::NEW_PRIVATE_MESSAGE, s)).to eq nil
end end
it 'does not nuke new topic draft after a pm is created' do it 'does not nuke new topic draft after a pm is created' do
u = Fabricate(:user) Draft.set(user, Draft::NEW_TOPIC, 0, 'my draft')
Draft.set(u, Draft::NEW_TOPIC, 0, 'my draft') t = Fabricate(:topic, user: user, archetype: Archetype.private_message, category_id: nil)
t = Fabricate(:topic, user: u, archetype: Archetype.private_message, category_id: nil)
s = DraftSequence.current(t.user, Draft::NEW_TOPIC) s = DraftSequence.current(t.user, Draft::NEW_TOPIC)
expect(Draft.get(u, Draft::NEW_TOPIC, s)).to eq 'my draft' expect(Draft.get(user, Draft::NEW_TOPIC, s)).to eq 'my draft'
end end
it 'nukes the post draft when a post is created' do it 'nukes the post draft when a post is created' do
user = Fabricate(:user)
topic = Fabricate(:topic) topic = Fabricate(:topic)
Draft.set(user, topic.draft_key, 0, 'hello') Draft.set(user, topic.draft_key, 0, 'hello')
@ -207,23 +206,20 @@ describe Draft do
end end
it 'nukes the post draft when a post is revised' do it 'nukes the post draft when a post is revised' do
p = Fabricate(:post) Draft.set(post.user, post.topic.draft_key, 0, 'hello')
Draft.set(p.user, p.topic.draft_key, 0, 'hello') post.revise(post.user, raw: 'another test')
p.revise(p.user, raw: 'another test') s = DraftSequence.current(post.user, post.topic.draft_key)
s = DraftSequence.current(p.user, p.topic.draft_key) expect(Draft.get(post.user, post.topic.draft_key, s)).to eq nil
expect(Draft.get(p.user, p.topic.draft_key, s)).to eq nil
end end
it 'increases revision each time you set' do it 'increases revision each time you set' do
u = User.first Draft.set(user, 'new_topic', 0, 'hello')
Draft.set(u, 'new_topic', 0, 'hello') Draft.set(user, 'new_topic', 0, 'goodbye')
Draft.set(u, 'new_topic', 0, 'goodbye')
expect(Draft.find_by(user_id: u.id, draft_key: 'new_topic').revisions).to eq(2) expect(Draft.find_by(user_id: user.id, draft_key: 'new_topic').revisions).to eq(2)
end end
it 'handles owner switching gracefully' do it 'handles owner switching gracefully' do
user = Fabricate(:user)
draft_seq = Draft.set(user, 'new_topic', 0, 'hello', _owner = 'ABCDEF') draft_seq = Draft.set(user, 'new_topic', 0, 'hello', _owner = 'ABCDEF')
expect(draft_seq).to eq(0) expect(draft_seq).to eq(0)
@ -233,5 +229,20 @@ describe Draft do
draft_seq = Draft.set(user, 'new_topic', 1, 'hello world', _owner = 'HIJKL') draft_seq = Draft.set(user, 'new_topic', 1, 'hello world', _owner = 'HIJKL')
expect(draft_seq).to eq(1) expect(draft_seq).to eq(1)
end end
it 'can correctly preload drafts' do
Draft.set(user, "#{Draft::EXISTING_TOPIC}#{post.topic_id}", 0, { raw: 'hello', postId: post.id }.to_json)
drafts = Draft.where(user_id: user.id).to_a
Draft.preload_data(drafts, user)
expect(drafts[0].topic_preloaded?).to eq(true)
expect(drafts[0].topic.id).to eq(post.topic_id)
expect(drafts[0].post_preloaded?).to eq(true)
expect(drafts[0].post.id).to eq(post.id)
end
end end
end end

View File

@ -34,4 +34,24 @@ describe DraftsController do
get "/drafts.json", params: { username: user_b.username } get "/drafts.json", params: { username: user_b.username }
expect(response.status).to eq(403) expect(response.status).to eq(403)
end end
it 'does not include topic details when user cannot see topic' do
topic = Fabricate(:private_message_topic)
topic_user = topic.user
other_user = Fabricate(:user)
Draft.set(topic_user, "topic_#{topic.id}", 0, '{}')
Draft.set(other_user, "topic_#{topic.id}", 0, '{}')
sign_in(topic_user)
get "/drafts.json", params: { username: topic_user.username }
expect(response.status).to eq(200)
parsed = JSON.parse(response.body)
expect(parsed["drafts"].first["title"]).to eq(topic.title)
sign_in(other_user)
get "/drafts.json", params: { username: other_user.username }
expect(response.status).to eq(200)
parsed = JSON.parse(response.body)
expect(parsed["drafts"].first["title"]).to eq(nil)
end
end end